summaryrefslogtreecommitdiff
path: root/app/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main')
-rw-r--r--app/src/main/AndroidManifest.xml42
-rw-r--r--app/src/main/assets/Readability.js2786
-rw-r--r--app/src/main/java/org/terst/feedreader/data/AppDatabase.kt29
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Daos.kt93
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Entities.kt43
-rw-r--r--app/src/main/java/org/terst/feedreader/data/FeedParser.kt207
-rw-r--r--app/src/main/java/org/terst/feedreader/data/FilterRuleEngine.kt24
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Net.kt8
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Settings.kt34
-rw-r--r--app/src/main/java/org/terst/feedreader/opml/Opml.kt71
-rw-r--r--app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt95
-rw-r--r--app/src/main/java/org/terst/feedreader/stash/StashClient.kt82
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt152
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt109
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt123
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/MainActivity.kt79
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt166
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt162
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt113
-rw-r--r--app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt123
-rw-r--r--app/src/main/res/values/strings.xml4
-rw-r--r--app/src/main/res/values/themes.xml12
22 files changed, 4557 insertions, 0 deletions
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..76148e4
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+ <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
+
+ <application
+ android:label="Feeds"
+ android:theme="@style/Theme.FeedReader"
+ android:allowBackup="false">
+
+ <activity
+ android:name=".ui.MainActivity"
+ android:exported="true"
+ android:windowSoftInputMode="adjustResize">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ </activity>
+
+ <!--
+ Share-sheet target: a URL (or plain text containing one) shared from any
+ other app (browser, Twitter, etc.) lands here. Deliberately its own
+ activity (not MainActivity re-handling an intent) so it can show a minimal
+ "saving..." UI and finish immediately, rather than dropping the user into
+ the middle of the main feed/article navigation stack.
+ -->
+ <activity
+ android:name=".ui.ShareReceiverActivity"
+ android:theme="@style/Theme.FeedReader.Dialog"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="android.intent.action.SEND" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:mimeType="text/plain" />
+ </intent-filter>
+ </activity>
+
+ </application>
+</manifest>
diff --git a/app/src/main/assets/Readability.js b/app/src/main/assets/Readability.js
new file mode 100644
index 0000000..ad32a65
--- /dev/null
+++ b/app/src/main/assets/Readability.js
@@ -0,0 +1,2786 @@
+/*
+ * Copyright (c) 2010 Arc90 Inc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * This code is heavily based on Arc90's readability.js (1.7.1) script
+ * available at: http://code.google.com/p/arc90labs-readability
+ */
+
+/**
+ * Public constructor.
+ * @param {HTMLDocument} doc The document to parse.
+ * @param {Object} options The options object.
+ */
+function Readability(doc, options) {
+ // In some older versions, people passed a URI as the first argument. Cope:
+ if (options && options.documentElement) {
+ doc = options;
+ options = arguments[2];
+ } else if (!doc || !doc.documentElement) {
+ throw new Error(
+ "First argument to Readability constructor should be a document object."
+ );
+ }
+ options = options || {};
+
+ this._doc = doc;
+ this._docJSDOMParser = this._doc.firstChild.__JSDOMParser__;
+ this._articleTitle = null;
+ this._articleByline = null;
+ this._articleDir = null;
+ this._articleSiteName = null;
+ this._attempts = [];
+ this._metadata = {};
+
+ // Configurable options
+ this._debug = !!options.debug;
+ this._maxElemsToParse =
+ options.maxElemsToParse || this.DEFAULT_MAX_ELEMS_TO_PARSE;
+ this._nbTopCandidates =
+ options.nbTopCandidates || this.DEFAULT_N_TOP_CANDIDATES;
+ this._charThreshold = options.charThreshold || this.DEFAULT_CHAR_THRESHOLD;
+ this._classesToPreserve = this.CLASSES_TO_PRESERVE.concat(
+ options.classesToPreserve || []
+ );
+ this._keepClasses = !!options.keepClasses;
+ this._serializer =
+ options.serializer ||
+ function (el) {
+ return el.innerHTML;
+ };
+ this._disableJSONLD = !!options.disableJSONLD;
+ this._allowedVideoRegex = options.allowedVideoRegex || this.REGEXPS.videos;
+ this._linkDensityModifier = options.linkDensityModifier || 0;
+
+ // Start with all flags set
+ this._flags =
+ this.FLAG_STRIP_UNLIKELYS |
+ this.FLAG_WEIGHT_CLASSES |
+ this.FLAG_CLEAN_CONDITIONALLY;
+
+ // Control whether log messages are sent to the console
+ if (this._debug) {
+ let logNode = function (node) {
+ if (node.nodeType == node.TEXT_NODE) {
+ return `${node.nodeName} ("${node.textContent}")`;
+ }
+ let attrPairs = Array.from(node.attributes || [], function (attr) {
+ return `${attr.name}="${attr.value}"`;
+ }).join(" ");
+ return `<${node.localName} ${attrPairs}>`;
+ };
+ this.log = function () {
+ if (typeof console !== "undefined") {
+ let args = Array.from(arguments, arg => {
+ if (arg && arg.nodeType == this.ELEMENT_NODE) {
+ return logNode(arg);
+ }
+ return arg;
+ });
+ args.unshift("Reader: (Readability)");
+ // eslint-disable-next-line no-console
+ console.log(...args);
+ } else if (typeof dump !== "undefined") {
+ /* global dump */
+ var msg = Array.prototype.map
+ .call(arguments, function (x) {
+ return x && x.nodeName ? logNode(x) : x;
+ })
+ .join(" ");
+ dump("Reader: (Readability) " + msg + "\n");
+ }
+ };
+ } else {
+ this.log = function () {};
+ }
+}
+
+Readability.prototype = {
+ FLAG_STRIP_UNLIKELYS: 0x1,
+ FLAG_WEIGHT_CLASSES: 0x2,
+ FLAG_CLEAN_CONDITIONALLY: 0x4,
+
+ // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType
+ ELEMENT_NODE: 1,
+ TEXT_NODE: 3,
+
+ // Max number of nodes supported by this parser. Default: 0 (no limit)
+ DEFAULT_MAX_ELEMS_TO_PARSE: 0,
+
+ // The number of top candidates to consider when analysing how
+ // tight the competition is among candidates.
+ DEFAULT_N_TOP_CANDIDATES: 5,
+
+ // Element tags to score by default.
+ DEFAULT_TAGS_TO_SCORE: "section,h2,h3,h4,h5,h6,p,td,pre"
+ .toUpperCase()
+ .split(","),
+
+ // The default number of chars an article must have in order to return a result
+ DEFAULT_CHAR_THRESHOLD: 500,
+
+ // All of the regular expressions in use within readability.
+ // Defined up here so we don't instantiate them repeatedly in loops.
+ REGEXPS: {
+ // NOTE: These two regular expressions are duplicated in
+ // Readability-readerable.js. Please keep both copies in sync.
+ unlikelyCandidates:
+ /-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i,
+ okMaybeItsACandidate: /and|article|body|column|content|main|shadow/i,
+
+ positive:
+ /article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story/i,
+ negative:
+ /-ad-|hidden|^hid$| hid$| hid |^hid |banner|combx|comment|com-|contact|footer|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|widget/i,
+ extraneous:
+ /print|archive|comment|discuss|e[\-]?mail|share|reply|all|login|sign|single|utility/i,
+ byline: /byline|author|dateline|writtenby|p-author/i,
+ replaceFonts: /<(\/?)font[^>]*>/gi,
+ normalize: /\s{2,}/g,
+ videos:
+ /\/\/(www\.)?((dailymotion|youtube|youtube-nocookie|player\.vimeo|v\.qq)\.com|(archive|upload\.wikimedia)\.org|player\.twitch\.tv)/i,
+ shareElements: /(\b|_)(share|sharedaddy)(\b|_)/i,
+ nextLink: /(next|weiter|continue|>([^\|]|$)|»([^\|]|$))/i,
+ prevLink: /(prev|earl|old|new|<|«)/i,
+ tokenize: /\W+/g,
+ whitespace: /^\s*$/,
+ hasContent: /\S$/,
+ hashUrl: /^#.+/,
+ srcsetUrl: /(\S+)(\s+[\d.]+[xw])?(\s*(?:,|$))/g,
+ b64DataUrl: /^data:\s*([^\s;,]+)\s*;\s*base64\s*,/i,
+ // Commas as used in Latin, Sindhi, Chinese and various other scripts.
+ // see: https://en.wikipedia.org/wiki/Comma#Comma_variants
+ commas: /\u002C|\u060C|\uFE50|\uFE10|\uFE11|\u2E41|\u2E34|\u2E32|\uFF0C/g,
+ // See: https://schema.org/Article
+ jsonLdArticleTypes:
+ /^Article|AdvertiserContentArticle|NewsArticle|AnalysisNewsArticle|AskPublicNewsArticle|BackgroundNewsArticle|OpinionNewsArticle|ReportageNewsArticle|ReviewNewsArticle|Report|SatiricalArticle|ScholarlyArticle|MedicalScholarlyArticle|SocialMediaPosting|BlogPosting|LiveBlogPosting|DiscussionForumPosting|TechArticle|APIReference$/,
+ // used to see if a node's content matches words commonly used for ad blocks or loading indicators
+ adWords:
+ /^(ad(vertising|vertisement)?|pub(licité)?|werb(ung)?|广告|Реклама|Anuncio)$/iu,
+ loadingWords:
+ /^((loading|正在加载|Загрузка|chargement|cargando)(…|\.\.\.)?)$/iu,
+ },
+
+ UNLIKELY_ROLES: [
+ "menu",
+ "menubar",
+ "complementary",
+ "navigation",
+ "alert",
+ "alertdialog",
+ "dialog",
+ ],
+
+ DIV_TO_P_ELEMS: new Set([
+ "BLOCKQUOTE",
+ "DL",
+ "DIV",
+ "IMG",
+ "OL",
+ "P",
+ "PRE",
+ "TABLE",
+ "UL",
+ ]),
+
+ ALTER_TO_DIV_EXCEPTIONS: ["DIV", "ARTICLE", "SECTION", "P", "OL", "UL"],
+
+ PRESENTATIONAL_ATTRIBUTES: [
+ "align",
+ "background",
+ "bgcolor",
+ "border",
+ "cellpadding",
+ "cellspacing",
+ "frame",
+ "hspace",
+ "rules",
+ "style",
+ "valign",
+ "vspace",
+ ],
+
+ DEPRECATED_SIZE_ATTRIBUTE_ELEMS: ["TABLE", "TH", "TD", "HR", "PRE"],
+
+ // The commented out elements qualify as phrasing content but tend to be
+ // removed by readability when put into paragraphs, so we ignore them here.
+ PHRASING_ELEMS: [
+ // "CANVAS", "IFRAME", "SVG", "VIDEO",
+ "ABBR",
+ "AUDIO",
+ "B",
+ "BDO",
+ "BR",
+ "BUTTON",
+ "CITE",
+ "CODE",
+ "DATA",
+ "DATALIST",
+ "DFN",
+ "EM",
+ "EMBED",
+ "I",
+ "IMG",
+ "INPUT",
+ "KBD",
+ "LABEL",
+ "MARK",
+ "MATH",
+ "METER",
+ "NOSCRIPT",
+ "OBJECT",
+ "OUTPUT",
+ "PROGRESS",
+ "Q",
+ "RUBY",
+ "SAMP",
+ "SCRIPT",
+ "SELECT",
+ "SMALL",
+ "SPAN",
+ "STRONG",
+ "SUB",
+ "SUP",
+ "TEXTAREA",
+ "TIME",
+ "VAR",
+ "WBR",
+ ],
+
+ // These are the classes that readability sets itself.
+ CLASSES_TO_PRESERVE: ["page"],
+
+ // These are the list of HTML entities that need to be escaped.
+ HTML_ESCAPE_MAP: {
+ lt: "<",
+ gt: ">",
+ amp: "&",
+ quot: '"',
+ apos: "'",
+ },
+
+ /**
+ * Run any post-process modifications to article content as necessary.
+ *
+ * @param Element
+ * @return void
+ **/
+ _postProcessContent(articleContent) {
+ // Readability cannot open relative uris so we convert them to absolute uris.
+ this._fixRelativeUris(articleContent);
+
+ this._simplifyNestedElements(articleContent);
+
+ if (!this._keepClasses) {
+ // Remove classes.
+ this._cleanClasses(articleContent);
+ }
+ },
+
+ /**
+ * Iterates over a NodeList, calls `filterFn` for each node and removes node
+ * if function returned `true`.
+ *
+ * If function is not passed, removes all the nodes in node list.
+ *
+ * @param NodeList nodeList The nodes to operate on
+ * @param Function filterFn the function to use as a filter
+ * @return void
+ */
+ _removeNodes(nodeList, filterFn) {
+ // Avoid ever operating on live node lists.
+ if (this._docJSDOMParser && nodeList._isLiveNodeList) {
+ throw new Error("Do not pass live node lists to _removeNodes");
+ }
+ for (var i = nodeList.length - 1; i >= 0; i--) {
+ var node = nodeList[i];
+ var parentNode = node.parentNode;
+ if (parentNode) {
+ if (!filterFn || filterFn.call(this, node, i, nodeList)) {
+ parentNode.removeChild(node);
+ }
+ }
+ }
+ },
+
+ /**
+ * Iterates over a NodeList, and calls _setNodeTag for each node.
+ *
+ * @param NodeList nodeList The nodes to operate on
+ * @param String newTagName the new tag name to use
+ * @return void
+ */
+ _replaceNodeTags(nodeList, newTagName) {
+ // Avoid ever operating on live node lists.
+ if (this._docJSDOMParser && nodeList._isLiveNodeList) {
+ throw new Error("Do not pass live node lists to _replaceNodeTags");
+ }
+ for (const node of nodeList) {
+ this._setNodeTag(node, newTagName);
+ }
+ },
+
+ /**
+ * Iterate over a NodeList, which doesn't natively fully implement the Array
+ * interface.
+ *
+ * For convenience, the current object context is applied to the provided
+ * iterate function.
+ *
+ * @param NodeList nodeList The NodeList.
+ * @param Function fn The iterate function.
+ * @return void
+ */
+ _forEachNode(nodeList, fn) {
+ Array.prototype.forEach.call(nodeList, fn, this);
+ },
+
+ /**
+ * Iterate over a NodeList, and return the first node that passes
+ * the supplied test function
+ *
+ * For convenience, the current object context is applied to the provided
+ * test function.
+ *
+ * @param NodeList nodeList The NodeList.
+ * @param Function fn The test function.
+ * @return void
+ */
+ _findNode(nodeList, fn) {
+ return Array.prototype.find.call(nodeList, fn, this);
+ },
+
+ /**
+ * Iterate over a NodeList, return true if any of the provided iterate
+ * function calls returns true, false otherwise.
+ *
+ * For convenience, the current object context is applied to the
+ * provided iterate function.
+ *
+ * @param NodeList nodeList The NodeList.
+ * @param Function fn The iterate function.
+ * @return Boolean
+ */
+ _someNode(nodeList, fn) {
+ return Array.prototype.some.call(nodeList, fn, this);
+ },
+
+ /**
+ * Iterate over a NodeList, return true if all of the provided iterate
+ * function calls return true, false otherwise.
+ *
+ * For convenience, the current object context is applied to the
+ * provided iterate function.
+ *
+ * @param NodeList nodeList The NodeList.
+ * @param Function fn The iterate function.
+ * @return Boolean
+ */
+ _everyNode(nodeList, fn) {
+ return Array.prototype.every.call(nodeList, fn, this);
+ },
+
+ _getAllNodesWithTag(node, tagNames) {
+ if (node.querySelectorAll) {
+ return node.querySelectorAll(tagNames.join(","));
+ }
+ return [].concat.apply(
+ [],
+ tagNames.map(function (tag) {
+ var collection = node.getElementsByTagName(tag);
+ return Array.isArray(collection) ? collection : Array.from(collection);
+ })
+ );
+ },
+
+ /**
+ * Removes the class="" attribute from every element in the given
+ * subtree, except those that match CLASSES_TO_PRESERVE and
+ * the classesToPreserve array from the options object.
+ *
+ * @param Element
+ * @return void
+ */
+ _cleanClasses(node) {
+ var classesToPreserve = this._classesToPreserve;
+ var className = (node.getAttribute("class") || "")
+ .split(/\s+/)
+ .filter(cls => classesToPreserve.includes(cls))
+ .join(" ");
+
+ if (className) {
+ node.setAttribute("class", className);
+ } else {
+ node.removeAttribute("class");
+ }
+
+ for (node = node.firstElementChild; node; node = node.nextElementSibling) {
+ this._cleanClasses(node);
+ }
+ },
+
+ /**
+ * Tests whether a string is a URL or not.
+ *
+ * @param {string} str The string to test
+ * @return {boolean} true if str is a URL, false if not
+ */
+ _isUrl(str) {
+ try {
+ new URL(str);
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ /**
+ * Converts each <a> and <img> uri in the given element to an absolute URI,
+ * ignoring #ref URIs.
+ *
+ * @param Element
+ * @return void
+ */
+ _fixRelativeUris(articleContent) {
+ var baseURI = this._doc.baseURI;
+ var documentURI = this._doc.documentURI;
+ function toAbsoluteURI(uri) {
+ // Leave hash links alone if the base URI matches the document URI:
+ if (baseURI == documentURI && uri.charAt(0) == "#") {
+ return uri;
+ }
+
+ // Otherwise, resolve against base URI:
+ try {
+ return new URL(uri, baseURI).href;
+ } catch (ex) {
+ // Something went wrong, just return the original:
+ }
+ return uri;
+ }
+
+ var links = this._getAllNodesWithTag(articleContent, ["a"]);
+ this._forEachNode(links, function (link) {
+ var href = link.getAttribute("href");
+ if (href) {
+ // Remove links with javascript: URIs, since
+ // they won't work after scripts have been removed from the page.
+ if (href.indexOf("javascript:") === 0) {
+ // if the link only contains simple text content, it can be converted to a text node
+ if (
+ link.childNodes.length === 1 &&
+ link.childNodes[0].nodeType === this.TEXT_NODE
+ ) {
+ var text = this._doc.createTextNode(link.textContent);
+ link.parentNode.replaceChild(text, link);
+ } else {
+ // if the link has multiple children, they should all be preserved
+ var container = this._doc.createElement("span");
+ while (link.firstChild) {
+ container.appendChild(link.firstChild);
+ }
+ link.parentNode.replaceChild(container, link);
+ }
+ } else {
+ link.setAttribute("href", toAbsoluteURI(href));
+ }
+ }
+ });
+
+ var medias = this._getAllNodesWithTag(articleContent, [
+ "img",
+ "picture",
+ "figure",
+ "video",
+ "audio",
+ "source",
+ ]);
+
+ this._forEachNode(medias, function (media) {
+ var src = media.getAttribute("src");
+ var poster = media.getAttribute("poster");
+ var srcset = media.getAttribute("srcset");
+
+ if (src) {
+ media.setAttribute("src", toAbsoluteURI(src));
+ }
+
+ if (poster) {
+ media.setAttribute("poster", toAbsoluteURI(poster));
+ }
+
+ if (srcset) {
+ var newSrcset = srcset.replace(
+ this.REGEXPS.srcsetUrl,
+ function (_, p1, p2, p3) {
+ return toAbsoluteURI(p1) + (p2 || "") + p3;
+ }
+ );
+
+ media.setAttribute("srcset", newSrcset);
+ }
+ });
+ },
+
+ _simplifyNestedElements(articleContent) {
+ var node = articleContent;
+
+ while (node) {
+ if (
+ node.parentNode &&
+ ["DIV", "SECTION"].includes(node.tagName) &&
+ !(node.id && node.id.startsWith("readability"))
+ ) {
+ if (this._isElementWithoutContent(node)) {
+ node = this._removeAndGetNext(node);
+ continue;
+ } else if (
+ this._hasSingleTagInsideElement(node, "DIV") ||
+ this._hasSingleTagInsideElement(node, "SECTION")
+ ) {
+ var child = node.children[0];
+ for (var i = 0; i < node.attributes.length; i++) {
+ child.setAttributeNode(node.attributes[i].cloneNode());
+ }
+ node.parentNode.replaceChild(child, node);
+ node = child;
+ continue;
+ }
+ }
+
+ node = this._getNextNode(node);
+ }
+ },
+
+ /**
+ * Get the article title as an H1.
+ *
+ * @return string
+ **/
+ _getArticleTitle() {
+ var doc = this._doc;
+ var curTitle = "";
+ var origTitle = "";
+
+ try {
+ curTitle = origTitle = doc.title.trim();
+
+ // If they had an element with id "title" in their HTML
+ if (typeof curTitle !== "string") {
+ curTitle = origTitle = this._getInnerText(
+ doc.getElementsByTagName("title")[0]
+ );
+ }
+ } catch (e) {
+ /* ignore exceptions setting the title. */
+ }
+
+ var titleHadHierarchicalSeparators = false;
+ function wordCount(str) {
+ return str.split(/\s+/).length;
+ }
+
+ // If there's a separator in the title, first remove the final part
+ if (/ [\|\-\\\/>»] /.test(curTitle)) {
+ titleHadHierarchicalSeparators = / [\\\/>»] /.test(curTitle);
+ let allSeparators = Array.from(origTitle.matchAll(/ [\|\-\\\/>»] /gi));
+ curTitle = origTitle.substring(0, allSeparators.pop().index);
+
+ // If the resulting title is too short, remove the first part instead:
+ if (wordCount(curTitle) < 3) {
+ curTitle = origTitle.replace(/^[^\|\-\\\/>»]*[\|\-\\\/>»]/gi, "");
+ }
+ } else if (curTitle.includes(": ")) {
+ // Check if we have an heading containing this exact string, so we
+ // could assume it's the full title.
+ var headings = this._getAllNodesWithTag(doc, ["h1", "h2"]);
+ var trimmedTitle = curTitle.trim();
+ var match = this._someNode(headings, function (heading) {
+ return heading.textContent.trim() === trimmedTitle;
+ });
+
+ // If we don't, let's extract the title out of the original title string.
+ if (!match) {
+ curTitle = origTitle.substring(origTitle.lastIndexOf(":") + 1);
+
+ // If the title is now too short, try the first colon instead:
+ if (wordCount(curTitle) < 3) {
+ curTitle = origTitle.substring(origTitle.indexOf(":") + 1);
+ // But if we have too many words before the colon there's something weird
+ // with the titles and the H tags so let's just use the original title instead
+ } else if (wordCount(origTitle.substr(0, origTitle.indexOf(":"))) > 5) {
+ curTitle = origTitle;
+ }
+ }
+ } else if (curTitle.length > 150 || curTitle.length < 15) {
+ var hOnes = doc.getElementsByTagName("h1");
+
+ if (hOnes.length === 1) {
+ curTitle = this._getInnerText(hOnes[0]);
+ }
+ }
+
+ curTitle = curTitle.trim().replace(this.REGEXPS.normalize, " ");
+ // If we now have 4 words or fewer as our title, and either no
+ // 'hierarchical' separators (\, /, > or ») were found in the original
+ // title or we decreased the number of words by more than 1 word, use
+ // the original title.
+ var curTitleWordCount = wordCount(curTitle);
+ if (
+ curTitleWordCount <= 4 &&
+ (!titleHadHierarchicalSeparators ||
+ curTitleWordCount !=
+ wordCount(origTitle.replace(/[\|\-\\\/>»]+/g, "")) - 1)
+ ) {
+ curTitle = origTitle;
+ }
+
+ return curTitle;
+ },
+
+ /**
+ * Prepare the HTML document for readability to scrape it.
+ * This includes things like stripping javascript, CSS, and handling terrible markup.
+ *
+ * @return void
+ **/
+ _prepDocument() {
+ var doc = this._doc;
+
+ // Remove all style tags in head
+ this._removeNodes(this._getAllNodesWithTag(doc, ["style"]));
+
+ if (doc.body) {
+ this._replaceBrs(doc.body);
+ }
+
+ this._replaceNodeTags(this._getAllNodesWithTag(doc, ["font"]), "SPAN");
+ },
+
+ /**
+ * Finds the next node, starting from the given node, and ignoring
+ * whitespace in between. If the given node is an element, the same node is
+ * returned.
+ */
+ _nextNode(node) {
+ var next = node;
+ while (
+ next &&
+ next.nodeType != this.ELEMENT_NODE &&
+ this.REGEXPS.whitespace.test(next.textContent)
+ ) {
+ next = next.nextSibling;
+ }
+ return next;
+ },
+
+ /**
+ * Replaces 2 or more successive <br> elements with a single <p>.
+ * Whitespace between <br> elements are ignored. For example:
+ * <div>foo<br>bar<br> <br><br>abc</div>
+ * will become:
+ * <div>foo<br>bar<p>abc</p></div>
+ */
+ _replaceBrs(elem) {
+ this._forEachNode(this._getAllNodesWithTag(elem, ["br"]), function (br) {
+ var next = br.nextSibling;
+
+ // Whether 2 or more <br> elements have been found and replaced with a
+ // <p> block.
+ var replaced = false;
+
+ // If we find a <br> chain, remove the <br>s until we hit another node
+ // or non-whitespace. This leaves behind the first <br> in the chain
+ // (which will be replaced with a <p> later).
+ while ((next = this._nextNode(next)) && next.tagName == "BR") {
+ replaced = true;
+ var brSibling = next.nextSibling;
+ next.remove();
+ next = brSibling;
+ }
+
+ // If we removed a <br> chain, replace the remaining <br> with a <p>. Add
+ // all sibling nodes as children of the <p> until we hit another <br>
+ // chain.
+ if (replaced) {
+ var p = this._doc.createElement("p");
+ br.parentNode.replaceChild(p, br);
+
+ next = p.nextSibling;
+ while (next) {
+ // If we've hit another <br><br>, we're done adding children to this <p>.
+ if (next.tagName == "BR") {
+ var nextElem = this._nextNode(next.nextSibling);
+ if (nextElem && nextElem.tagName == "BR") {
+ break;
+ }
+ }
+
+ if (!this._isPhrasingContent(next)) {
+ break;
+ }
+
+ // Otherwise, make this node a child of the new <p>.
+ var sibling = next.nextSibling;
+ p.appendChild(next);
+ next = sibling;
+ }
+
+ while (p.lastChild && this._isWhitespace(p.lastChild)) {
+ p.lastChild.remove();
+ }
+
+ if (p.parentNode.tagName === "P") {
+ this._setNodeTag(p.parentNode, "DIV");
+ }
+ }
+ });
+ },
+
+ _setNodeTag(node, tag) {
+ this.log("_setNodeTag", node, tag);
+ if (this._docJSDOMParser) {
+ node.localName = tag.toLowerCase();
+ node.tagName = tag.toUpperCase();
+ return node;
+ }
+
+ var replacement = node.ownerDocument.createElement(tag);
+ while (node.firstChild) {
+ replacement.appendChild(node.firstChild);
+ }
+ node.parentNode.replaceChild(replacement, node);
+ if (node.readability) {
+ replacement.readability = node.readability;
+ }
+
+ for (var i = 0; i < node.attributes.length; i++) {
+ replacement.setAttributeNode(node.attributes[i].cloneNode());
+ }
+ return replacement;
+ },
+
+ /**
+ * Prepare the article node for display. Clean out any inline styles,
+ * iframes, forms, strip extraneous <p> tags, etc.
+ *
+ * @param Element
+ * @return void
+ **/
+ _prepArticle(articleContent) {
+ this._cleanStyles(articleContent);
+
+ // Check for data tables before we continue, to avoid removing items in
+ // those tables, which will often be isolated even though they're
+ // visually linked to other content-ful elements (text, images, etc.).
+ this._markDataTables(articleContent);
+
+ this._fixLazyImages(articleContent);
+
+ // Clean out junk from the article content
+ this._cleanConditionally(articleContent, "form");
+ this._cleanConditionally(articleContent, "fieldset");
+ this._clean(articleContent, "object");
+ this._clean(articleContent, "embed");
+ this._clean(articleContent, "footer");
+ this._clean(articleContent, "link");
+ this._clean(articleContent, "aside");
+
+ // Clean out elements with little content that have "share" in their id/class combinations from final top candidates,
+ // which means we don't remove the top candidates even they have "share".
+
+ var shareElementThreshold = this.DEFAULT_CHAR_THRESHOLD;
+
+ this._forEachNode(articleContent.children, function (topCandidate) {
+ this._cleanMatchedNodes(topCandidate, function (node, matchString) {
+ return (
+ this.REGEXPS.shareElements.test(matchString) &&
+ node.textContent.length < shareElementThreshold
+ );
+ });
+ });
+
+ this._clean(articleContent, "iframe");
+ this._clean(articleContent, "input");
+ this._clean(articleContent, "textarea");
+ this._clean(articleContent, "select");
+ this._clean(articleContent, "button");
+ this._cleanHeaders(articleContent);
+
+ // Do these last as the previous stuff may have removed junk
+ // that will affect these
+ this._cleanConditionally(articleContent, "table");
+ this._cleanConditionally(articleContent, "ul");
+ this._cleanConditionally(articleContent, "div");
+
+ // replace H1 with H2 as H1 should be only title that is displayed separately
+ this._replaceNodeTags(
+ this._getAllNodesWithTag(articleContent, ["h1"]),
+ "h2"
+ );
+
+ // Remove extra paragraphs
+ this._removeNodes(
+ this._getAllNodesWithTag(articleContent, ["p"]),
+ function (paragraph) {
+ // At this point, nasty iframes have been removed; only embedded video
+ // ones remain.
+ var contentElementCount = this._getAllNodesWithTag(paragraph, [
+ "img",
+ "embed",
+ "object",
+ "iframe",
+ ]).length;
+ return (
+ contentElementCount === 0 && !this._getInnerText(paragraph, false)
+ );
+ }
+ );
+
+ this._forEachNode(
+ this._getAllNodesWithTag(articleContent, ["br"]),
+ function (br) {
+ var next = this._nextNode(br.nextSibling);
+ if (next && next.tagName == "P") {
+ br.remove();
+ }
+ }
+ );
+
+ // Remove single-cell tables
+ this._forEachNode(
+ this._getAllNodesWithTag(articleContent, ["table"]),
+ function (table) {
+ var tbody = this._hasSingleTagInsideElement(table, "TBODY")
+ ? table.firstElementChild
+ : table;
+ if (this._hasSingleTagInsideElement(tbody, "TR")) {
+ var row = tbody.firstElementChild;
+ if (this._hasSingleTagInsideElement(row, "TD")) {
+ var cell = row.firstElementChild;
+ cell = this._setNodeTag(
+ cell,
+ this._everyNode(cell.childNodes, this._isPhrasingContent)
+ ? "P"
+ : "DIV"
+ );
+ table.parentNode.replaceChild(cell, table);
+ }
+ }
+ }
+ );
+ },
+
+ /**
+ * Initialize a node with the readability object. Also checks the
+ * className/id for special names to add to its score.
+ *
+ * @param Element
+ * @return void
+ **/
+ _initializeNode(node) {
+ node.readability = { contentScore: 0 };
+
+ switch (node.tagName) {
+ case "DIV":
+ node.readability.contentScore += 5;
+ break;
+
+ case "PRE":
+ case "TD":
+ case "BLOCKQUOTE":
+ node.readability.contentScore += 3;
+ break;
+
+ case "ADDRESS":
+ case "OL":
+ case "UL":
+ case "DL":
+ case "DD":
+ case "DT":
+ case "LI":
+ case "FORM":
+ node.readability.contentScore -= 3;
+ break;
+
+ case "H1":
+ case "H2":
+ case "H3":
+ case "H4":
+ case "H5":
+ case "H6":
+ case "TH":
+ node.readability.contentScore -= 5;
+ break;
+ }
+
+ node.readability.contentScore += this._getClassWeight(node);
+ },
+
+ _removeAndGetNext(node) {
+ var nextNode = this._getNextNode(node, true);
+ node.remove();
+ return nextNode;
+ },
+
+ /**
+ * Traverse the DOM from node to node, starting at the node passed in.
+ * Pass true for the second parameter to indicate this node itself
+ * (and its kids) are going away, and we want the next node over.
+ *
+ * Calling this in a loop will traverse the DOM depth-first.
+ *
+ * @param {Element} node
+ * @param {boolean} ignoreSelfAndKids
+ * @return {Element}
+ */
+ _getNextNode(node, ignoreSelfAndKids) {
+ // First check for kids if those aren't being ignored
+ if (!ignoreSelfAndKids && node.firstElementChild) {
+ return node.firstElementChild;
+ }
+ // Then for siblings...
+ if (node.nextElementSibling) {
+ return node.nextElementSibling;
+ }
+ // And finally, move up the parent chain *and* find a sibling
+ // (because this is depth-first traversal, we will have already
+ // seen the parent nodes themselves).
+ do {
+ node = node.parentNode;
+ } while (node && !node.nextElementSibling);
+ return node && node.nextElementSibling;
+ },
+
+ // compares second text to first one
+ // 1 = same text, 0 = completely different text
+ // works the way that it splits both texts into words and then finds words that are unique in second text
+ // the result is given by the lower length of unique parts
+ _textSimilarity(textA, textB) {
+ var tokensA = textA
+ .toLowerCase()
+ .split(this.REGEXPS.tokenize)
+ .filter(Boolean);
+ var tokensB = textB
+ .toLowerCase()
+ .split(this.REGEXPS.tokenize)
+ .filter(Boolean);
+ if (!tokensA.length || !tokensB.length) {
+ return 0;
+ }
+ var uniqTokensB = tokensB.filter(token => !tokensA.includes(token));
+ var distanceB = uniqTokensB.join(" ").length / tokensB.join(" ").length;
+ return 1 - distanceB;
+ },
+
+ /**
+ * Checks whether an element node contains a valid byline
+ *
+ * @param node {Element}
+ * @param matchString {string}
+ * @return boolean
+ */
+ _isValidByline(node, matchString) {
+ var rel = node.getAttribute("rel");
+ var itemprop = node.getAttribute("itemprop");
+ var bylineLength = node.textContent.trim().length;
+
+ return (
+ (rel === "author" ||
+ (itemprop && itemprop.includes("author")) ||
+ this.REGEXPS.byline.test(matchString)) &&
+ !!bylineLength &&
+ bylineLength < 100
+ );
+ },
+
+ _getNodeAncestors(node, maxDepth) {
+ maxDepth = maxDepth || 0;
+ var i = 0,
+ ancestors = [];
+ while (node.parentNode) {
+ ancestors.push(node.parentNode);
+ if (maxDepth && ++i === maxDepth) {
+ break;
+ }
+ node = node.parentNode;
+ }
+ return ancestors;
+ },
+
+ /***
+ * grabArticle - Using a variety of metrics (content score, classname, element types), find the content that is
+ * most likely to be the stuff a user wants to read. Then return it wrapped up in a div.
+ *
+ * @param page a document to run upon. Needs to be a full document, complete with body.
+ * @return Element
+ **/
+ /* eslint-disable-next-line complexity */
+ _grabArticle(page) {
+ this.log("**** grabArticle ****");
+ var doc = this._doc;
+ var isPaging = page !== null;
+ page = page ? page : this._doc.body;
+
+ // We can't grab an article if we don't have a page!
+ if (!page) {
+ this.log("No body found in document. Abort.");
+ return null;
+ }
+
+ var pageCacheHtml = page.innerHTML;
+
+ while (true) {
+ this.log("Starting grabArticle loop");
+ var stripUnlikelyCandidates = this._flagIsActive(
+ this.FLAG_STRIP_UNLIKELYS
+ );
+
+ // First, node prepping. Trash nodes that look cruddy (like ones with the
+ // class name "comment", etc), and turn divs into P tags where they have been
+ // used inappropriately (as in, where they contain no other block level elements.)
+ var elementsToScore = [];
+ var node = this._doc.documentElement;
+
+ let shouldRemoveTitleHeader = true;
+
+ while (node) {
+ if (node.tagName === "HTML") {
+ this._articleLang = node.getAttribute("lang");
+ }
+
+ var matchString = node.className + " " + node.id;
+
+ if (!this._isProbablyVisible(node)) {
+ this.log("Removing hidden node - " + matchString);
+ node = this._removeAndGetNext(node);
+ continue;
+ }
+
+ // User is not able to see elements applied with both "aria-modal = true" and "role = dialog"
+ if (
+ node.getAttribute("aria-modal") == "true" &&
+ node.getAttribute("role") == "dialog"
+ ) {
+ node = this._removeAndGetNext(node);
+ continue;
+ }
+
+ // If we don't have a byline yet check to see if this node is a byline; if it is store the byline and remove the node.
+ if (
+ !this._articleByline &&
+ !this._metadata.byline &&
+ this._isValidByline(node, matchString)
+ ) {
+ // Find child node matching [itemprop="name"] and use that if it exists for a more accurate author name byline
+ var endOfSearchMarkerNode = this._getNextNode(node, true);
+ var next = this._getNextNode(node);
+ var itemPropNameNode = null;
+ while (next && next != endOfSearchMarkerNode) {
+ var itemprop = next.getAttribute("itemprop");
+ if (itemprop && itemprop.includes("name")) {
+ itemPropNameNode = next;
+ break;
+ } else {
+ next = this._getNextNode(next);
+ }
+ }
+ this._articleByline = (itemPropNameNode ?? node).textContent.trim();
+ node = this._removeAndGetNext(node);
+ continue;
+ }
+
+ if (shouldRemoveTitleHeader && this._headerDuplicatesTitle(node)) {
+ this.log(
+ "Removing header: ",
+ node.textContent.trim(),
+ this._articleTitle.trim()
+ );
+ shouldRemoveTitleHeader = false;
+ node = this._removeAndGetNext(node);
+ continue;
+ }
+
+ // Remove unlikely candidates
+ if (stripUnlikelyCandidates) {
+ if (
+ this.REGEXPS.unlikelyCandidates.test(matchString) &&
+ !this.REGEXPS.okMaybeItsACandidate.test(matchString) &&
+ !this._hasAncestorTag(node, "table") &&
+ !this._hasAncestorTag(node, "code") &&
+ node.tagName !== "BODY" &&
+ node.tagName !== "A"
+ ) {
+ this.log("Removing unlikely candidate - " + matchString);
+ node = this._removeAndGetNext(node);
+ continue;
+ }
+
+ if (this.UNLIKELY_ROLES.includes(node.getAttribute("role"))) {
+ this.log(
+ "Removing content with role " +
+ node.getAttribute("role") +
+ " - " +
+ matchString
+ );
+ node = this._removeAndGetNext(node);
+ continue;
+ }
+ }
+
+ // Remove DIV, SECTION, and HEADER nodes without any content(e.g. text, image, video, or iframe).
+ if (
+ (node.tagName === "DIV" ||
+ node.tagName === "SECTION" ||
+ node.tagName === "HEADER" ||
+ node.tagName === "H1" ||
+ node.tagName === "H2" ||
+ node.tagName === "H3" ||
+ node.tagName === "H4" ||
+ node.tagName === "H5" ||
+ node.tagName === "H6") &&
+ this._isElementWithoutContent(node)
+ ) {
+ node = this._removeAndGetNext(node);
+ continue;
+ }
+
+ if (this.DEFAULT_TAGS_TO_SCORE.includes(node.tagName)) {
+ elementsToScore.push(node);
+ }
+
+ // Turn all divs that don't have children block level elements into p's
+ if (node.tagName === "DIV") {
+ // Put phrasing content into paragraphs.
+ var p = null;
+ var childNode = node.firstChild;
+ while (childNode) {
+ var nextSibling = childNode.nextSibling;
+ if (this._isPhrasingContent(childNode)) {
+ if (p !== null) {
+ p.appendChild(childNode);
+ } else if (!this._isWhitespace(childNode)) {
+ p = doc.createElement("p");
+ node.replaceChild(p, childNode);
+ p.appendChild(childNode);
+ }
+ } else if (p !== null) {
+ while (p.lastChild && this._isWhitespace(p.lastChild)) {
+ p.lastChild.remove();
+ }
+ p = null;
+ }
+ childNode = nextSibling;
+ }
+
+ // Sites like http://mobile.slate.com encloses each paragraph with a DIV
+ // element. DIVs with only a P element inside and no text content can be
+ // safely converted into plain P elements to avoid confusing the scoring
+ // algorithm with DIVs with are, in practice, paragraphs.
+ if (
+ this._hasSingleTagInsideElement(node, "P") &&
+ this._getLinkDensity(node) < 0.25
+ ) {
+ var newNode = node.children[0];
+ node.parentNode.replaceChild(newNode, node);
+ node = newNode;
+ elementsToScore.push(node);
+ } else if (!this._hasChildBlockElement(node)) {
+ node = this._setNodeTag(node, "P");
+ elementsToScore.push(node);
+ }
+ }
+ node = this._getNextNode(node);
+ }
+
+ /**
+ * Loop through all paragraphs, and assign a score to them based on how content-y they look.
+ * Then add their score to their parent node.
+ *
+ * A score is determined by things like number of commas, class names, etc. Maybe eventually link density.
+ **/
+ var candidates = [];
+ this._forEachNode(elementsToScore, function (elementToScore) {
+ if (
+ !elementToScore.parentNode ||
+ typeof elementToScore.parentNode.tagName === "undefined"
+ ) {
+ return;
+ }
+
+ // If this paragraph is less than 25 characters, don't even count it.
+ var innerText = this._getInnerText(elementToScore);
+ if (innerText.length < 25) {
+ return;
+ }
+
+ // Exclude nodes with no ancestor.
+ var ancestors = this._getNodeAncestors(elementToScore, 5);
+ if (ancestors.length === 0) {
+ return;
+ }
+
+ var contentScore = 0;
+
+ // Add a point for the paragraph itself as a base.
+ contentScore += 1;
+
+ // Add points for any commas within this paragraph.
+ contentScore += innerText.split(this.REGEXPS.commas).length;
+
+ // For every 100 characters in this paragraph, add another point. Up to 3 points.
+ contentScore += Math.min(Math.floor(innerText.length / 100), 3);
+
+ // Initialize and score ancestors.
+ this._forEachNode(ancestors, function (ancestor, level) {
+ if (
+ !ancestor.tagName ||
+ !ancestor.parentNode ||
+ typeof ancestor.parentNode.tagName === "undefined"
+ ) {
+ return;
+ }
+
+ if (typeof ancestor.readability === "undefined") {
+ this._initializeNode(ancestor);
+ candidates.push(ancestor);
+ }
+
+ // Node score divider:
+ // - parent: 1 (no division)
+ // - grandparent: 2
+ // - great grandparent+: ancestor level * 3
+ if (level === 0) {
+ var scoreDivider = 1;
+ } else if (level === 1) {
+ scoreDivider = 2;
+ } else {
+ scoreDivider = level * 3;
+ }
+ ancestor.readability.contentScore += contentScore / scoreDivider;
+ });
+ });
+
+ // After we've calculated scores, loop through all of the possible
+ // candidate nodes we found and find the one with the highest score.
+ var topCandidates = [];
+ for (var c = 0, cl = candidates.length; c < cl; c += 1) {
+ var candidate = candidates[c];
+
+ // Scale the final candidates score based on link density. Good content
+ // should have a relatively small link density (5% or less) and be mostly
+ // unaffected by this operation.
+ var candidateScore =
+ candidate.readability.contentScore *
+ (1 - this._getLinkDensity(candidate));
+ candidate.readability.contentScore = candidateScore;
+
+ this.log("Candidate:", candidate, "with score " + candidateScore);
+
+ for (var t = 0; t < this._nbTopCandidates; t++) {
+ var aTopCandidate = topCandidates[t];
+
+ if (
+ !aTopCandidate ||
+ candidateScore > aTopCandidate.readability.contentScore
+ ) {
+ topCandidates.splice(t, 0, candidate);
+ if (topCandidates.length > this._nbTopCandidates) {
+ topCandidates.pop();
+ }
+ break;
+ }
+ }
+ }
+
+ var topCandidate = topCandidates[0] || null;
+ var neededToCreateTopCandidate = false;
+ var parentOfTopCandidate;
+
+ // If we still have no top candidate, just use the body as a last resort.
+ // We also have to copy the body node so it is something we can modify.
+ if (topCandidate === null || topCandidate.tagName === "BODY") {
+ // Move all of the page's children into topCandidate
+ topCandidate = doc.createElement("DIV");
+ neededToCreateTopCandidate = true;
+ // Move everything (not just elements, also text nodes etc.) into the container
+ // so we even include text directly in the body:
+ while (page.firstChild) {
+ this.log("Moving child out:", page.firstChild);
+ topCandidate.appendChild(page.firstChild);
+ }
+
+ page.appendChild(topCandidate);
+
+ this._initializeNode(topCandidate);
+ } else if (topCandidate) {
+ // Find a better top candidate node if it contains (at least three) nodes which belong to `topCandidates` array
+ // and whose scores are quite closed with current `topCandidate` node.
+ var alternativeCandidateAncestors = [];
+ for (var i = 1; i < topCandidates.length; i++) {
+ if (
+ topCandidates[i].readability.contentScore /
+ topCandidate.readability.contentScore >=
+ 0.75
+ ) {
+ alternativeCandidateAncestors.push(
+ this._getNodeAncestors(topCandidates[i])
+ );
+ }
+ }
+ var MINIMUM_TOPCANDIDATES = 3;
+ if (alternativeCandidateAncestors.length >= MINIMUM_TOPCANDIDATES) {
+ parentOfTopCandidate = topCandidate.parentNode;
+ while (parentOfTopCandidate.tagName !== "BODY") {
+ var listsContainingThisAncestor = 0;
+ for (
+ var ancestorIndex = 0;
+ ancestorIndex < alternativeCandidateAncestors.length &&
+ listsContainingThisAncestor < MINIMUM_TOPCANDIDATES;
+ ancestorIndex++
+ ) {
+ listsContainingThisAncestor += Number(
+ alternativeCandidateAncestors[ancestorIndex].includes(
+ parentOfTopCandidate
+ )
+ );
+ }
+ if (listsContainingThisAncestor >= MINIMUM_TOPCANDIDATES) {
+ topCandidate = parentOfTopCandidate;
+ break;
+ }
+ parentOfTopCandidate = parentOfTopCandidate.parentNode;
+ }
+ }
+ if (!topCandidate.readability) {
+ this._initializeNode(topCandidate);
+ }
+
+ // Because of our bonus system, parents of candidates might have scores
+ // themselves. They get half of the node. There won't be nodes with higher
+ // scores than our topCandidate, but if we see the score going *up* in the first
+ // few steps up the tree, that's a decent sign that there might be more content
+ // lurking in other places that we want to unify in. The sibling stuff
+ // below does some of that - but only if we've looked high enough up the DOM
+ // tree.
+ parentOfTopCandidate = topCandidate.parentNode;
+ var lastScore = topCandidate.readability.contentScore;
+ // The scores shouldn't get too low.
+ var scoreThreshold = lastScore / 3;
+ while (parentOfTopCandidate.tagName !== "BODY") {
+ if (!parentOfTopCandidate.readability) {
+ parentOfTopCandidate = parentOfTopCandidate.parentNode;
+ continue;
+ }
+ var parentScore = parentOfTopCandidate.readability.contentScore;
+ if (parentScore < scoreThreshold) {
+ break;
+ }
+ if (parentScore > lastScore) {
+ // Alright! We found a better parent to use.
+ topCandidate = parentOfTopCandidate;
+ break;
+ }
+ lastScore = parentOfTopCandidate.readability.contentScore;
+ parentOfTopCandidate = parentOfTopCandidate.parentNode;
+ }
+
+ // If the top candidate is the only child, use parent instead. This will help sibling
+ // joining logic when adjacent content is actually located in parent's sibling node.
+ parentOfTopCandidate = topCandidate.parentNode;
+ while (
+ parentOfTopCandidate.tagName != "BODY" &&
+ parentOfTopCandidate.children.length == 1
+ ) {
+ topCandidate = parentOfTopCandidate;
+ parentOfTopCandidate = topCandidate.parentNode;
+ }
+ if (!topCandidate.readability) {
+ this._initializeNode(topCandidate);
+ }
+ }
+
+ // Now that we have the top candidate, look through its siblings for content
+ // that might also be related. Things like preambles, content split by ads
+ // that we removed, etc.
+ var articleContent = doc.createElement("DIV");
+ if (isPaging) {
+ articleContent.id = "readability-content";
+ }
+
+ var siblingScoreThreshold = Math.max(
+ 10,
+ topCandidate.readability.contentScore * 0.2
+ );
+ // Keep potential top candidate's parent node to try to get text direction of it later.
+ parentOfTopCandidate = topCandidate.parentNode;
+ var siblings = parentOfTopCandidate.children;
+
+ for (var s = 0, sl = siblings.length; s < sl; s++) {
+ var sibling = siblings[s];
+ var append = false;
+
+ this.log(
+ "Looking at sibling node:",
+ sibling,
+ sibling.readability
+ ? "with score " + sibling.readability.contentScore
+ : ""
+ );
+ this.log(
+ "Sibling has score",
+ sibling.readability ? sibling.readability.contentScore : "Unknown"
+ );
+
+ if (sibling === topCandidate) {
+ append = true;
+ } else {
+ var contentBonus = 0;
+
+ // Give a bonus if sibling nodes and top candidates have the example same classname
+ if (
+ sibling.className === topCandidate.className &&
+ topCandidate.className !== ""
+ ) {
+ contentBonus += topCandidate.readability.contentScore * 0.2;
+ }
+
+ if (
+ sibling.readability &&
+ sibling.readability.contentScore + contentBonus >=
+ siblingScoreThreshold
+ ) {
+ append = true;
+ } else if (sibling.nodeName === "P") {
+ var linkDensity = this._getLinkDensity(sibling);
+ var nodeContent = this._getInnerText(sibling);
+ var nodeLength = nodeContent.length;
+
+ if (nodeLength > 80 && linkDensity < 0.25) {
+ append = true;
+ } else if (
+ nodeLength < 80 &&
+ nodeLength > 0 &&
+ linkDensity === 0 &&
+ nodeContent.search(/\.( |$)/) !== -1
+ ) {
+ append = true;
+ }
+ }
+ }
+
+ if (append) {
+ this.log("Appending node:", sibling);
+
+ if (!this.ALTER_TO_DIV_EXCEPTIONS.includes(sibling.nodeName)) {
+ // We have a node that isn't a common block level element, like a form or td tag.
+ // Turn it into a div so it doesn't get filtered out later by accident.
+ this.log("Altering sibling:", sibling, "to div.");
+
+ sibling = this._setNodeTag(sibling, "DIV");
+ }
+
+ articleContent.appendChild(sibling);
+ // Fetch children again to make it compatible
+ // with DOM parsers without live collection support.
+ siblings = parentOfTopCandidate.children;
+ // siblings is a reference to the children array, and
+ // sibling is removed from the array when we call appendChild().
+ // As a result, we must revisit this index since the nodes
+ // have been shifted.
+ s -= 1;
+ sl -= 1;
+ }
+ }
+
+ if (this._debug) {
+ this.log("Article content pre-prep: " + articleContent.innerHTML);
+ }
+ // So we have all of the content that we need. Now we clean it up for presentation.
+ this._prepArticle(articleContent);
+ if (this._debug) {
+ this.log("Article content post-prep: " + articleContent.innerHTML);
+ }
+
+ if (neededToCreateTopCandidate) {
+ // We already created a fake div thing, and there wouldn't have been any siblings left
+ // for the previous loop, so there's no point trying to create a new div, and then
+ // move all the children over. Just assign IDs and class names here. No need to append
+ // because that already happened anyway.
+ topCandidate.id = "readability-page-1";
+ topCandidate.className = "page";
+ } else {
+ var div = doc.createElement("DIV");
+ div.id = "readability-page-1";
+ div.className = "page";
+ while (articleContent.firstChild) {
+ div.appendChild(articleContent.firstChild);
+ }
+ articleContent.appendChild(div);
+ }
+
+ if (this._debug) {
+ this.log("Article content after paging: " + articleContent.innerHTML);
+ }
+
+ var parseSuccessful = true;
+
+ // Now that we've gone through the full algorithm, check to see if
+ // we got any meaningful content. If we didn't, we may need to re-run
+ // grabArticle with different flags set. This gives us a higher likelihood of
+ // finding the content, and the sieve approach gives us a higher likelihood of
+ // finding the -right- content.
+ var textLength = this._getInnerText(articleContent, true).length;
+ if (textLength < this._charThreshold) {
+ parseSuccessful = false;
+ // eslint-disable-next-line no-unsanitized/property
+ page.innerHTML = pageCacheHtml;
+
+ this._attempts.push({
+ articleContent,
+ textLength,
+ });
+
+ if (this._flagIsActive(this.FLAG_STRIP_UNLIKELYS)) {
+ this._removeFlag(this.FLAG_STRIP_UNLIKELYS);
+ } else if (this._flagIsActive(this.FLAG_WEIGHT_CLASSES)) {
+ this._removeFlag(this.FLAG_WEIGHT_CLASSES);
+ } else if (this._flagIsActive(this.FLAG_CLEAN_CONDITIONALLY)) {
+ this._removeFlag(this.FLAG_CLEAN_CONDITIONALLY);
+ } else {
+ // No luck after removing flags, just return the longest text we found during the different loops
+ this._attempts.sort(function (a, b) {
+ return b.textLength - a.textLength;
+ });
+
+ // But first check if we actually have something
+ if (!this._attempts[0].textLength) {
+ return null;
+ }
+
+ articleContent = this._attempts[0].articleContent;
+ parseSuccessful = true;
+ }
+ }
+
+ if (parseSuccessful) {
+ // Find out text direction from ancestors of final top candidate.
+ var ancestors = [parentOfTopCandidate, topCandidate].concat(
+ this._getNodeAncestors(parentOfTopCandidate)
+ );
+ this._someNode(ancestors, function (ancestor) {
+ if (!ancestor.tagName) {
+ return false;
+ }
+ var articleDir = ancestor.getAttribute("dir");
+ if (articleDir) {
+ this._articleDir = articleDir;
+ return true;
+ }
+ return false;
+ });
+ return articleContent;
+ }
+ }
+ },
+
+ /**
+ * Converts some of the common HTML entities in string to their corresponding characters.
+ *
+ * @param str {string} - a string to unescape.
+ * @return string without HTML entity.
+ */
+ _unescapeHtmlEntities(str) {
+ if (!str) {
+ return str;
+ }
+
+ var htmlEscapeMap = this.HTML_ESCAPE_MAP;
+ return str
+ .replace(/&(quot|amp|apos|lt|gt);/g, function (_, tag) {
+ return htmlEscapeMap[tag];
+ })
+ .replace(/&#(?:x([0-9a-f]+)|([0-9]+));/gi, function (_, hex, numStr) {
+ var num = parseInt(hex || numStr, hex ? 16 : 10);
+
+ // these character references are replaced by a conforming HTML parser
+ if (num == 0 || num > 0x10ffff || (num >= 0xd800 && num <= 0xdfff)) {
+ num = 0xfffd;
+ }
+
+ return String.fromCodePoint(num);
+ });
+ },
+
+ /**
+ * Try to extract metadata from JSON-LD object.
+ * For now, only Schema.org objects of type Article or its subtypes are supported.
+ * @return Object with any metadata that could be extracted (possibly none)
+ */
+ _getJSONLD(doc) {
+ var scripts = this._getAllNodesWithTag(doc, ["script"]);
+
+ var metadata;
+
+ this._forEachNode(scripts, function (jsonLdElement) {
+ if (
+ !metadata &&
+ jsonLdElement.getAttribute("type") === "application/ld+json"
+ ) {
+ try {
+ // Strip CDATA markers if present
+ var content = jsonLdElement.textContent.replace(
+ /^\s*<!\[CDATA\[|\]\]>\s*$/g,
+ ""
+ );
+ var parsed = JSON.parse(content);
+
+ if (Array.isArray(parsed)) {
+ parsed = parsed.find(it => {
+ return (
+ it["@type"] &&
+ it["@type"].match(this.REGEXPS.jsonLdArticleTypes)
+ );
+ });
+ if (!parsed) {
+ return;
+ }
+ }
+
+ var schemaDotOrgRegex = /^https?\:\/\/schema\.org\/?$/;
+ var matches =
+ (typeof parsed["@context"] === "string" &&
+ parsed["@context"].match(schemaDotOrgRegex)) ||
+ (typeof parsed["@context"] === "object" &&
+ typeof parsed["@context"]["@vocab"] == "string" &&
+ parsed["@context"]["@vocab"].match(schemaDotOrgRegex));
+
+ if (!matches) {
+ return;
+ }
+
+ if (!parsed["@type"] && Array.isArray(parsed["@graph"])) {
+ parsed = parsed["@graph"].find(it => {
+ return (it["@type"] || "").match(this.REGEXPS.jsonLdArticleTypes);
+ });
+ }
+
+ if (
+ !parsed ||
+ !parsed["@type"] ||
+ !parsed["@type"].match(this.REGEXPS.jsonLdArticleTypes)
+ ) {
+ return;
+ }
+
+ metadata = {};
+
+ if (
+ typeof parsed.name === "string" &&
+ typeof parsed.headline === "string" &&
+ parsed.name !== parsed.headline
+ ) {
+ // we have both name and headline element in the JSON-LD. They should both be the same but some websites like aktualne.cz
+ // put their own name into "name" and the article title to "headline" which confuses Readability. So we try to check if either
+ // "name" or "headline" closely matches the html title, and if so, use that one. If not, then we use "name" by default.
+
+ var title = this._getArticleTitle();
+ var nameMatches = this._textSimilarity(parsed.name, title) > 0.75;
+ var headlineMatches =
+ this._textSimilarity(parsed.headline, title) > 0.75;
+
+ if (headlineMatches && !nameMatches) {
+ metadata.title = parsed.headline;
+ } else {
+ metadata.title = parsed.name;
+ }
+ } else if (typeof parsed.name === "string") {
+ metadata.title = parsed.name.trim();
+ } else if (typeof parsed.headline === "string") {
+ metadata.title = parsed.headline.trim();
+ }
+ if (parsed.author) {
+ if (typeof parsed.author.name === "string") {
+ metadata.byline = parsed.author.name.trim();
+ } else if (
+ Array.isArray(parsed.author) &&
+ parsed.author[0] &&
+ typeof parsed.author[0].name === "string"
+ ) {
+ metadata.byline = parsed.author
+ .filter(function (author) {
+ return author && typeof author.name === "string";
+ })
+ .map(function (author) {
+ return author.name.trim();
+ })
+ .join(", ");
+ }
+ }
+ if (typeof parsed.description === "string") {
+ metadata.excerpt = parsed.description.trim();
+ }
+ if (parsed.publisher && typeof parsed.publisher.name === "string") {
+ metadata.siteName = parsed.publisher.name.trim();
+ }
+ if (typeof parsed.datePublished === "string") {
+ metadata.datePublished = parsed.datePublished.trim();
+ }
+ } catch (err) {
+ this.log(err.message);
+ }
+ }
+ });
+ return metadata ? metadata : {};
+ },
+
+ /**
+ * Attempts to get excerpt and byline metadata for the article.
+ *
+ * @param {Object} jsonld — object containing any metadata that
+ * could be extracted from JSON-LD object.
+ *
+ * @return Object with optional "excerpt" and "byline" properties
+ */
+ _getArticleMetadata(jsonld) {
+ var metadata = {};
+ var values = {};
+ var metaElements = this._doc.getElementsByTagName("meta");
+
+ // property is a space-separated list of values
+ var propertyPattern =
+ /\s*(article|dc|dcterm|og|twitter)\s*:\s*(author|creator|description|published_time|title|site_name)\s*/gi;
+
+ // name is a single value
+ var namePattern =
+ /^\s*(?:(dc|dcterm|og|twitter|parsely|weibo:(article|webpage))\s*[-\.:]\s*)?(author|creator|pub-date|description|title|site_name)\s*$/i;
+
+ // Find description tags.
+ this._forEachNode(metaElements, function (element) {
+ var elementName = element.getAttribute("name");
+ var elementProperty = element.getAttribute("property");
+ var content = element.getAttribute("content");
+ if (!content) {
+ return;
+ }
+ var matches = null;
+ var name = null;
+
+ if (elementProperty) {
+ matches = elementProperty.match(propertyPattern);
+ if (matches) {
+ // Convert to lowercase, and remove any whitespace
+ // so we can match below.
+ name = matches[0].toLowerCase().replace(/\s/g, "");
+ // multiple authors
+ values[name] = content.trim();
+ }
+ }
+ if (!matches && elementName && namePattern.test(elementName)) {
+ name = elementName;
+ if (content) {
+ // Convert to lowercase, remove any whitespace, and convert dots
+ // to colons so we can match below.
+ name = name.toLowerCase().replace(/\s/g, "").replace(/\./g, ":");
+ values[name] = content.trim();
+ }
+ }
+ });
+
+ // get title
+ metadata.title =
+ jsonld.title ||
+ values["dc:title"] ||
+ values["dcterm:title"] ||
+ values["og:title"] ||
+ values["weibo:article:title"] ||
+ values["weibo:webpage:title"] ||
+ values.title ||
+ values["twitter:title"] ||
+ values["parsely-title"];
+
+ if (!metadata.title) {
+ metadata.title = this._getArticleTitle();
+ }
+
+ const articleAuthor =
+ typeof values["article:author"] === "string" &&
+ !this._isUrl(values["article:author"])
+ ? values["article:author"]
+ : undefined;
+
+ // get author
+ metadata.byline =
+ jsonld.byline ||
+ values["dc:creator"] ||
+ values["dcterm:creator"] ||
+ values.author ||
+ values["parsely-author"] ||
+ articleAuthor;
+
+ // get description
+ metadata.excerpt =
+ jsonld.excerpt ||
+ values["dc:description"] ||
+ values["dcterm:description"] ||
+ values["og:description"] ||
+ values["weibo:article:description"] ||
+ values["weibo:webpage:description"] ||
+ values.description ||
+ values["twitter:description"];
+
+ // get site name
+ metadata.siteName = jsonld.siteName || values["og:site_name"];
+
+ // get article published time
+ metadata.publishedTime =
+ jsonld.datePublished ||
+ values["article:published_time"] ||
+ values["parsely-pub-date"] ||
+ null;
+
+ // in many sites the meta value is escaped with HTML entities,
+ // so here we need to unescape it
+ metadata.title = this._unescapeHtmlEntities(metadata.title);
+ metadata.byline = this._unescapeHtmlEntities(metadata.byline);
+ metadata.excerpt = this._unescapeHtmlEntities(metadata.excerpt);
+ metadata.siteName = this._unescapeHtmlEntities(metadata.siteName);
+ metadata.publishedTime = this._unescapeHtmlEntities(metadata.publishedTime);
+
+ return metadata;
+ },
+
+ /**
+ * Check if node is image, or if node contains exactly only one image
+ * whether as a direct child or as its descendants.
+ *
+ * @param Element
+ **/
+ _isSingleImage(node) {
+ while (node) {
+ if (node.tagName === "IMG") {
+ return true;
+ }
+ if (node.children.length !== 1 || node.textContent.trim() !== "") {
+ return false;
+ }
+ node = node.children[0];
+ }
+ return false;
+ },
+
+ /**
+ * Find all <noscript> that are located after <img> nodes, and which contain only one
+ * <img> element. Replace the first image with the image from inside the <noscript> tag,
+ * and remove the <noscript> tag. This improves the quality of the images we use on
+ * some sites (e.g. Medium).
+ *
+ * @param Element
+ **/
+ _unwrapNoscriptImages(doc) {
+ // Find img without source or attributes that might contains image, and remove it.
+ // This is done to prevent a placeholder img is replaced by img from noscript in next step.
+ var imgs = Array.from(doc.getElementsByTagName("img"));
+ this._forEachNode(imgs, function (img) {
+ for (var i = 0; i < img.attributes.length; i++) {
+ var attr = img.attributes[i];
+ switch (attr.name) {
+ case "src":
+ case "srcset":
+ case "data-src":
+ case "data-srcset":
+ return;
+ }
+
+ if (/\.(jpg|jpeg|png|webp)/i.test(attr.value)) {
+ return;
+ }
+ }
+
+ img.remove();
+ });
+
+ // Next find noscript and try to extract its image
+ var noscripts = Array.from(doc.getElementsByTagName("noscript"));
+ this._forEachNode(noscripts, function (noscript) {
+ // Parse content of noscript and make sure it only contains image
+ if (!this._isSingleImage(noscript)) {
+ return;
+ }
+ var tmp = doc.createElement("div");
+ // We're running in the document context, and using unmodified
+ // document contents, so doing this should be safe.
+ // (Also we heavily discourage people from allowing script to
+ // run at all in this document...)
+ // eslint-disable-next-line no-unsanitized/property
+ tmp.innerHTML = noscript.innerHTML;
+
+ // If noscript has previous sibling and it only contains image,
+ // replace it with noscript content. However we also keep old
+ // attributes that might contains image.
+ var prevElement = noscript.previousElementSibling;
+ if (prevElement && this._isSingleImage(prevElement)) {
+ var prevImg = prevElement;
+ if (prevImg.tagName !== "IMG") {
+ prevImg = prevElement.getElementsByTagName("img")[0];
+ }
+
+ var newImg = tmp.getElementsByTagName("img")[0];
+ for (var i = 0; i < prevImg.attributes.length; i++) {
+ var attr = prevImg.attributes[i];
+ if (attr.value === "") {
+ continue;
+ }
+
+ if (
+ attr.name === "src" ||
+ attr.name === "srcset" ||
+ /\.(jpg|jpeg|png|webp)/i.test(attr.value)
+ ) {
+ if (newImg.getAttribute(attr.name) === attr.value) {
+ continue;
+ }
+
+ var attrName = attr.name;
+ if (newImg.hasAttribute(attrName)) {
+ attrName = "data-old-" + attrName;
+ }
+
+ newImg.setAttribute(attrName, attr.value);
+ }
+ }
+
+ noscript.parentNode.replaceChild(tmp.firstElementChild, prevElement);
+ }
+ });
+ },
+
+ /**
+ * Removes script tags from the document.
+ *
+ * @param Element
+ **/
+ _removeScripts(doc) {
+ this._removeNodes(this._getAllNodesWithTag(doc, ["script", "noscript"]));
+ },
+
+ /**
+ * Check if this node has only whitespace and a single element with given tag
+ * Returns false if the DIV node contains non-empty text nodes
+ * or if it contains no element with given tag or more than 1 element.
+ *
+ * @param Element
+ * @param string tag of child element
+ **/
+ _hasSingleTagInsideElement(element, tag) {
+ // There should be exactly 1 element child with given tag
+ if (element.children.length != 1 || element.children[0].tagName !== tag) {
+ return false;
+ }
+
+ // And there should be no text nodes with real content
+ return !this._someNode(element.childNodes, function (node) {
+ return (
+ node.nodeType === this.TEXT_NODE &&
+ this.REGEXPS.hasContent.test(node.textContent)
+ );
+ });
+ },
+
+ _isElementWithoutContent(node) {
+ return (
+ node.nodeType === this.ELEMENT_NODE &&
+ !node.textContent.trim().length &&
+ (!node.children.length ||
+ node.children.length ==
+ node.getElementsByTagName("br").length +
+ node.getElementsByTagName("hr").length)
+ );
+ },
+
+ /**
+ * Determine whether element has any children block level elements.
+ *
+ * @param Element
+ */
+ _hasChildBlockElement(element) {
+ return this._someNode(element.childNodes, function (node) {
+ return (
+ this.DIV_TO_P_ELEMS.has(node.tagName) ||
+ this._hasChildBlockElement(node)
+ );
+ });
+ },
+
+ /***
+ * Determine if a node qualifies as phrasing content.
+ * https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Phrasing_content
+ **/
+ _isPhrasingContent(node) {
+ return (
+ node.nodeType === this.TEXT_NODE ||
+ this.PHRASING_ELEMS.includes(node.tagName) ||
+ ((node.tagName === "A" ||
+ node.tagName === "DEL" ||
+ node.tagName === "INS") &&
+ this._everyNode(node.childNodes, this._isPhrasingContent))
+ );
+ },
+
+ _isWhitespace(node) {
+ return (
+ (node.nodeType === this.TEXT_NODE &&
+ node.textContent.trim().length === 0) ||
+ (node.nodeType === this.ELEMENT_NODE && node.tagName === "BR")
+ );
+ },
+
+ /**
+ * Get the inner text of a node - cross browser compatibly.
+ * This also strips out any excess whitespace to be found.
+ *
+ * @param Element
+ * @param Boolean normalizeSpaces (default: true)
+ * @return string
+ **/
+ _getInnerText(e, normalizeSpaces) {
+ normalizeSpaces =
+ typeof normalizeSpaces === "undefined" ? true : normalizeSpaces;
+ var textContent = e.textContent.trim();
+
+ if (normalizeSpaces) {
+ return textContent.replace(this.REGEXPS.normalize, " ");
+ }
+ return textContent;
+ },
+
+ /**
+ * Get the number of times a string s appears in the node e.
+ *
+ * @param Element
+ * @param string - what to split on. Default is ","
+ * @return number (integer)
+ **/
+ _getCharCount(e, s) {
+ s = s || ",";
+ return this._getInnerText(e).split(s).length - 1;
+ },
+
+ /**
+ * Remove the style attribute on every e and under.
+ * TODO: Test if getElementsByTagName(*) is faster.
+ *
+ * @param Element
+ * @return void
+ **/
+ _cleanStyles(e) {
+ if (!e || e.tagName.toLowerCase() === "svg") {
+ return;
+ }
+
+ // Remove `style` and deprecated presentational attributes
+ for (var i = 0; i < this.PRESENTATIONAL_ATTRIBUTES.length; i++) {
+ e.removeAttribute(this.PRESENTATIONAL_ATTRIBUTES[i]);
+ }
+
+ if (this.DEPRECATED_SIZE_ATTRIBUTE_ELEMS.includes(e.tagName)) {
+ e.removeAttribute("width");
+ e.removeAttribute("height");
+ }
+
+ var cur = e.firstElementChild;
+ while (cur !== null) {
+ this._cleanStyles(cur);
+ cur = cur.nextElementSibling;
+ }
+ },
+
+ /**
+ * Get the density of links as a percentage of the content
+ * This is the amount of text that is inside a link divided by the total text in the node.
+ *
+ * @param Element
+ * @return number (float)
+ **/
+ _getLinkDensity(element) {
+ var textLength = this._getInnerText(element).length;
+ if (textLength === 0) {
+ return 0;
+ }
+
+ var linkLength = 0;
+
+ // XXX implement _reduceNodeList?
+ this._forEachNode(element.getElementsByTagName("a"), function (linkNode) {
+ var href = linkNode.getAttribute("href");
+ var coefficient = href && this.REGEXPS.hashUrl.test(href) ? 0.3 : 1;
+ linkLength += this._getInnerText(linkNode).length * coefficient;
+ });
+
+ return linkLength / textLength;
+ },
+
+ /**
+ * Get an elements class/id weight. Uses regular expressions to tell if this
+ * element looks good or bad.
+ *
+ * @param Element
+ * @return number (Integer)
+ **/
+ _getClassWeight(e) {
+ if (!this._flagIsActive(this.FLAG_WEIGHT_CLASSES)) {
+ return 0;
+ }
+
+ var weight = 0;
+
+ // Look for a special classname
+ if (typeof e.className === "string" && e.className !== "") {
+ if (this.REGEXPS.negative.test(e.className)) {
+ weight -= 25;
+ }
+
+ if (this.REGEXPS.positive.test(e.className)) {
+ weight += 25;
+ }
+ }
+
+ // Look for a special ID
+ if (typeof e.id === "string" && e.id !== "") {
+ if (this.REGEXPS.negative.test(e.id)) {
+ weight -= 25;
+ }
+
+ if (this.REGEXPS.positive.test(e.id)) {
+ weight += 25;
+ }
+ }
+
+ return weight;
+ },
+
+ /**
+ * Clean a node of all elements of type "tag".
+ * (Unless it's a youtube/vimeo video. People love movies.)
+ *
+ * @param Element
+ * @param string tag to clean
+ * @return void
+ **/
+ _clean(e, tag) {
+ var isEmbed = ["object", "embed", "iframe"].includes(tag);
+
+ this._removeNodes(this._getAllNodesWithTag(e, [tag]), function (element) {
+ // Allow youtube and vimeo videos through as people usually want to see those.
+ if (isEmbed) {
+ // First, check the elements attributes to see if any of them contain youtube or vimeo
+ for (var i = 0; i < element.attributes.length; i++) {
+ if (this._allowedVideoRegex.test(element.attributes[i].value)) {
+ return false;
+ }
+ }
+
+ // For embed with <object> tag, check inner HTML as well.
+ if (
+ element.tagName === "object" &&
+ this._allowedVideoRegex.test(element.innerHTML)
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+ });
+ },
+
+ /**
+ * Check if a given node has one of its ancestor tag name matching the
+ * provided one.
+ * @param HTMLElement node
+ * @param String tagName
+ * @param Number maxDepth
+ * @param Function filterFn a filter to invoke to determine whether this node 'counts'
+ * @return Boolean
+ */
+ _hasAncestorTag(node, tagName, maxDepth, filterFn) {
+ maxDepth = maxDepth || 3;
+ tagName = tagName.toUpperCase();
+ var depth = 0;
+ while (node.parentNode) {
+ if (maxDepth > 0 && depth > maxDepth) {
+ return false;
+ }
+ if (
+ node.parentNode.tagName === tagName &&
+ (!filterFn || filterFn(node.parentNode))
+ ) {
+ return true;
+ }
+ node = node.parentNode;
+ depth++;
+ }
+ return false;
+ },
+
+ /**
+ * Return an object indicating how many rows and columns this table has.
+ */
+ _getRowAndColumnCount(table) {
+ var rows = 0;
+ var columns = 0;
+ var trs = table.getElementsByTagName("tr");
+ for (var i = 0; i < trs.length; i++) {
+ var rowspan = trs[i].getAttribute("rowspan") || 0;
+ if (rowspan) {
+ rowspan = parseInt(rowspan, 10);
+ }
+ rows += rowspan || 1;
+
+ // Now look for column-related info
+ var columnsInThisRow = 0;
+ var cells = trs[i].getElementsByTagName("td");
+ for (var j = 0; j < cells.length; j++) {
+ var colspan = cells[j].getAttribute("colspan") || 0;
+ if (colspan) {
+ colspan = parseInt(colspan, 10);
+ }
+ columnsInThisRow += colspan || 1;
+ }
+ columns = Math.max(columns, columnsInThisRow);
+ }
+ return { rows, columns };
+ },
+
+ /**
+ * Look for 'data' (as opposed to 'layout') tables, for which we use
+ * similar checks as
+ * https://searchfox.org/mozilla-central/rev/f82d5c549f046cb64ce5602bfd894b7ae807c8f8/accessible/generic/TableAccessible.cpp#19
+ */
+ _markDataTables(root) {
+ var tables = root.getElementsByTagName("table");
+ for (var i = 0; i < tables.length; i++) {
+ var table = tables[i];
+ var role = table.getAttribute("role");
+ if (role == "presentation") {
+ table._readabilityDataTable = false;
+ continue;
+ }
+ var datatable = table.getAttribute("datatable");
+ if (datatable == "0") {
+ table._readabilityDataTable = false;
+ continue;
+ }
+ var summary = table.getAttribute("summary");
+ if (summary) {
+ table._readabilityDataTable = true;
+ continue;
+ }
+
+ var caption = table.getElementsByTagName("caption")[0];
+ if (caption && caption.childNodes.length) {
+ table._readabilityDataTable = true;
+ continue;
+ }
+
+ // If the table has a descendant with any of these tags, consider a data table:
+ var dataTableDescendants = ["col", "colgroup", "tfoot", "thead", "th"];
+ var descendantExists = function (tag) {
+ return !!table.getElementsByTagName(tag)[0];
+ };
+ if (dataTableDescendants.some(descendantExists)) {
+ this.log("Data table because found data-y descendant");
+ table._readabilityDataTable = true;
+ continue;
+ }
+
+ // Nested tables indicate a layout table:
+ if (table.getElementsByTagName("table")[0]) {
+ table._readabilityDataTable = false;
+ continue;
+ }
+
+ var sizeInfo = this._getRowAndColumnCount(table);
+
+ if (sizeInfo.columns == 1 || sizeInfo.rows == 1) {
+ // single colum/row tables are commonly used for page layout purposes.
+ table._readabilityDataTable = false;
+ continue;
+ }
+
+ if (sizeInfo.rows >= 10 || sizeInfo.columns > 4) {
+ table._readabilityDataTable = true;
+ continue;
+ }
+ // Now just go by size entirely:
+ table._readabilityDataTable = sizeInfo.rows * sizeInfo.columns > 10;
+ }
+ },
+
+ /* convert images and figures that have properties like data-src into images that can be loaded without JS */
+ _fixLazyImages(root) {
+ this._forEachNode(
+ this._getAllNodesWithTag(root, ["img", "picture", "figure"]),
+ function (elem) {
+ // In some sites (e.g. Kotaku), they put 1px square image as base64 data uri in the src attribute.
+ // So, here we check if the data uri is too short, just might as well remove it.
+ if (elem.src && this.REGEXPS.b64DataUrl.test(elem.src)) {
+ // Make sure it's not SVG, because SVG can have a meaningful image in under 133 bytes.
+ var parts = this.REGEXPS.b64DataUrl.exec(elem.src);
+ if (parts[1] === "image/svg+xml") {
+ return;
+ }
+
+ // Make sure this element has other attributes which contains image.
+ // If it doesn't, then this src is important and shouldn't be removed.
+ var srcCouldBeRemoved = false;
+ for (var i = 0; i < elem.attributes.length; i++) {
+ var attr = elem.attributes[i];
+ if (attr.name === "src") {
+ continue;
+ }
+
+ if (/\.(jpg|jpeg|png|webp)/i.test(attr.value)) {
+ srcCouldBeRemoved = true;
+ break;
+ }
+ }
+
+ // Here we assume if image is less than 100 bytes (or 133 after encoded to base64)
+ // it will be too small, therefore it might be placeholder image.
+ if (srcCouldBeRemoved) {
+ var b64starts = parts[0].length;
+ var b64length = elem.src.length - b64starts;
+ if (b64length < 133) {
+ elem.removeAttribute("src");
+ }
+ }
+ }
+
+ // also check for "null" to work around https://github.com/jsdom/jsdom/issues/2580
+ if (
+ (elem.src || (elem.srcset && elem.srcset != "null")) &&
+ !elem.className.toLowerCase().includes("lazy")
+ ) {
+ return;
+ }
+
+ for (var j = 0; j < elem.attributes.length; j++) {
+ attr = elem.attributes[j];
+ if (
+ attr.name === "src" ||
+ attr.name === "srcset" ||
+ attr.name === "alt"
+ ) {
+ continue;
+ }
+ var copyTo = null;
+ if (/\.(jpg|jpeg|png|webp)\s+\d/.test(attr.value)) {
+ copyTo = "srcset";
+ } else if (/^\s*\S+\.(jpg|jpeg|png|webp)\S*\s*$/.test(attr.value)) {
+ copyTo = "src";
+ }
+ if (copyTo) {
+ //if this is an img or picture, set the attribute directly
+ if (elem.tagName === "IMG" || elem.tagName === "PICTURE") {
+ elem.setAttribute(copyTo, attr.value);
+ } else if (
+ elem.tagName === "FIGURE" &&
+ !this._getAllNodesWithTag(elem, ["img", "picture"]).length
+ ) {
+ //if the item is a <figure> that does not contain an image or picture, create one and place it inside the figure
+ //see the nytimes-3 testcase for an example
+ var img = this._doc.createElement("img");
+ img.setAttribute(copyTo, attr.value);
+ elem.appendChild(img);
+ }
+ }
+ }
+ }
+ );
+ },
+
+ _getTextDensity(e, tags) {
+ var textLength = this._getInnerText(e, true).length;
+ if (textLength === 0) {
+ return 0;
+ }
+ var childrenLength = 0;
+ var children = this._getAllNodesWithTag(e, tags);
+ this._forEachNode(
+ children,
+ child => (childrenLength += this._getInnerText(child, true).length)
+ );
+ return childrenLength / textLength;
+ },
+
+ /**
+ * Clean an element of all tags of type "tag" if they look fishy.
+ * "Fishy" is an algorithm based on content length, classnames, link density, number of images & embeds, etc.
+ *
+ * @return void
+ **/
+ _cleanConditionally(e, tag) {
+ if (!this._flagIsActive(this.FLAG_CLEAN_CONDITIONALLY)) {
+ return;
+ }
+
+ // Gather counts for other typical elements embedded within.
+ // Traverse backwards so we can remove nodes at the same time
+ // without effecting the traversal.
+ //
+ // TODO: Consider taking into account original contentScore here.
+ this._removeNodes(this._getAllNodesWithTag(e, [tag]), function (node) {
+ // First check if this node IS data table, in which case don't remove it.
+ var isDataTable = function (t) {
+ return t._readabilityDataTable;
+ };
+
+ var isList = tag === "ul" || tag === "ol";
+ if (!isList) {
+ var listLength = 0;
+ var listNodes = this._getAllNodesWithTag(node, ["ul", "ol"]);
+ this._forEachNode(
+ listNodes,
+ list => (listLength += this._getInnerText(list).length)
+ );
+ isList = listLength / this._getInnerText(node).length > 0.9;
+ }
+
+ if (tag === "table" && isDataTable(node)) {
+ return false;
+ }
+
+ // Next check if we're inside a data table, in which case don't remove it as well.
+ if (this._hasAncestorTag(node, "table", -1, isDataTable)) {
+ return false;
+ }
+
+ if (this._hasAncestorTag(node, "code")) {
+ return false;
+ }
+
+ // keep element if it has a data tables
+ if (
+ [...node.getElementsByTagName("table")].some(
+ tbl => tbl._readabilityDataTable
+ )
+ ) {
+ return false;
+ }
+
+ var weight = this._getClassWeight(node);
+
+ this.log("Cleaning Conditionally", node);
+
+ var contentScore = 0;
+
+ if (weight + contentScore < 0) {
+ return true;
+ }
+
+ if (this._getCharCount(node, ",") < 10) {
+ // If there are not very many commas, and the number of
+ // non-paragraph elements is more than paragraphs or other
+ // ominous signs, remove the element.
+ var p = node.getElementsByTagName("p").length;
+ var img = node.getElementsByTagName("img").length;
+ var li = node.getElementsByTagName("li").length - 100;
+ var input = node.getElementsByTagName("input").length;
+ var headingDensity = this._getTextDensity(node, [
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ ]);
+
+ var embedCount = 0;
+ var embeds = this._getAllNodesWithTag(node, [
+ "object",
+ "embed",
+ "iframe",
+ ]);
+
+ for (var i = 0; i < embeds.length; i++) {
+ // If this embed has attribute that matches video regex, don't delete it.
+ for (var j = 0; j < embeds[i].attributes.length; j++) {
+ if (this._allowedVideoRegex.test(embeds[i].attributes[j].value)) {
+ return false;
+ }
+ }
+
+ // For embed with <object> tag, check inner HTML as well.
+ if (
+ embeds[i].tagName === "object" &&
+ this._allowedVideoRegex.test(embeds[i].innerHTML)
+ ) {
+ return false;
+ }
+
+ embedCount++;
+ }
+
+ var innerText = this._getInnerText(node);
+
+ // toss any node whose inner text contains nothing but suspicious words
+ if (
+ this.REGEXPS.adWords.test(innerText) ||
+ this.REGEXPS.loadingWords.test(innerText)
+ ) {
+ return true;
+ }
+
+ var contentLength = innerText.length;
+ var linkDensity = this._getLinkDensity(node);
+ var textishTags = ["SPAN", "LI", "TD"].concat(
+ Array.from(this.DIV_TO_P_ELEMS)
+ );
+ var textDensity = this._getTextDensity(node, textishTags);
+ var isFigureChild = this._hasAncestorTag(node, "figure");
+
+ // apply shadiness checks, then check for exceptions
+ const shouldRemoveNode = () => {
+ const errs = [];
+ if (!isFigureChild && img > 1 && p / img < 0.5) {
+ errs.push(`Bad p to img ratio (img=${img}, p=${p})`);
+ }
+ if (!isList && li > p) {
+ errs.push(`Too many li's outside of a list. (li=${li} > p=${p})`);
+ }
+ if (input > Math.floor(p / 3)) {
+ errs.push(`Too many inputs per p. (input=${input}, p=${p})`);
+ }
+ if (
+ !isList &&
+ !isFigureChild &&
+ headingDensity < 0.9 &&
+ contentLength < 25 &&
+ (img === 0 || img > 2) &&
+ linkDensity > 0
+ ) {
+ errs.push(
+ `Suspiciously short. (headingDensity=${headingDensity}, img=${img}, linkDensity=${linkDensity})`
+ );
+ }
+ if (
+ !isList &&
+ weight < 25 &&
+ linkDensity > 0.2 + this._linkDensityModifier
+ ) {
+ errs.push(
+ `Low weight and a little linky. (linkDensity=${linkDensity})`
+ );
+ }
+ if (weight >= 25 && linkDensity > 0.5 + this._linkDensityModifier) {
+ errs.push(
+ `High weight and mostly links. (linkDensity=${linkDensity})`
+ );
+ }
+ if ((embedCount === 1 && contentLength < 75) || embedCount > 1) {
+ errs.push(
+ `Suspicious embed. (embedCount=${embedCount}, contentLength=${contentLength})`
+ );
+ }
+ if (img === 0 && textDensity === 0) {
+ errs.push(
+ `No useful content. (img=${img}, textDensity=${textDensity})`
+ );
+ }
+
+ if (errs.length) {
+ this.log("Checks failed", errs);
+ return true;
+ }
+
+ return false;
+ };
+
+ var haveToRemove = shouldRemoveNode();
+
+ // Allow simple lists of images to remain in pages
+ if (isList && haveToRemove) {
+ for (var x = 0; x < node.children.length; x++) {
+ let child = node.children[x];
+ // Don't filter in lists with li's that contain more than one child
+ if (child.children.length > 1) {
+ return haveToRemove;
+ }
+ }
+ let li_count = node.getElementsByTagName("li").length;
+ // Only allow the list to remain if every li contains an image
+ if (img == li_count) {
+ return false;
+ }
+ }
+ return haveToRemove;
+ }
+ return false;
+ });
+ },
+
+ /**
+ * Clean out elements that match the specified conditions
+ *
+ * @param Element
+ * @param Function determines whether a node should be removed
+ * @return void
+ **/
+ _cleanMatchedNodes(e, filter) {
+ var endOfSearchMarkerNode = this._getNextNode(e, true);
+ var next = this._getNextNode(e);
+ while (next && next != endOfSearchMarkerNode) {
+ if (filter.call(this, next, next.className + " " + next.id)) {
+ next = this._removeAndGetNext(next);
+ } else {
+ next = this._getNextNode(next);
+ }
+ }
+ },
+
+ /**
+ * Clean out spurious headers from an Element.
+ *
+ * @param Element
+ * @return void
+ **/
+ _cleanHeaders(e) {
+ let headingNodes = this._getAllNodesWithTag(e, ["h1", "h2"]);
+ this._removeNodes(headingNodes, function (node) {
+ let shouldRemove = this._getClassWeight(node) < 0;
+ if (shouldRemove) {
+ this.log("Removing header with low class weight:", node);
+ }
+ return shouldRemove;
+ });
+ },
+
+ /**
+ * Check if this node is an H1 or H2 element whose content is mostly
+ * the same as the article title.
+ *
+ * @param Element the node to check.
+ * @return boolean indicating whether this is a title-like header.
+ */
+ _headerDuplicatesTitle(node) {
+ if (node.tagName != "H1" && node.tagName != "H2") {
+ return false;
+ }
+ var heading = this._getInnerText(node, false);
+ this.log("Evaluating similarity of header:", heading, this._articleTitle);
+ return this._textSimilarity(this._articleTitle, heading) > 0.75;
+ },
+
+ _flagIsActive(flag) {
+ return (this._flags & flag) > 0;
+ },
+
+ _removeFlag(flag) {
+ this._flags = this._flags & ~flag;
+ },
+
+ _isProbablyVisible(node) {
+ // Have to null-check node.style and node.className.includes to deal with SVG and MathML nodes.
+ return (
+ (!node.style || node.style.display != "none") &&
+ (!node.style || node.style.visibility != "hidden") &&
+ !node.hasAttribute("hidden") &&
+ //check for "fallback-image" so that wikimedia math images are displayed
+ (!node.hasAttribute("aria-hidden") ||
+ node.getAttribute("aria-hidden") != "true" ||
+ (node.className &&
+ node.className.includes &&
+ node.className.includes("fallback-image")))
+ );
+ },
+
+ /**
+ * Runs readability.
+ *
+ * Workflow:
+ * 1. Prep the document by removing script tags, css, etc.
+ * 2. Build readability's DOM tree.
+ * 3. Grab the article content from the current dom tree.
+ * 4. Replace the current DOM tree with the new one.
+ * 5. Read peacefully.
+ *
+ * @return void
+ **/
+ parse() {
+ // Avoid parsing too large documents, as per configuration option
+ if (this._maxElemsToParse > 0) {
+ var numTags = this._doc.getElementsByTagName("*").length;
+ if (numTags > this._maxElemsToParse) {
+ throw new Error(
+ "Aborting parsing document; " + numTags + " elements found"
+ );
+ }
+ }
+
+ // Unwrap image from noscript
+ this._unwrapNoscriptImages(this._doc);
+
+ // Extract JSON-LD metadata before removing scripts
+ var jsonLd = this._disableJSONLD ? {} : this._getJSONLD(this._doc);
+
+ // Remove script tags from the document.
+ this._removeScripts(this._doc);
+
+ this._prepDocument();
+
+ var metadata = this._getArticleMetadata(jsonLd);
+ this._metadata = metadata;
+ this._articleTitle = metadata.title;
+
+ var articleContent = this._grabArticle();
+ if (!articleContent) {
+ return null;
+ }
+
+ this.log("Grabbed: " + articleContent.innerHTML);
+
+ this._postProcessContent(articleContent);
+
+ // If we haven't found an excerpt in the article's metadata, use the article's
+ // first paragraph as the excerpt. This is used for displaying a preview of
+ // the article's content.
+ if (!metadata.excerpt) {
+ var paragraphs = articleContent.getElementsByTagName("p");
+ if (paragraphs.length) {
+ metadata.excerpt = paragraphs[0].textContent.trim();
+ }
+ }
+
+ var textContent = articleContent.textContent;
+ return {
+ title: this._articleTitle,
+ byline: metadata.byline || this._articleByline,
+ dir: this._articleDir,
+ lang: this._articleLang,
+ content: this._serializer(articleContent),
+ textContent,
+ length: textContent.length,
+ excerpt: metadata.excerpt,
+ siteName: metadata.siteName || this._articleSiteName,
+ publishedTime: metadata.publishedTime,
+ };
+ },
+};
+
+if (typeof module === "object") {
+ /* eslint-disable-next-line no-redeclare */
+ /* global module */
+ module.exports = Readability;
+}
diff --git a/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt b/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt
new file mode 100644
index 0000000..8579341
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt
@@ -0,0 +1,29 @@
+package org.terst.feedreader.data
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+
+@Database(
+ entities = [FeedEntity::class, ArticleEntity::class, FilterRuleEntity::class],
+ version = 1,
+ exportSchema = false
+)
+abstract class AppDatabase : RoomDatabase() {
+ abstract fun feedDao(): FeedDao
+ abstract fun articleDao(): ArticleDao
+ abstract fun filterRuleDao(): FilterRuleDao
+
+ companion object {
+ @Volatile private var instance: AppDatabase? = null
+
+ fun get(context: Context): AppDatabase = instance ?: synchronized(this) {
+ instance ?: Room.databaseBuilder(
+ context.applicationContext,
+ AppDatabase::class.java,
+ "feedreader.db"
+ ).build().also { instance = it }
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/data/Daos.kt b/app/src/main/java/org/terst/feedreader/data/Daos.kt
new file mode 100644
index 0000000..0a41f0c
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/data/Daos.kt
@@ -0,0 +1,93 @@
+package org.terst.feedreader.data
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface FeedDao {
+ @Query("SELECT * FROM feeds ORDER BY title ASC")
+ fun observeAll(): Flow<List<FeedEntity>>
+
+ @Query("SELECT * FROM feeds ORDER BY title ASC")
+ suspend fun getAll(): List<FeedEntity>
+
+ @Query("SELECT * FROM feeds WHERE id = :id")
+ suspend fun getById(id: String): FeedEntity?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(feed: FeedEntity)
+
+ @Update
+ suspend fun update(feed: FeedEntity)
+
+ @Query("DELETE FROM feeds WHERE id = :id")
+ suspend fun delete(id: String)
+}
+
+data class FeedUnreadCount(val feedId: String, val count: Int)
+
+@Dao
+interface ArticleDao {
+ // Unread-first, newest-first -- the default "what's new" view across every feed.
+ @Query("SELECT * FROM articles WHERE isRead = 0 ORDER BY publishedAt DESC")
+ fun observeUnread(): Flow<List<ArticleEntity>>
+
+ @Query("SELECT feedId, COUNT(*) as count FROM articles WHERE isRead = 0 AND feedId IS NOT NULL GROUP BY feedId")
+ fun observeUnreadCounts(): Flow<List<FeedUnreadCount>>
+
+ @Query("SELECT * FROM articles WHERE feedId = :feedId ORDER BY publishedAt DESC")
+ fun observeByFeed(feedId: String): Flow<List<ArticleEntity>>
+
+ @Query("SELECT * FROM articles WHERE isSaved = 1 ORDER BY createdAt DESC")
+ fun observeSaved(): Flow<List<ArticleEntity>>
+
+ @Query("SELECT * FROM articles WHERE id = :id")
+ suspend fun getById(id: String): ArticleEntity?
+
+ // IGNORE, not REPLACE: a re-fetch of a feed re-parses items we already have:
+ // this keeps the existing row (read state, extracted content, saved state)
+ // untouched instead of clobbering it with the freshly-parsed feed data.
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertNew(articles: List<ArticleEntity>): List<Long>
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(article: ArticleEntity)
+
+ @Query("UPDATE articles SET isRead = :read WHERE id = :id")
+ suspend fun setRead(id: String, read: Boolean)
+
+ @Query("UPDATE articles SET isRead = 1 WHERE feedId = :feedId")
+ suspend fun markAllReadForFeed(feedId: String)
+
+ @Query("UPDATE articles SET extractedContent = :content WHERE id = :id")
+ suspend fun setExtractedContent(id: String, content: String)
+
+ @Query("UPDATE articles SET isSaved = :saved WHERE id = :id")
+ suspend fun setSaved(id: String, saved: Boolean)
+
+ @Query("UPDATE articles SET stashArtifactId = :artifactId WHERE id = :id")
+ suspend fun setStashArtifactId(id: String, artifactId: String)
+
+ @Delete
+ suspend fun delete(article: ArticleEntity)
+}
+
+@Dao
+interface FilterRuleDao {
+ @Query("SELECT * FROM filter_rules ORDER BY createdAt DESC")
+ fun observeAll(): Flow<List<FilterRuleEntity>>
+
+ @Query("SELECT * FROM filter_rules")
+ suspend fun getAll(): List<FilterRuleEntity>
+
+ @Insert
+ suspend fun insert(rule: FilterRuleEntity): Long
+
+ @Query("DELETE FROM filter_rules WHERE id = :id")
+ suspend fun delete(id: Long)
+}
diff --git a/app/src/main/java/org/terst/feedreader/data/Entities.kt b/app/src/main/java/org/terst/feedreader/data/Entities.kt
new file mode 100644
index 0000000..d71e1eb
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/data/Entities.kt
@@ -0,0 +1,43 @@
+package org.terst.feedreader.data
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "feeds")
+data class FeedEntity(
+ @PrimaryKey val id: String, // the feed URL itself -- naturally unique, no need for a separate id scheme
+ val title: String,
+ val siteUrl: String? = null, // the human-facing site link, if the feed provides one (distinct from the feed XML URL)
+ val lastFetchedAt: Long? = null,
+ val etag: String? = null, // conditional-GET caching (If-None-Match) to avoid re-downloading unchanged feeds
+ val lastModified: String? = null, // conditional-GET caching (If-Modified-Since)
+ val lastError: String? = null // set when a fetch fails; cleared on the next successful fetch
+)
+
+@Entity(tableName = "articles")
+data class ArticleEntity(
+ @PrimaryKey val id: String, // guid if the feed provides one, else a hash of the link
+ @ColumnInfo(index = true) val feedId: String?, // null for a standalone article saved via the share-sheet target, not tied to any feed
+ val title: String,
+ val link: String,
+ val author: String? = null,
+ val publishedAt: Long? = null,
+ val feedContentHtml: String? = null, // whatever content the feed itself provided (may be a summary, may be full text)
+ val extractedContent: String? = null, // Readability-extracted full text, filled in on first read if feedContentHtml was thin
+ val isRead: Boolean = false,
+ val isSaved: Boolean = false, // explicitly kept (independent of read state) -- triggers a Stash save
+ val stashArtifactId: String? = null, // set once successfully archived to Stash; null means not yet saved or save failed
+ val createdAt: Long
+)
+
+enum class FilterAction { MUTE, HIGHLIGHT }
+
+@Entity(tableName = "filter_rules")
+data class FilterRuleEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val pattern: String, // plain substring match, case-insensitive -- see FilterRuleEngine
+ val action: FilterAction,
+ val feedId: String? = null, // null applies globally across all feeds
+ val createdAt: Long
+)
diff --git a/app/src/main/java/org/terst/feedreader/data/FeedParser.kt b/app/src/main/java/org/terst/feedreader/data/FeedParser.kt
new file mode 100644
index 0000000..cd337d6
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/data/FeedParser.kt
@@ -0,0 +1,207 @@
+package org.terst.feedreader.data
+
+import android.util.Xml
+import org.xmlpull.v1.XmlPullParser
+import java.io.InputStream
+import java.time.OffsetDateTime
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+import java.time.format.DateTimeFormatterBuilder
+import java.time.temporal.ChronoField
+import java.util.Locale
+
+data class ParsedItem(
+ val guid: String?,
+ val title: String,
+ val link: String,
+ val author: String?,
+ val publishedAt: Long?,
+ val contentHtml: String?
+)
+
+data class ParsedFeed(
+ val title: String,
+ val siteUrl: String?,
+ val items: List<ParsedItem>
+)
+
+/**
+ * Hand-rolled RSS 2.0 + Atom parser -- deliberately not a third-party library
+ * (rome, etc.), per the "simple design" scope: only the fields this app
+ * actually uses (title/link/guid/author/date/content), nothing else.
+ */
+object FeedParser {
+
+ fun parse(input: InputStream): ParsedFeed {
+ val parser = Xml.newPullParser()
+ parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
+ parser.setInput(input, null)
+
+ // Peek the root element to decide which dialect we're in.
+ var eventType = parser.eventType
+ while (eventType != XmlPullParser.START_TAG) {
+ eventType = parser.next()
+ }
+ return when (parser.name.lowercase()) {
+ "rss" -> parseRss(parser)
+ "feed" -> parseAtom(parser)
+ else -> throw IllegalArgumentException("Unrecognized feed root element: ${parser.name}")
+ }
+ }
+
+ private fun parseRss(parser: XmlPullParser): ParsedFeed {
+ var feedTitle = ""
+ var siteUrl: String? = null
+ val items = mutableListOf<ParsedItem>()
+
+ var eventType = parser.next()
+ var depth = 1
+ // Track whether we're directly inside <channel> (title/link there) vs inside an <item>.
+ var inItem = false
+ var itemGuid: String? = null
+ var itemTitle = ""
+ var itemLink = ""
+ var itemAuthor: String? = null
+ var itemDate: Long? = null
+ var itemContent: String? = null
+ var itemDescription: String? = null
+
+ while (eventType != XmlPullParser.END_DOCUMENT && depth > 0) {
+ when (eventType) {
+ XmlPullParser.START_TAG -> {
+ depth++
+ when (parser.name.lowercase()) {
+ "item" -> {
+ inItem = true
+ itemGuid = null; itemTitle = ""; itemLink = ""; itemAuthor = null
+ itemDate = null; itemContent = null; itemDescription = null
+ }
+ "title" -> {
+ val text = parser.nextText()
+ if (inItem) itemTitle = text else feedTitle = text
+ }
+ "link" -> {
+ val text = parser.nextText()
+ if (inItem) itemLink = text else if (siteUrl == null) siteUrl = text
+ }
+ "guid" -> if (inItem) itemGuid = parser.nextText()
+ "author" -> if (inItem) itemAuthor = parser.nextText()
+ "dc:creator" -> if (inItem) itemAuthor = parser.nextText()
+ "pubdate" -> if (inItem) itemDate = parseRfc822Date(parser.nextText())
+ "content:encoded" -> if (inItem) itemContent = parser.nextText()
+ "description" -> if (inItem) itemDescription = parser.nextText()
+ }
+ }
+ XmlPullParser.END_TAG -> {
+ depth--
+ if (parser.name.lowercase() == "item") {
+ inItem = false
+ items += ParsedItem(
+ guid = itemGuid ?: itemLink.ifBlank { null },
+ title = itemTitle,
+ link = itemLink,
+ author = itemAuthor,
+ publishedAt = itemDate,
+ contentHtml = itemContent ?: itemDescription
+ )
+ }
+ }
+ }
+ if (depth > 0) eventType = parser.next()
+ }
+ return ParsedFeed(feedTitle, siteUrl, items)
+ }
+
+ private fun parseAtom(parser: XmlPullParser): ParsedFeed {
+ var feedTitle = ""
+ var siteUrl: String? = null
+ val items = mutableListOf<ParsedItem>()
+
+ var eventType = parser.next()
+ var depth = 1
+ var inEntry = false
+ var entryId: String? = null
+ var entryTitle = ""
+ var entryLink = ""
+ var entryAuthor: String? = null
+ var entryDate: Long? = null
+ var entryContent: String? = null
+ var entrySummary: String? = null
+
+ while (eventType != XmlPullParser.END_DOCUMENT && depth > 0) {
+ when (eventType) {
+ XmlPullParser.START_TAG -> {
+ depth++
+ when (parser.name.lowercase()) {
+ "entry" -> {
+ inEntry = true
+ entryId = null; entryTitle = ""; entryLink = ""; entryAuthor = null
+ entryDate = null; entryContent = null; entrySummary = null
+ }
+ "title" -> {
+ val text = parser.nextText()
+ if (inEntry) entryTitle = text else feedTitle = text
+ }
+ "link" -> {
+ val rel = parser.getAttributeValue(null, "rel")
+ val href = parser.getAttributeValue(null, "href")
+ if (href != null && (rel == null || rel == "alternate")) {
+ if (inEntry) entryLink = href else if (siteUrl == null) siteUrl = href
+ }
+ }
+ "id" -> if (inEntry) entryId = parser.nextText()
+ "name" -> if (inEntry && entryAuthor == null) entryAuthor = parser.nextText()
+ "updated" -> if (inEntry) entryDate = parseIso8601Date(parser.nextText())
+ "published" -> if (inEntry) entryDate = parseIso8601Date(parser.nextText()) ?: entryDate
+ "content" -> if (inEntry) entryContent = parser.nextText()
+ "summary" -> if (inEntry) entrySummary = parser.nextText()
+ }
+ }
+ XmlPullParser.END_TAG -> {
+ depth--
+ if (parser.name.lowercase() == "entry") {
+ inEntry = false
+ items += ParsedItem(
+ guid = entryId ?: entryLink.ifBlank { null },
+ title = entryTitle,
+ link = entryLink,
+ author = entryAuthor,
+ publishedAt = entryDate,
+ contentHtml = entryContent ?: entrySummary
+ )
+ }
+ }
+ }
+ if (depth > 0) eventType = parser.next()
+ }
+ return ParsedFeed(feedTitle, siteUrl, items)
+ }
+
+ private val rfc822Formatters = listOf(
+ DateTimeFormatter.RFC_1123_DATE_TIME,
+ DateTimeFormatterBuilder()
+ .appendPattern("EEE, d MMM yyyy HH:mm:ss ")
+ .optionalStart().appendPattern("zzz").optionalEnd()
+ .optionalStart().appendOffset("+HHMM", "+0000").optionalEnd()
+ .parseDefaulting(ChronoField.OFFSET_SECONDS, 0)
+ .toFormatter(Locale.US)
+ )
+
+ private fun parseRfc822Date(raw: String): Long? {
+ val trimmed = raw.trim()
+ for (formatter in rfc822Formatters) {
+ try {
+ return ZonedDateTime.parse(trimmed, formatter).toInstant().toEpochMilli()
+ } catch (_: Exception) {
+ // try the next formatter
+ }
+ }
+ return null
+ }
+
+ private fun parseIso8601Date(raw: String): Long? = try {
+ OffsetDateTime.parse(raw.trim()).toInstant().toEpochMilli()
+ } catch (_: Exception) {
+ null
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/data/FilterRuleEngine.kt b/app/src/main/java/org/terst/feedreader/data/FilterRuleEngine.kt
new file mode 100644
index 0000000..c32e8e9
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/data/FilterRuleEngine.kt
@@ -0,0 +1,24 @@
+package org.terst.feedreader.data
+
+/** How a single article is affected by the current set of filter rules. */
+enum class FilterResult { NORMAL, HIGHLIGHTED, MUTED }
+
+/**
+ * Plain case-insensitive substring matching against the article title --
+ * deliberately not regex, per the "simple design" scope. A rule with a
+ * non-null feedId only applies to that feed; a null feedId applies
+ * globally. MUTE wins over HIGHLIGHT if both match (hiding takes priority
+ * over drawing attention).
+ */
+object FilterRuleEngine {
+ fun evaluate(article: ArticleEntity, rules: List<FilterRuleEntity>): FilterResult {
+ val applicable = rules.filter { it.feedId == null || it.feedId == article.feedId }
+ val title = article.title.lowercase()
+
+ val muted = applicable.any { it.action == FilterAction.MUTE && title.contains(it.pattern.lowercase()) }
+ if (muted) return FilterResult.MUTED
+
+ val highlighted = applicable.any { it.action == FilterAction.HIGHLIGHT && title.contains(it.pattern.lowercase()) }
+ return if (highlighted) FilterResult.HIGHLIGHTED else FilterResult.NORMAL
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/data/Net.kt b/app/src/main/java/org/terst/feedreader/data/Net.kt
new file mode 100644
index 0000000..d3720a0
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/data/Net.kt
@@ -0,0 +1,8 @@
+package org.terst.feedreader.data
+
+import okhttp3.OkHttpClient
+
+/** Shared OkHttp client -- one connection pool for the whole app. */
+object Net {
+ val client: OkHttpClient by lazy { OkHttpClient() }
+}
diff --git a/app/src/main/java/org/terst/feedreader/data/Settings.kt b/app/src/main/java/org/terst/feedreader/data/Settings.kt
new file mode 100644
index 0000000..b4af70e
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/data/Settings.kt
@@ -0,0 +1,34 @@
+package org.terst.feedreader.data
+
+import android.content.Context
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+
+val Context.settingsDataStore by preferencesDataStore(name = "settings")
+
+private object SettingsKeys {
+ val STASH_BASE_URL = stringPreferencesKey("stash_base_url")
+ val STASH_API_KEY = stringPreferencesKey("stash_api_key")
+}
+
+/** Stash connection settings, configured on the Settings screen. Both blank until set. */
+class Settings(private val context: Context) {
+ val stashBaseUrl: Flow<String> =
+ context.settingsDataStore.data.map { it[SettingsKeys.STASH_BASE_URL] ?: "" }
+ val stashApiKey: Flow<String> =
+ context.settingsDataStore.data.map { it[SettingsKeys.STASH_API_KEY] ?: "" }
+
+ suspend fun stashBaseUrlNow(): String = stashBaseUrl.first()
+ suspend fun stashApiKeyNow(): String = stashApiKey.first()
+
+ suspend fun setStash(baseUrl: String, apiKey: String) {
+ context.settingsDataStore.edit { prefs ->
+ prefs[SettingsKeys.STASH_BASE_URL] = baseUrl.trim().trimEnd('/')
+ prefs[SettingsKeys.STASH_API_KEY] = apiKey.trim()
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/opml/Opml.kt b/app/src/main/java/org/terst/feedreader/opml/Opml.kt
new file mode 100644
index 0000000..65510fb
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/opml/Opml.kt
@@ -0,0 +1,71 @@
+package org.terst.feedreader.opml
+
+import android.util.Xml
+import org.terst.feedreader.data.FeedEntity
+import org.xmlpull.v1.XmlPullParser
+import java.io.InputStream
+import java.io.OutputStream
+
+data class OpmlFeed(val title: String, val xmlUrl: String, val htmlUrl: String?)
+
+/**
+ * OPML import/export. Any folder nesting (<outline> containing other
+ * <outline> elements instead of an xmlUrl) is flattened -- this app has no
+ * folder concept, per the "simple design" scope, so a folder's contents
+ * are just imported as a flat list of feeds.
+ */
+object Opml {
+
+ fun parse(input: InputStream): List<OpmlFeed> {
+ val parser = Xml.newPullParser()
+ parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
+ parser.setInput(input, null)
+
+ val feeds = mutableListOf<OpmlFeed>()
+ var eventType = parser.eventType
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ if (eventType == XmlPullParser.START_TAG && parser.name.equals("outline", ignoreCase = true)) {
+ val xmlUrl = parser.getAttributeValue(null, "xmlUrl")
+ if (xmlUrl != null) {
+ val title = parser.getAttributeValue(null, "title")
+ ?: parser.getAttributeValue(null, "text")
+ ?: xmlUrl
+ val htmlUrl = parser.getAttributeValue(null, "htmlUrl")
+ feeds += OpmlFeed(title, xmlUrl, htmlUrl)
+ }
+ }
+ eventType = parser.next()
+ }
+ return feeds
+ }
+
+ fun write(feeds: List<FeedEntity>, output: OutputStream) {
+ val serializer = Xml.newSerializer()
+ serializer.setOutput(output, "UTF-8")
+ serializer.startDocument("UTF-8", true)
+ serializer.startTag(null, "opml")
+ serializer.attribute(null, "version", "2.0")
+
+ serializer.startTag(null, "head")
+ serializer.startTag(null, "title")
+ serializer.text("Feeds subscriptions")
+ serializer.endTag(null, "title")
+ serializer.endTag(null, "head")
+
+ serializer.startTag(null, "body")
+ for (feed in feeds) {
+ serializer.startTag(null, "outline")
+ serializer.attribute(null, "text", feed.title)
+ serializer.attribute(null, "title", feed.title)
+ serializer.attribute(null, "type", "rss")
+ serializer.attribute(null, "xmlUrl", feed.id)
+ feed.siteUrl?.let { serializer.attribute(null, "htmlUrl", it) }
+ serializer.endTag(null, "outline")
+ }
+ serializer.endTag(null, "body")
+
+ serializer.endTag(null, "opml")
+ serializer.endDocument()
+ serializer.flush()
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt
new file mode 100644
index 0000000..9be08b0
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt
@@ -0,0 +1,95 @@
+package org.terst.feedreader.readability
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlin.coroutines.resume
+
+@Serializable
+data class ExtractedArticle(
+ val title: String? = null,
+ val content: String? = null, // HTML
+ val textContent: String? = null,
+ val excerpt: String? = null,
+ val error: String? = null
+)
+
+/**
+ * Runs Mozilla's Readability.js (vendored in assets/Readability.js, pinned
+ * to release 0.6.0) against a page loaded in a hidden WebView, for feed
+ * articles whose feedContentHtml is missing or too short to be the real
+ * article body. WebView APIs are main-thread-only, so this hops to
+ * Dispatchers.Main for the load+eval and suspends the calling coroutine
+ * until the JS callback fires (or the timeout elapses).
+ */
+object ReaderExtractor {
+ private const val TIMEOUT_MS = 15_000L
+ private val json = Json { ignoreUnknownKeys = true }
+
+ suspend fun extract(context: Context, url: String): ExtractedArticle? =
+ withTimeoutOrNull(TIMEOUT_MS) {
+ withContext(Dispatchers.Main) {
+ extractOnMainThread(context, url)
+ }
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private suspend fun extractOnMainThread(context: Context, url: String): ExtractedArticle? =
+ suspendCancellableCoroutine { continuation ->
+ val webView = WebView(context)
+ webView.settings.javaScriptEnabled = true
+
+ fun finish(result: ExtractedArticle?) {
+ if (continuation.isActive) continuation.resume(result)
+ webView.stopLoading()
+ webView.destroy()
+ }
+
+ webView.webViewClient = object : WebViewClient() {
+ override fun onPageFinished(view: WebView, finishedUrl: String) {
+ val readabilityJs = context.assets.open("Readability.js").bufferedReader().use { it.readText() }
+ val script = """
+ (function() {
+ try {
+ $readabilityJs
+ var article = new Readability(document.cloneNode(true)).parse();
+ return JSON.stringify(article);
+ } catch (e) {
+ return JSON.stringify({error: e.toString()});
+ }
+ })();
+ """.trimIndent()
+
+ view.evaluateJavascript(script) { rawResult ->
+ // evaluateJavascript's callback value is itself JSON-encoded (our
+ // JSON.stringify(...) string comes back wrapped in an extra layer
+ // of JSON string-quoting), so this needs decoding twice.
+ val unwrapped = runCatching { json.decodeFromString<String>(rawResult) }.getOrNull()
+ val article = unwrapped?.let {
+ runCatching { json.decodeFromString<ExtractedArticle>(it) }.getOrNull()
+ }
+ finish(article)
+ }
+ }
+
+ override fun onReceivedError(
+ view: WebView,
+ errorCode: Int,
+ description: String?,
+ failingUrl: String?
+ ) {
+ finish(ExtractedArticle(error = description ?: "Failed to load $failingUrl"))
+ }
+ }
+
+ continuation.invokeOnCancellation { webView.destroy() }
+ webView.loadUrl(url)
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/stash/StashClient.kt b/app/src/main/java/org/terst/feedreader/stash/StashClient.kt
new file mode 100644
index 0000000..28cc2b3
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/stash/StashClient.kt
@@ -0,0 +1,82 @@
+package org.terst.feedreader.stash
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+
+private val json = Json { ignoreUnknownKeys = true }
+
+@Serializable
+private data class CreateArtifactRequest(
+ val type: String = "url",
+ @SerialName("source_url") val sourceUrl: String,
+ val title: String? = null
+)
+
+@Serializable
+data class ArtifactResponse(
+ val id: String,
+ val title: String? = null,
+ @SerialName("content_md") val contentMd: String? = null,
+ @SerialName("processing_steps") val processingSteps: List<String> = emptyList()
+)
+
+@Serializable
+private data class DuplicateErrorResponse(@SerialName("existing_id") val existingId: String)
+
+sealed class StashSaveResult {
+ data class Saved(val artifact: ArtifactResponse) : StashSaveResult()
+ data class AlreadySaved(val existingId: String) : StashSaveResult()
+ data class Failed(val message: String) : StashSaveResult()
+}
+
+/**
+ * Client for the parts of Stash's API this app needs: POST /artifacts to
+ * archive an article. Uses sync=true so content_md comes back in the same
+ * response (server runs extract+summary_lite inline, up to ~30s) --
+ * tags/full-summary/embeddings still fill in async server-side afterward,
+ * but this app doesn't need to wait on or poll for those.
+ */
+class StashClient(
+ private val client: OkHttpClient,
+ private val baseUrl: String,
+ private val apiKey: String
+) {
+ suspend fun saveArticle(url: String, title: String?): StashSaveResult = withContext(Dispatchers.IO) {
+ if (baseUrl.isBlank() || apiKey.isBlank()) {
+ return@withContext StashSaveResult.Failed("Stash isn't configured (set base URL and API key in Settings)")
+ }
+
+ val body = json.encodeToString(
+ CreateArtifactRequest.serializer(),
+ CreateArtifactRequest(sourceUrl = url, title = title)
+ ).toRequestBody("application/json".toMediaType())
+
+ val request = Request.Builder()
+ .url("$baseUrl/artifacts?sync=true")
+ .header("X-API-Key", apiKey)
+ .post(body)
+ .build()
+
+ runCatching {
+ client.newCall(request).execute().use { response ->
+ val responseBody = response.body?.string().orEmpty()
+ when {
+ response.code == 201 || response.code == 200 ->
+ StashSaveResult.Saved(json.decodeFromString(ArtifactResponse.serializer(), responseBody))
+ response.code == 409 ->
+ StashSaveResult.AlreadySaved(
+ json.decodeFromString(DuplicateErrorResponse.serializer(), responseBody).existingId
+ )
+ else -> StashSaveResult.Failed("HTTP ${response.code}")
+ }
+ }
+ }.getOrElse { StashSaveResult.Failed(it.message ?: "Network error") }
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt b/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt
new file mode 100644
index 0000000..1e053af
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt
@@ -0,0 +1,152 @@
+package org.terst.feedreader.ui
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import okhttp3.Request
+import org.terst.feedreader.data.AppDatabase
+import org.terst.feedreader.data.FeedEntity
+import org.terst.feedreader.data.FeedParser
+import org.terst.feedreader.data.Net
+import org.terst.feedreader.opml.Opml
+import org.terst.feedreader.work.FeedFetchWorker
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AddFeedScreen(onDone: () -> Unit) {
+ val context = LocalContext.current
+ val db = remember { AppDatabase.get(context) }
+ val scope = rememberCoroutineScope()
+
+ var url by remember { mutableStateOf("") }
+ var status by remember { mutableStateOf<String?>(null) }
+ var isBusy by remember { mutableStateOf(false) }
+
+ val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
+ if (uri == null) return@rememberLauncherForActivityResult
+ isBusy = true
+ scope.launch {
+ val imported = withContext(Dispatchers.IO) {
+ context.contentResolver.openInputStream(uri)?.use { stream -> Opml.parse(stream) } ?: emptyList()
+ }
+ for (feed in imported) {
+ db.feedDao().upsert(FeedEntity(id = feed.xmlUrl, title = feed.title, siteUrl = feed.htmlUrl))
+ }
+ FeedFetchWorker.fetchNow(context)
+ status = "Imported ${imported.size} feed(s)"
+ isBusy = false
+ }
+ }
+
+ val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/x-opml")) { uri ->
+ if (uri == null) return@rememberLauncherForActivityResult
+ scope.launch {
+ withContext(Dispatchers.IO) {
+ val feeds = db.feedDao().getAll()
+ context.contentResolver.openOutputStream(uri)?.use { out -> Opml.write(feeds, out) }
+ }
+ status = "Exported subscriptions"
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Add Feed") },
+ navigationIcon = {
+ IconButton(onClick = onDone) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ LazyColumn(modifier = Modifier.fillMaxWidth().padding(padding)) {
+ item {
+ Column(modifier = Modifier.padding(16.dp)) {
+ OutlinedTextField(
+ value = url,
+ onValueChange = { url = it },
+ label = { Text("Feed URL") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Button(
+ enabled = url.isNotBlank() && !isBusy,
+ onClick = {
+ isBusy = true
+ val feedUrl = url.trim()
+ scope.launch {
+ status = addFeedByUrl(context, db, feedUrl)
+ if (status?.startsWith("Added") == true) url = ""
+ isBusy = false
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) { Text("Add") }
+
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedButton(onClick = { importLauncher.launch("*/*") }, modifier = Modifier.fillMaxWidth()) {
+ Text("Import OPML")
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ OutlinedButton(onClick = { exportLauncher.launch("subscriptions.opml") }, modifier = Modifier.fillMaxWidth()) {
+ Text("Export OPML")
+ }
+
+ status?.let {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(it)
+ }
+ }
+ }
+ }
+ }
+}
+
+private suspend fun addFeedByUrl(context: android.content.Context, db: AppDatabase, feedUrl: String): String =
+ withContext(Dispatchers.IO) {
+ try {
+ val response = Net.client.newCall(Request.Builder().url(feedUrl).build()).execute()
+ response.use {
+ if (!it.isSuccessful) return@withContext "Couldn't fetch feed: HTTP ${it.code}"
+ val body = it.body ?: return@withContext "Empty response"
+ val parsed = FeedParser.parse(body.byteStream())
+ db.feedDao().upsert(
+ FeedEntity(id = feedUrl, title = parsed.title.ifBlank { feedUrl }, siteUrl = parsed.siteUrl)
+ )
+ }
+ FeedFetchWorker.fetchNow(context)
+ "Added"
+ } catch (e: Exception) {
+ "Couldn't add feed: ${e.message}"
+ }
+ }
diff --git a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt
new file mode 100644
index 0000000..d9f3d8a
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt
@@ -0,0 +1,109 @@
+package org.terst.feedreader.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextDecoration
+import kotlinx.coroutines.flow.combine
+import org.terst.feedreader.data.AppDatabase
+import org.terst.feedreader.data.ArticleEntity
+import org.terst.feedreader.data.FilterResult
+import org.terst.feedreader.data.FilterRuleEngine
+import java.text.DateFormat
+import java.util.Date
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ArticleListScreen(
+ feedId: String,
+ onOpenArticle: (String) -> Unit,
+ onBack: () -> Unit
+) {
+ val context = LocalContext.current
+ val db = remember { AppDatabase.get(context) }
+
+ val articleFlow = remember(feedId) {
+ when (feedId) {
+ UNREAD_FEED_ID -> db.articleDao().observeUnread()
+ SAVED_FEED_ID -> db.articleDao().observeSaved()
+ else -> db.articleDao().observeByFeed(feedId)
+ }
+ }
+ val visibleArticles by remember(feedId) {
+ combine(articleFlow, db.filterRuleDao().observeAll()) { articles, rules ->
+ articles.map { it to FilterRuleEngine.evaluate(it, rules) }
+ .filter { (_, result) -> result != FilterResult.MUTED }
+ }
+ }.collectAsState(initial = emptyList())
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ when (feedId) {
+ UNREAD_FEED_ID -> "Unread"
+ SAVED_FEED_ID -> "Saved"
+ else -> "Articles"
+ }
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) {
+ items(visibleArticles, key = { it.first.id }) { (article, result) ->
+ ArticleRow(
+ article = article,
+ highlighted = result == FilterResult.HIGHLIGHTED,
+ onClick = { onOpenArticle(article.id) }
+ )
+ HorizontalDivider()
+ }
+ }
+ }
+}
+
+@Composable
+private fun ArticleRow(article: ArticleEntity, highlighted: Boolean, onClick: () -> Unit) {
+ val dateText = article.publishedAt?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it)) }
+ ListItem(
+ headlineContent = {
+ Text(
+ article.title,
+ textDecoration = if (article.isRead) TextDecoration.LineThrough else null
+ )
+ },
+ supportingContent = { if (dateText != null) Text(dateText) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(if (highlighted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface)
+ .clickable { onClick() }
+ )
+}
diff --git a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt
new file mode 100644
index 0000000..85c8d3c
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt
@@ -0,0 +1,123 @@
+package org.terst.feedreader.ui
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import kotlinx.coroutines.flow.combine
+import org.terst.feedreader.data.AppDatabase
+import org.terst.feedreader.data.FeedEntity
+import org.terst.feedreader.work.FeedFetchWorker
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun FeedListScreen(
+ onOpenFeed: (String) -> Unit,
+ onOpenUnread: () -> Unit,
+ onOpenSaved: () -> Unit,
+ onAddFeed: () -> Unit,
+ onSettings: () -> Unit
+) {
+ val context = LocalContext.current
+ val db = remember { AppDatabase.get(context) }
+
+ val feeds by remember {
+ combine(db.feedDao().observeAll(), db.articleDao().observeUnreadCounts()) { feeds, counts ->
+ val countsByFeed = counts.associate { it.feedId to it.count }
+ feeds.map { it to (countsByFeed[it.id] ?: 0) }
+ }
+ }.collectAsState(initial = emptyList())
+
+ val totalUnread = feeds.sumOf { it.second }
+ var menuExpanded by remember { mutableStateOf(false) }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Feeds") },
+ actions = {
+ IconButton(onClick = { FeedFetchWorker.fetchNow(context) }) {
+ Icon(Icons.Default.Refresh, contentDescription = "Refresh all")
+ }
+ IconButton(onClick = onSettings) {
+ Icon(Icons.Default.Settings, contentDescription = "Settings")
+ }
+ IconButton(onClick = { menuExpanded = true }) {
+ Icon(Icons.Default.MoreVert, contentDescription = "More")
+ }
+ DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
+ DropdownMenuItem(
+ text = { Text("Import OPML") },
+ onClick = { menuExpanded = false; onAddFeed() }
+ )
+ }
+ }
+ )
+ },
+ floatingActionButton = {
+ FloatingActionButton(onClick = onAddFeed) {
+ Icon(Icons.Default.Add, contentDescription = "Add feed")
+ }
+ }
+ ) { padding ->
+ LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) {
+ item {
+ ListItem(
+ headlineContent = { Text("Unread") },
+ trailingContent = { Text(totalUnread.toString()) },
+ modifier = Modifier.fillMaxWidth().clickable { onOpenUnread() }
+ )
+ HorizontalDivider()
+ ListItem(
+ headlineContent = { Text("Saved") },
+ modifier = Modifier.fillMaxWidth().clickable { onOpenSaved() }
+ )
+ HorizontalDivider()
+ }
+ items(feeds, key = { it.first.id }) { (feed, unreadCount) ->
+ FeedRow(feed = feed, unreadCount = unreadCount, onClick = { onOpenFeed(feed.id) })
+ HorizontalDivider()
+ }
+ }
+ }
+}
+
+@Composable
+private fun FeedRow(feed: FeedEntity, unreadCount: Int, onClick: () -> Unit) {
+ ListItem(
+ headlineContent = { Text(feed.title.ifBlank { feed.id }) },
+ supportingContent = {
+ if (feed.lastError != null) {
+ Text("Last fetch failed: ${feed.lastError}")
+ }
+ },
+ trailingContent = { if (unreadCount > 0) Text(unreadCount.toString()) },
+ modifier = Modifier.fillMaxWidth().clickable { onClick() }
+ )
+}
diff --git a/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt
new file mode 100644
index 0000000..345bede
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt
@@ -0,0 +1,79 @@
+package org.terst.feedreader.ui
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import org.terst.feedreader.work.FeedFetchWorker
+import java.net.URLDecoder
+import java.net.URLEncoder
+
+const val UNREAD_FEED_ID = "__unread__"
+const val SAVED_FEED_ID = "__saved__"
+
+fun encodeNavArg(value: String): String = URLEncoder.encode(value, "UTF-8")
+fun decodeNavArg(value: String): String = URLDecoder.decode(value, "UTF-8")
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ FeedFetchWorker.schedulePeriodic(applicationContext)
+
+ setContent {
+ MaterialTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ FeedReaderApp()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun FeedReaderApp() {
+ val navController = rememberNavController()
+ NavHost(navController = navController, startDestination = "feeds") {
+ composable("feeds") {
+ FeedListScreen(
+ onOpenFeed = { feedId -> navController.navigate("articles/${encodeNavArg(feedId)}") },
+ onOpenUnread = { navController.navigate("articles/${encodeNavArg(UNREAD_FEED_ID)}") },
+ onOpenSaved = { navController.navigate("articles/${encodeNavArg(SAVED_FEED_ID)}") },
+ onAddFeed = { navController.navigate("addFeed") },
+ onSettings = { navController.navigate("settings") }
+ )
+ }
+ composable(
+ "articles/{feedId}",
+ arguments = listOf(navArgument("feedId") { type = NavType.StringType })
+ ) { backStackEntry ->
+ val feedId = decodeNavArg(backStackEntry.arguments?.getString("feedId") ?: UNREAD_FEED_ID)
+ ArticleListScreen(
+ feedId = feedId,
+ onOpenArticle = { articleId -> navController.navigate("reader/${encodeNavArg(articleId)}") },
+ onBack = { navController.popBackStack() }
+ )
+ }
+ composable(
+ "reader/{articleId}",
+ arguments = listOf(navArgument("articleId") { type = NavType.StringType })
+ ) { backStackEntry ->
+ val articleId = decodeNavArg(backStackEntry.arguments?.getString("articleId") ?: "")
+ ReaderScreen(articleId = articleId, onBack = { navController.popBackStack() })
+ }
+ composable("addFeed") {
+ AddFeedScreen(onDone = { navController.popBackStack() })
+ }
+ composable("settings") {
+ SettingsScreen(onBack = { navController.popBackStack() })
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
new file mode 100644
index 0000000..8b320eb
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
@@ -0,0 +1,166 @@
+package org.terst.feedreader.ui
+
+import android.content.Intent
+import android.net.Uri
+import android.webkit.WebView
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Bookmark
+import androidx.compose.material.icons.filled.BookmarkBorder
+import androidx.compose.material.icons.filled.OpenInBrowser
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.viewinterop.AndroidView
+import kotlinx.coroutines.launch
+import org.terst.feedreader.data.AppDatabase
+import org.terst.feedreader.data.ArticleEntity
+import org.terst.feedreader.data.Net
+import org.terst.feedreader.data.Settings
+import org.terst.feedreader.readability.ReaderExtractor
+import org.terst.feedreader.stash.StashClient
+import org.terst.feedreader.stash.StashSaveResult
+
+// Below this length (of the plain feed-provided HTML), assume it's a teaser/summary,
+// not the real article body, and fall back to on-device Readability extraction.
+private const val THIN_CONTENT_THRESHOLD = 500
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ReaderScreen(articleId: String, onBack: () -> Unit) {
+ val context = LocalContext.current
+ val db = remember { AppDatabase.get(context) }
+ val scope = rememberCoroutineScope()
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ var article by remember { mutableStateOf<ArticleEntity?>(null) }
+ var displayContent by remember { mutableStateOf<String?>(null) }
+ var isExtracting by remember { mutableStateOf(false) }
+ var isSaving by remember { mutableStateOf(false) }
+
+ LaunchedEffect(articleId) {
+ val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect
+ article = loaded
+ db.articleDao().setRead(articleId, true)
+
+ val feedContent = loaded.feedContentHtml
+ val plainLength = feedContent?.replace(Regex("<[^>]*>"), "")?.trim()?.length ?: 0
+
+ displayContent = when {
+ loaded.extractedContent != null -> loaded.extractedContent
+ plainLength >= THIN_CONTENT_THRESHOLD -> feedContent
+ else -> {
+ isExtracting = true
+ val extracted = ReaderExtractor.extract(context, loaded.link)
+ isExtracting = false
+ val content = extracted?.content
+ if (content != null) {
+ db.articleDao().setExtractedContent(articleId, content)
+ content
+ } else {
+ feedContent ?: "<p>Couldn't load article content. Try opening in browser.</p>"
+ }
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(article?.title.orEmpty(), maxLines = 1) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ },
+ actions = {
+ val currentArticle = article
+ IconButton(onClick = {
+ val a = currentArticle ?: return@IconButton
+ val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(a.link))
+ context.startActivity(browserIntent)
+ }) {
+ Icon(Icons.Default.OpenInBrowser, contentDescription = "Open in browser")
+ }
+ IconButton(
+ enabled = !isSaving,
+ onClick = {
+ val a = currentArticle ?: return@IconButton
+ isSaving = true
+ scope.launch {
+ val settings = Settings(context)
+ val client = StashClient(Net.client, settings.stashBaseUrlNow(), settings.stashApiKeyNow())
+ when (val result = client.saveArticle(a.link, a.title)) {
+ is StashSaveResult.Saved -> {
+ db.articleDao().setSaved(a.id, true)
+ db.articleDao().setStashArtifactId(a.id, result.artifact.id)
+ snackbarHostState.showSnackbar("Saved to Stash")
+ }
+ is StashSaveResult.AlreadySaved -> {
+ db.articleDao().setSaved(a.id, true)
+ db.articleDao().setStashArtifactId(a.id, result.existingId)
+ snackbarHostState.showSnackbar("Already in Stash")
+ }
+ is StashSaveResult.Failed ->
+ snackbarHostState.showSnackbar("Save failed: ${result.message}")
+ }
+ isSaving = false
+ }
+ }
+ ) {
+ Icon(
+ if (currentArticle?.isSaved == true) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
+ contentDescription = "Save to Stash"
+ )
+ }
+ }
+ )
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) }
+ ) { padding ->
+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
+ val content = displayContent
+ val baseUrl = article?.link
+ if (content != null) {
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = { ctx ->
+ WebView(ctx).apply {
+ settings.javaScriptEnabled = false
+ loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null)
+ }
+ },
+ update = { webView -> webView.loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) }
+ )
+ } else if (isExtracting) {
+ CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+ }
+ }
+ }
+}
+
+private fun wrapHtml(bodyContent: String): String = """
+ <html><head><meta name="viewport" content="width=device-width, initial-scale=1">
+ <style>body { font-family: sans-serif; font-size: 18px; line-height: 1.5; padding: 16px; }
+ img { max-width: 100%; height: auto; }</style></head>
+ <body>$bodyContent</body></html>
+""".trimIndent()
diff --git a/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt
new file mode 100644
index 0000000..a5d3f9c
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt
@@ -0,0 +1,162 @@
+package org.terst.feedreader.ui
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.Button
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import org.terst.feedreader.data.AppDatabase
+import org.terst.feedreader.data.FilterAction
+import org.terst.feedreader.data.FilterRuleEntity
+import org.terst.feedreader.data.Settings
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(onBack: () -> Unit) {
+ val context = LocalContext.current
+ val db = remember { AppDatabase.get(context) }
+ val settings = remember { Settings(context) }
+ val scope = rememberCoroutineScope()
+
+ var stashUrl by remember { mutableStateOf("") }
+ var stashKey by remember { mutableStateOf("") }
+ LaunchedEffect(Unit) {
+ stashUrl = settings.stashBaseUrlNow()
+ stashKey = settings.stashApiKeyNow()
+ }
+
+ var newRulePattern by remember { mutableStateOf("") }
+ var newRuleAction by remember { mutableStateOf(FilterAction.MUTE) }
+ var actionMenuExpanded by remember { mutableStateOf(false) }
+ val rules by db.filterRuleDao().observeAll().collectAsState(initial = emptyList())
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Settings") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ LazyColumn(modifier = Modifier.fillMaxWidth().padding(padding)) {
+ item {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text("Stash", style = MaterialTheme.typography.titleMedium)
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = stashUrl,
+ onValueChange = { stashUrl = it },
+ label = { Text("Base URL") },
+ placeholder = { Text("https://stash.example.com") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = stashKey,
+ onValueChange = { stashKey = it },
+ label = { Text("API key") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(
+ onClick = { scope.launch { settings.setStash(stashUrl, stashKey) } },
+ modifier = Modifier.fillMaxWidth()
+ ) { Text("Save") }
+ }
+ HorizontalDivider()
+ }
+
+ item {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text("Filter rules", style = MaterialTheme.typography.titleMedium)
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(modifier = Modifier.fillMaxWidth()) {
+ OutlinedTextField(
+ value = newRulePattern,
+ onValueChange = { newRulePattern = it },
+ label = { Text("Keyword") },
+ modifier = Modifier.weight(1f)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Box {
+ Button(onClick = { actionMenuExpanded = true }) {
+ Text(if (newRuleAction == FilterAction.MUTE) "Mute" else "Highlight")
+ }
+ DropdownMenu(expanded = actionMenuExpanded, onDismissRequest = { actionMenuExpanded = false }) {
+ DropdownMenuItem(text = { Text("Mute") }, onClick = { newRuleAction = FilterAction.MUTE; actionMenuExpanded = false })
+ DropdownMenuItem(text = { Text("Highlight") }, onClick = { newRuleAction = FilterAction.HIGHLIGHT; actionMenuExpanded = false })
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(
+ enabled = newRulePattern.isNotBlank(),
+ onClick = {
+ scope.launch {
+ db.filterRuleDao().insert(
+ FilterRuleEntity(
+ pattern = newRulePattern.trim(),
+ action = newRuleAction,
+ createdAt = System.currentTimeMillis()
+ )
+ )
+ newRulePattern = ""
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) { Text("Add rule") }
+ }
+ }
+
+ items(rules, key = { it.id }) { rule ->
+ ListItem(
+ headlineContent = { Text(rule.pattern) },
+ supportingContent = { Text(if (rule.action == FilterAction.MUTE) "Mute" else "Highlight") },
+ trailingContent = {
+ IconButton(onClick = { scope.launch { db.filterRuleDao().delete(rule.id) } }) {
+ Icon(Icons.Default.Delete, contentDescription = "Delete rule")
+ }
+ }
+ )
+ HorizontalDivider()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt b/app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt
new file mode 100644
index 0000000..656bebc
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt
@@ -0,0 +1,113 @@
+package org.terst.feedreader.ui
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import org.terst.feedreader.data.AppDatabase
+import org.terst.feedreader.data.ArticleEntity
+import org.terst.feedreader.data.Net
+import org.terst.feedreader.data.Settings
+import org.terst.feedreader.readability.ReaderExtractor
+import org.terst.feedreader.stash.StashClient
+import org.terst.feedreader.stash.StashSaveResult
+import java.security.MessageDigest
+
+private val URL_REGEX = Regex("""https?://\S+""")
+
+/**
+ * Share-sheet target: a URL shared from any other app lands here. Extracts
+ * the article on-device (so it's readable offline immediately, independent
+ * of Stash), stores it locally as a standalone saved article (feedId=null,
+ * not tied to any subscription), and best-effort archives it to Stash.
+ */
+class ShareReceiverActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val sharedText = intent?.getStringExtra(android.content.Intent.EXTRA_TEXT).orEmpty()
+ val url = URL_REGEX.find(sharedText)?.value
+
+ setContent {
+ MaterialTheme {
+ ShareReceiverContent(url = url, onDone = { finish() })
+ }
+ }
+ }
+}
+
+@Composable
+private fun ShareReceiverContent(url: String?, onDone: () -> Unit) {
+ val context = LocalContext.current
+ var status by remember { mutableStateOf(if (url == null) "No link found in shared content" else "Saving…") }
+ var done by remember { mutableStateOf(url == null) }
+
+ LaunchedEffect(url) {
+ if (url == null) return@LaunchedEffect
+ val db = AppDatabase.get(context)
+
+ val extracted = ReaderExtractor.extract(context, url)
+ val id = MessageDigest.getInstance("SHA-256").digest(url.toByteArray()).joinToString("") { "%02x".format(it) }
+ db.articleDao().upsert(
+ ArticleEntity(
+ id = id,
+ feedId = null,
+ title = extracted?.title ?: url,
+ link = url,
+ extractedContent = extracted?.content,
+ isSaved = true,
+ createdAt = System.currentTimeMillis()
+ )
+ )
+
+ val settings = Settings(context)
+ val client = StashClient(Net.client, settings.stashBaseUrlNow(), settings.stashApiKeyNow())
+ status = when (val result = client.saveArticle(url, extracted?.title)) {
+ is StashSaveResult.Saved -> {
+ db.articleDao().setStashArtifactId(id, result.artifact.id)
+ "Saved for offline reading and archived to Stash"
+ }
+ is StashSaveResult.AlreadySaved -> {
+ db.articleDao().setStashArtifactId(id, result.existingId)
+ "Saved for offline reading (already in Stash)"
+ }
+ is StashSaveResult.Failed ->
+ "Saved for offline reading. Stash archive failed: ${result.message}"
+ }
+ done = true
+ }
+
+ Column(modifier = Modifier.padding(24.dp)) {
+ Card(modifier = Modifier.padding(8.dp)) {
+ Column(modifier = Modifier.padding(20.dp)) {
+ Text("Feeds", style = MaterialTheme.typography.titleMedium)
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(status)
+ if (!done) {
+ Spacer(modifier = Modifier.height(8.dp))
+ CircularProgressIndicator()
+ } else {
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(onClick = onDone) { Text("Done") }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt b/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt
new file mode 100644
index 0000000..560b3d8
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt
@@ -0,0 +1,123 @@
+package org.terst.feedreader.work
+
+import android.content.Context
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import org.terst.feedreader.data.AppDatabase
+import org.terst.feedreader.data.ArticleEntity
+import org.terst.feedreader.data.FeedEntity
+import org.terst.feedreader.data.FeedParser
+import org.terst.feedreader.data.Net
+import okhttp3.Request
+import java.security.MessageDigest
+import java.util.concurrent.TimeUnit
+
+/**
+ * Fetches every subscribed feed on a schedule, parses new items, and
+ * inserts them (existing articles are left untouched -- see ArticleDao.insertNew's
+ * IGNORE conflict strategy). Uses conditional GET (ETag / Last-Modified) so
+ * an unchanged feed costs a 304 instead of a full re-download and re-parse.
+ */
+class FeedFetchWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
+
+ private val client = Net.client
+
+ override suspend fun doWork(): Result {
+ val db = AppDatabase.get(applicationContext)
+ val feeds = db.feedDao().getAll()
+
+ for (feed in feeds) {
+ fetchOne(feed, db)
+ }
+ return Result.success()
+ }
+
+ private suspend fun fetchOne(feed: FeedEntity, db: AppDatabase) {
+ val requestBuilder = Request.Builder().url(feed.id)
+ feed.etag?.let { requestBuilder.header("If-None-Match", it) }
+ feed.lastModified?.let { requestBuilder.header("If-Modified-Since", it) }
+
+ try {
+ client.newCall(requestBuilder.build()).execute().use { response ->
+ val now = System.currentTimeMillis()
+ if (response.code == 304) {
+ db.feedDao().update(feed.copy(lastFetchedAt = now, lastError = null))
+ return
+ }
+ if (!response.isSuccessful) {
+ db.feedDao().update(feed.copy(lastFetchedAt = now, lastError = "HTTP ${response.code}"))
+ return
+ }
+
+ val body = response.body ?: return
+ val parsed = FeedParser.parse(body.byteStream())
+
+ val articles = parsed.items.map { item ->
+ val id = item.guid ?: sha256(item.link)
+ ArticleEntity(
+ id = id,
+ feedId = feed.id,
+ title = item.title,
+ link = item.link,
+ author = item.author,
+ publishedAt = item.publishedAt,
+ feedContentHtml = item.contentHtml,
+ createdAt = now
+ )
+ }
+ db.articleDao().insertNew(articles)
+
+ db.feedDao().update(
+ feed.copy(
+ title = parsed.title.ifBlank { feed.title },
+ siteUrl = parsed.siteUrl ?: feed.siteUrl,
+ lastFetchedAt = now,
+ etag = response.header("ETag") ?: feed.etag,
+ lastModified = response.header("Last-Modified") ?: feed.lastModified,
+ lastError = null
+ )
+ )
+ }
+ } catch (e: Exception) {
+ db.feedDao().update(feed.copy(lastFetchedAt = System.currentTimeMillis(), lastError = e.message ?: "Fetch failed"))
+ }
+ }
+
+ private fun sha256(input: String): String {
+ val digest = MessageDigest.getInstance("SHA-256").digest(input.toByteArray())
+ return digest.joinToString("") { "%02x".format(it) }
+ }
+
+ companion object {
+ private const val UNIQUE_WORK_NAME = "feed_fetch_periodic"
+
+ fun schedulePeriodic(context: Context) {
+ val request = PeriodicWorkRequestBuilder<FeedFetchWorker>(60, TimeUnit.MINUTES)
+ .setConstraints(
+ androidx.work.Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+ )
+ .build()
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(
+ UNIQUE_WORK_NAME,
+ ExistingPeriodicWorkPolicy.KEEP,
+ request
+ )
+ }
+
+ /** One-off immediate fetch, e.g. a manual pull-to-refresh. */
+ fun fetchNow(context: Context) {
+ val request = androidx.work.OneTimeWorkRequestBuilder<FeedFetchWorker>().build()
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ "feed_fetch_now",
+ androidx.work.ExistingWorkPolicy.KEEP,
+ request
+ )
+ }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..23e3261
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="app_name">Feeds</string>
+</resources>
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..9c5f0ff
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <style name="Theme.FeedReader" parent="android:Theme.DeviceDefault.DayNight">
+ <item name="android:windowActionBar">false</item>
+ <item name="android:windowNoTitle">true</item>
+ </style>
+
+ <style name="Theme.FeedReader.Dialog" parent="android:Theme.DeviceDefault.Dialog">
+ <item name="android:windowIsFloating">true</item>
+ <item name="android:windowBackground">@android:color/transparent</item>
+ </style>
+</resources>