51 |
52 |
53 | -------------------------------------------------------------------------------- /src/main/java/fiets/processors/RssFeedProcessor.java: -------------------------------------------------------------------------------- 1 | package fiets.processors; 2 | 3 | import java.io.IOException; 4 | import java.text.ParseException; 5 | import java.util.ArrayList; 6 | import java.util.Date; 7 | import java.util.List; 8 | 9 | import javax.xml.parsers.ParserConfigurationException; 10 | import javax.xml.xpath.XPathExpressionException; 11 | 12 | import org.apache.logging.log4j.LogManager; 13 | import org.apache.logging.log4j.Logger; 14 | import org.w3c.dom.Document; 15 | import org.w3c.dom.Node; 16 | import org.w3c.dom.NodeList; 17 | import org.xml.sax.SAXException; 18 | 19 | import fiets.Filterer; 20 | import fiets.model.Feed; 21 | import fiets.model.Post; 22 | import fiets.processors.xml.Dom; 23 | import fiets.processors.xml.Xpath; 24 | import fiets.sources.HttpFeedSource; 25 | 26 | public class RssFeedProcessor implements FeedProcessor { 27 | 28 | private static final Logger log = LogManager.getLogger(); 29 | 30 | @Override public boolean canHandle(Feed feed, String content) { 31 | if (!HttpFeedSource.isHttpSource(feed) || content == null) { 32 | return false; 33 | } 34 | return content.contains(" parsePosts( 45 | Feed feed, String content) 46 | throws XPathExpressionException, SAXException, 47 | IOException, ParserConfigurationException { 48 | Document doc = Dom.parse(content); 49 | NodeList items = Xpath.xpathAsNodes(doc, "//item"); 50 | List result = new ArrayList<>(); 51 | int num = items.getLength(); 52 | if (num > 0) { 53 | for (int i = 0; i < num; i++) { 54 | Node item = items.item(i); 55 | String title = Xpath.xpathAsString(item, "title").orElse("-no title"); 56 | String link = Xpath.xpathAsString(item, "link").orElse("-no-link-"); 57 | if (link.trim().length() == 0) { 58 | link = Xpath.xpathAsString(item, "guid").orElse("-no-guid"); 59 | } 60 | String description = Xpath.xpathAsString(item, "description").orElse(""); 61 | Date date = Xpath.xpathAsString(item, "pubDate") 62 | .map(Xml::parseDate) 63 | .orElse(new Date()); 64 | Post post = new Post(0L, link, date, title, description, false, feed); 65 | result.add(post); 66 | } 67 | } 68 | return result; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/java/fiets/processors/AtomFeedProcessor.java: -------------------------------------------------------------------------------- 1 | package fiets.processors; 2 | 3 | import java.io.IOException; 4 | import java.text.ParseException; 5 | import java.util.ArrayList; 6 | import java.util.Date; 7 | import java.util.List; 8 | 9 | import javax.xml.parsers.ParserConfigurationException; 10 | import javax.xml.xpath.XPathExpressionException; 11 | 12 | import org.apache.logging.log4j.LogManager; 13 | import org.apache.logging.log4j.Logger; 14 | import org.w3c.dom.Document; 15 | import org.w3c.dom.Node; 16 | import org.w3c.dom.NodeList; 17 | import org.xml.sax.SAXException; 18 | 19 | import fiets.Filterer; 20 | import fiets.model.Feed; 21 | import fiets.model.Post; 22 | import fiets.processors.xml.Dom; 23 | import fiets.processors.xml.Xpath; 24 | import fiets.sources.HttpFeedSource; 25 | 26 | public class AtomFeedProcessor implements FeedProcessor { 27 | 28 | private static final Logger log = LogManager.getLogger(); 29 | 30 | @Override public boolean canHandle(Feed feed, String content) { 31 | if (!HttpFeedSource.isHttpSource(feed) || content == null) { 32 | return false; 33 | } 34 | try { 35 | content = Xml.dropSignature(content.trim()); 36 | content = Xml.dropComments(content.trim()); 37 | return content.matches("(?s)^<([a-zA-Z0-9]+\\:)?(feed).*"); 38 | } catch (RuntimeException e) { 39 | log.debug(e, e); 40 | return false; 41 | } 42 | } 43 | 44 | @Override public String parseTitle(Feed feed, String content) 45 | throws SAXException, IOException, 46 | ParserConfigurationException, XPathExpressionException { 47 | Document doc = Dom.parse(content); 48 | return Xpath.xpathAsString(doc, "/feed/title").orElse("-no title-"); 49 | } 50 | 51 | @Override public List parsePosts( 52 | Feed feed, String content) 53 | throws XPathExpressionException, SAXException, 54 | IOException, ParserConfigurationException, ParseException { 55 | Document doc = Dom.parse(content); 56 | NodeList items = Xpath.xpathAsNodes(doc, "//entry"); 57 | List result = new ArrayList<>(); 58 | int num = items.getLength(); 59 | if (num > 0) { 60 | for (int i = 0; i < num; i++) { 61 | Node item = items.item(i); 62 | String title = Xpath.xpathAsString(item, "title").orElse("-no title-"); 63 | String link = Xpath.xpathAsString(item, "link/@href").orElse("-unknown-link-"); 64 | String description = Xpath.xpathAsString(item, "content").orElse(""); 65 | Date date = Xpath.xpathAsString(item, "updated") 66 | .map(dateString -> Xml.parseDate(dateString)) 67 | .orElse(new Date()); 68 | Post post = new Post(0l, link, date, title, description, false, feed); 69 | result.add(post); 70 | } 71 | } 72 | return result; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/main/java/fiets/processors/Process.java: -------------------------------------------------------------------------------- 1 | package fiets.processors; 2 | 3 | import java.text.SimpleDateFormat; 4 | import java.util.Date; 5 | import java.util.LinkedList; 6 | import java.util.List; 7 | 8 | import org.apache.logging.log4j.LogManager; 9 | import org.apache.logging.log4j.Logger; 10 | 11 | import fiets.Filterer; 12 | import fiets.model.Feed; 13 | import fiets.model.Filter; 14 | import fiets.model.Post; 15 | import fiets.sources.FeedSource; 16 | import fiets.sources.HttpFeedSource; 17 | 18 | public class Process { 19 | private static final Process PROCESS = new Process(); 20 | 21 | static { 22 | PROCESS.register(new HttpFeedSource()); 23 | PROCESS.register(new RssFeedProcessor()); 24 | PROCESS.register(new AtomFeedProcessor()); 25 | PROCESS.register(new FacebookProcessor()); 26 | } 27 | 28 | public static List parsePosts(Feed feed) throws Exception { 29 | String input = PROCESS.preprocess(feed); 30 | return PROCESS.getParser(feed, input).parsePosts(feed, input); 31 | } 32 | 33 | public static String parseTitle(Feed feed) throws Exception { 34 | String input = PROCESS.preprocess(feed); 35 | return PROCESS.getParser(feed, input).parseTitle(feed, input); 36 | } 37 | 38 | private static final Logger log = LogManager.getLogger(); 39 | private final List processors = new LinkedList<>(); 40 | private final List sources = new LinkedList<>(); 41 | 42 | private String preprocess(Feed feed) { 43 | for (FeedSource pre : sources) { 44 | if (pre.canHandle(feed)) { 45 | return pre.process(feed); 46 | } 47 | } 48 | return null; 49 | } 50 | 51 | private FeedProcessor getParser(Feed feed, String findFor) { 52 | for (FeedProcessor f : processors) { 53 | if (f.canHandle(feed, findFor)) { 54 | return f; 55 | } 56 | } 57 | log.debug(findFor); 58 | throw new IllegalArgumentException( 59 | "No matching parser found for " + feed.getLocation()); 60 | } 61 | 62 | private void register(FeedSource source) { 63 | sources.add(source); 64 | } 65 | 66 | private void register(FeedProcessor processor) { 67 | processors.add(processor); 68 | } 69 | 70 | public static void registerProcessor(FeedProcessor processor) { 71 | PROCESS.register(processor); 72 | } 73 | 74 | public static Post errorPost(Feed f, String title) { 75 | return errorPost(f, title, null); 76 | } 77 | 78 | public static Post errorPost(Feed f, String title, Throwable t) { 79 | Date d = new Date(); 80 | Post post = new Post(0L, f.getLocation() + "?date=" 81 | + new SimpleDateFormat("yyyyMMdd").format(d), d, 82 | String.format("Error: %s - %s", f.getTitle(), title), 83 | t == null ? "" : t.getMessage(), false, f); 84 | return post; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/main/java/fiets/model/FeedMetadata.java: -------------------------------------------------------------------------------- 1 | package fiets.model; 2 | 3 | import java.util.Locale; 4 | 5 | public final class FeedMetadata { 6 | 7 | private static final String COLOR_PREFIX = "|fc="; 8 | private static final String DEFAULT_COLOR = "#e8eef7"; 9 | 10 | private FeedMetadata() {} 11 | 12 | public static String extractColor(Feed feed) { 13 | if (feed == null) { 14 | return null; 15 | } 16 | return extractColor(feed.getLastStatus()); 17 | } 18 | 19 | public static String extractColor(String status) { 20 | if (status == null) { 21 | return null; 22 | } 23 | int pos = status.indexOf(COLOR_PREFIX); 24 | if (pos < 0 || pos + COLOR_PREFIX.length() >= status.length()) { 25 | return null; 26 | } 27 | return normalizeColor(status.substring(pos + COLOR_PREFIX.length())); 28 | } 29 | 30 | public static String mergeStatusAndColor(String status, String existingStatus, 31 | String color) { 32 | String baseStatus = stripColor(status != null ? status : existingStatus); 33 | String persistedColor = normalizeColor(color != null 34 | ? color : extractColor(existingStatus)); 35 | if (persistedColor == null) { 36 | return baseStatus; 37 | } 38 | return baseStatus + COLOR_PREFIX + persistedColor; 39 | } 40 | 41 | public static String stripColor(String status) { 42 | if (status == null) { 43 | return "unknown"; 44 | } 45 | int pos = status.indexOf(COLOR_PREFIX); 46 | if (pos < 0) { 47 | return status; 48 | } 49 | return status.substring(0, pos); 50 | } 51 | 52 | public static String normalizeColor(String color) { 53 | if (color == null) { 54 | return null; 55 | } 56 | String trimmed = color.trim(); 57 | if (trimmed.length() == 0) { 58 | return null; 59 | } 60 | if (!trimmed.startsWith("#")) { 61 | trimmed = "#" + trimmed; 62 | } 63 | if (trimmed.length() != 7) { 64 | return null; 65 | } 66 | try { 67 | Integer.parseInt(trimmed.substring(1), 16); 68 | return trimmed.toLowerCase(Locale.ROOT); 69 | } catch (NumberFormatException e) { 70 | return null; 71 | } 72 | } 73 | 74 | public static String defaultColor() { 75 | return DEFAULT_COLOR; 76 | } 77 | 78 | public static String ensureColorOrDefault(String color) { 79 | String normalized = normalizeColor(color); 80 | return normalized == null ? DEFAULT_COLOR : normalized; 81 | } 82 | 83 | public static String readableTextColor(String backgroundColor) { 84 | String normalized = normalizeColor(backgroundColor); 85 | if (normalized == null) { 86 | normalized = DEFAULT_COLOR; 87 | } 88 | int r = Integer.parseInt(normalized.substring(1, 3), 16); 89 | int g = Integer.parseInt(normalized.substring(3, 5), 16); 90 | int b = Integer.parseInt(normalized.substring(5, 7), 16); 91 | double luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; 92 | return luminance > 0.6 ? "#111827" : "#f8fafc"; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fiets 2 | _Fiets_ is basically just another feeds aggregator tool. It's quite opinionated 3 | in terms of its feature set and in terms of implementation style. And even in 4 | its name - it doesn't have anything to do with bicycles by the way... ;-) 5 | 6 | If you are interested in details, read on. 7 | If you just want to dive in, download the current binary and head over to 8 | Quickstart. 9 | 10 | ## Features 11 | * Fetch any number of feeds on a regular base and store posts in a unified stream. 12 | * Supports RSS and Atom format. 13 | * Bookmark posts for later reading. 14 | * Fever API emulation. (As far as needed to make it work with the Reeder app.) 15 | * Extend _fiets_ with custom Java classes: 16 | * Fetch anything you want (well, where posts can be extracted) from the Internet. 17 | * Implement filters to drop unwanted posts. 18 | * Implement views to highlight certain posts. 19 | 20 | ## Quickstart 21 | (As _fiets_ is Java based, you need a working Java 21 (LTS) runtime on your 22 | path. The codebase builds cleanly against Java 21 without source changes; the 23 | Gradle toolchain enforces the required compiler level.) 24 | 25 | If you have an OPML file to import, you should do that first (if not, just 26 | skip this step): 27 | 28 | java -cp fiets-0.10.jar fiets.opml.ImportOpml 29 | 30 | Next, start _fiets_: 31 | 32 | java -jar fiets-0.10.jar 33 | 34 | By default it starts to listen at port 7000. You can choose an alternative port 35 | as optional command line parameter. 36 | 37 | Fiets _provides no means to secure the connection. When deploying it on a_ 38 | _public site make sure to run it behind a reverse proxy with authentication_ 39 | _and HTTPS!_ 40 | 41 | Database files are created in the current directory, log files below `logs`. 42 | 43 | _Fiets_ immediately starts checking known feeds for updates. 44 | 45 | ### Add feeds 46 | The add-feed bookmarklet on the Feeds page is currently broken and unfortunately 47 | there is no form to add feeds, yet. 48 | 49 | You can add feeds manually by calling the URL: 50 | 51 | http://:/add-feed?url= 52 | 53 | * `` has to point to a supported feed format. 54 | * `` must be URL encoded. If you call add-feed from a browser, 55 | it should be just fine. 56 | 57 | ### From Source 58 | If you prefer to build from the current source: 59 | 60 | ``` 61 | git clone https://github.com/ondy/fiets.git 62 | cd fiets 63 | gradle build 64 | java -jar build/libs/fiets-0.10.jar 65 | ``` 66 | There are also some really barebone bash start and stop scripts for your convenience. 67 | 68 | ## Why? 69 | I wrote _Fiets_ since my favorite aggregator is being discontinued. Of course 70 | I could have adopted that tool, but I don't really like implementing 71 | in PHP. 72 | 73 | Apart of that I have tried several feed aggregators over the years and 74 | have always been missing certain features 75 | so I took the chance now to implement my own one. And maybe it's also useful 76 | for you, so here you are. 77 | -------------------------------------------------------------------------------- /src/main/java/fiets/sources/HttpFeedSource.java: -------------------------------------------------------------------------------- 1 | package fiets.sources; 2 | 3 | import java.io.UnsupportedEncodingException; 4 | import java.net.URLDecoder; 5 | import java.nio.charset.Charset; 6 | import java.util.ArrayList; 7 | import java.util.HashMap; 8 | import java.util.List; 9 | import java.util.Locale; 10 | import java.util.Map; 11 | 12 | import jodd.util.StringUtil; 13 | import org.apache.logging.log4j.LogManager; 14 | import org.apache.logging.log4j.Logger; 15 | 16 | import fiets.model.Feed; 17 | import jodd.http.HttpRequest; 18 | import jodd.http.HttpResponse; 19 | 20 | public class HttpFeedSource implements FeedSource { 21 | public static final String UTF8_BOM = "\uFEFF"; 22 | private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:81.0) Gecko/20100101 Firefox/81.0"; 23 | private static final Logger log = LogManager.getLogger(); 24 | 25 | @Override public String process(Feed feed) { 26 | return readUrlContent(feed.getLocation()); 27 | } 28 | 29 | public static String readUrlContent(String url) { 30 | HttpRequest req = HttpRequest.get(url) 31 | .timeout(10000) 32 | .connectionTimeout(10000) 33 | .trustAllCerts(true) 34 | .followRedirects(true) 35 | .acceptEncoding("UTF-8"); 36 | req.header("User-Agent", USER_AGENT); 37 | HttpResponse rsp = req.send(); 38 | int status = rsp.statusCode(); 39 | if (status != 200) { 40 | log.error("Unexpected status for {} : {}", url, status); 41 | } 42 | fixCharset(rsp); 43 | String text = rsp.bodyText(); 44 | if (text.startsWith(UTF8_BOM)) { 45 | text = text.substring(1); 46 | } 47 | return text.trim(); 48 | } 49 | 50 | private static void fixCharset(HttpResponse rsp) { 51 | String cs = rsp.charset(); 52 | if (cs != null && cs.startsWith("\"")) { 53 | rsp.charset(StringUtil.removeQuotes(cs)); 54 | } 55 | } 56 | 57 | @Override public boolean canHandle(Feed feed) { 58 | return isHttpSource(feed); 59 | } 60 | 61 | public static boolean isHttpSource(Feed feed) { 62 | try { 63 | String url = feed.getLocation().toLowerCase(Locale.ROOT); 64 | return url.startsWith("http://") || url.startsWith("https://"); 65 | } catch (RuntimeException e) { 66 | log.debug("Unexpected issues: " + e, e); 67 | return false; 68 | } 69 | } 70 | 71 | public static Map> parseQueryString( 72 | String url, Charset charset) throws UnsupportedEncodingException { 73 | Map> result = new HashMap<>(); 74 | int qmPos = url.indexOf('?'); 75 | if (qmPos < 0) { 76 | return result; 77 | } 78 | String[] params = url.substring(qmPos+1).split("&"); 79 | for (String param : params) { 80 | int eqPos = param.indexOf('='); 81 | String name; 82 | String value = null; 83 | if (eqPos < 0) { 84 | name = param; 85 | } else { 86 | name = param.substring(0, eqPos); 87 | value = URLDecoder.decode(param.substring(eqPos+1), charset.name()); 88 | } 89 | if (!result.containsKey(name)) { 90 | result.put(name, new ArrayList<>()); 91 | } 92 | result.get(name).add(value); 93 | } 94 | return result; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/main/java/fiets/views/FeedsHtmlView.java: -------------------------------------------------------------------------------- 1 | package fiets.views; 2 | 3 | import fiets.model.Feed; 4 | import fiets.model.FeedInfo; 5 | import fiets.model.FeedMetadata; 6 | import fiets.views.Pages.Name; 7 | 8 | import java.io.UnsupportedEncodingException; 9 | import java.net.URLEncoder; 10 | import java.nio.charset.StandardCharsets; 11 | import java.util.List; 12 | 13 | public class FeedsHtmlView implements View { 14 | private static final String BOOKMARKLET = "javascript:(function()%7Bfunction%20callback()%7B(function(%24)%7Bvar%20jQuery%3D%24%3B%24.ajax(%7Burl%3A%20%22*HOST*%2Fadd-feed%22%2CdataType%3A%20%22jsonp%22%2Cdata%3A%20%7B%20url%3A%20window.location.href%20%7D%7D).done(function%20(data)%20%7Balert(data.title%20%7C%7C%20data.error)%3B%7D)%7D)(jQuery.noConflict(true))%7Dvar%20s%3Ddocument.createElement(%22script%22)%3Bs.src%3D%22https%3A%2F%2Fcode.jquery.com%2Fjquery-1.11.1.min.js%22%3Bif(s.addEventListener)%7Bs.addEventListener(%22load%22%2Ccallback%2Cfalse)%7Delse%20if(s.readyState)%7Bs.onreadystatechange%3Dcallback%7Ddocument.body.appendChild(s)%3B%7D)()"; 15 | private final String hostname; 16 | private List feeds; 17 | private int unreadCount; 18 | private int bookmarkCount; 19 | 20 | public FeedsHtmlView(String theHostname, List feedInfos, 21 | int theUnreadCount, int theBookmarkCount) { 22 | hostname = theHostname; 23 | feeds = feedInfos; 24 | unreadCount = theUnreadCount; 25 | bookmarkCount = theBookmarkCount; 26 | } 27 | 28 | @Override public String getMimeType() { 29 | return "text/html"; 30 | } 31 | 32 | @Override public String getContent() { 33 | StringBuilder sb = new StringBuilder() 34 | .append(Pages.headerTemplate(Name.feeds, feeds.size() + " feeds", 35 | unreadCount, bookmarkCount)) 36 | .append(addFeedBookmarklet()) 37 | .append("
    "); 38 | for (FeedInfo f : feeds) { 39 | sb.append("
  • ") 44 | .append(feed(f)) 45 | .append("
  • "); 46 | } 47 | return sb.append("
").append( 48 | Pages.footerTemplate("")).toString(); 49 | } 50 | 51 | private String addFeedBookmarklet() { 52 | return String.format( 53 | "add-feed bookmarklet", 54 | BOOKMARKLET); 55 | } 56 | 57 | private String feed(FeedInfo fi) { 58 | Feed f = fi.getFeed(); 59 | return String.format( 60 | "%s (unread: %d, read: %d, last post: %s, status: %s)" 61 | + "Update" 62 | + "Delete", 63 | f.getLocation(), f.getTitle(), fi.getNumUnread(), fi.getNumRead(), 64 | PostDisplay.fmtDate(fi.getMostRecentPost()), 65 | FeedMetadata.stripColor(f.getLastStatus()), 66 | updateFeedLink(f), deleteFeedLink(f)); 67 | } 68 | 69 | private String updateFeedLink(Feed f) { 70 | return "/update-feed?id=" + f.getId(); 71 | } 72 | 73 | private String deleteFeedLink(Feed f) { 74 | return "/delete-feed?id=" + f.getId(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/main/java/fiets/views/PostDisplay.java: -------------------------------------------------------------------------------- 1 | package fiets.views; 2 | 3 | import java.text.SimpleDateFormat; 4 | import java.util.Date; 5 | import java.util.Locale; 6 | 7 | import org.apache.logging.log4j.LogManager; 8 | import org.apache.logging.log4j.Logger; 9 | import org.jsoup.Jsoup; 10 | 11 | import fiets.model.Post; 12 | import fiets.model.FeedMetadata; 13 | 14 | public class PostDisplay { 15 | private static final Logger log = LogManager.getLogger(); 16 | 17 | private final Post post; 18 | 19 | public PostDisplay(Post thePost) { 20 | post = thePost; 21 | } 22 | 23 | public String getTitle() { 24 | return cleanup(post.getTitle()); 25 | } 26 | 27 | public String getShortenedTitle() { 28 | return shorten(getTitle(), 90); 29 | } 30 | 31 | public String getShortenedSnippet() { 32 | String snippet = post.getSnippet(); 33 | if (snippet == null || snippet.trim().length() == 0) { 34 | snippet = post.getTitle(); 35 | } 36 | return shorten(cleanup(snippet), 330); 37 | } 38 | 39 | public String getDate() { 40 | return fmtDate(post.getDate()); 41 | } 42 | 43 | public static String fmtDate(Date date) { 44 | return date == null ? "--" : 45 | new SimpleDateFormat("dd-MMM-yy", Locale.ENGLISH).format(date); 46 | } 47 | 48 | public String getFeedTitle() { 49 | return cleanup(post.getFeed().getTitle()); 50 | } 51 | 52 | public String getMobileFeedTitle() { 53 | String title = getFeedTitle(); 54 | if (title.length() <= 10) { 55 | return title; 56 | } 57 | 58 | String[] words = title.split("\\s+"); 59 | if (words.length == 0) { 60 | return title; 61 | } 62 | 63 | String firstWord = words[0]; 64 | if (firstWord.length() > 10) { 65 | return shortenFirstWord(firstWord); 66 | } 67 | 68 | StringBuilder shortened = new StringBuilder(firstWord); 69 | for (int i = 1; i < words.length; i++) { 70 | String next = words[i]; 71 | if (shortened.length() + 1 + next.length() > 10) { 72 | return shortened.toString(); 73 | } 74 | shortened.append(' ').append(next); 75 | } 76 | return shortened.toString(); 77 | } 78 | 79 | public String getLocation() { 80 | return cleanup(post.getLocation()); 81 | } 82 | 83 | public String getFeedColor() { 84 | return FeedMetadata.ensureColorOrDefault( 85 | FeedMetadata.extractColor(post.getFeed())); 86 | } 87 | 88 | public String getReadableTextColor() { 89 | return FeedMetadata.readableTextColor(getFeedColor()); 90 | } 91 | 92 | private static String shorten(String text, int maxLength) { 93 | if (text.length() > maxLength) { 94 | text = text.substring(0, maxLength-1) + " […]"; 95 | } 96 | return text; 97 | } 98 | 99 | private static String shortenFirstWord(String word) { 100 | int maxChars = Math.min(word.length(), 10); 101 | for (int i = 0; i < maxChars; i++) { 102 | char c = word.charAt(i); 103 | if (!Character.isLetterOrDigit(c)) { 104 | return word.substring(0, i); 105 | } 106 | } 107 | return word.substring(0, maxChars); 108 | } 109 | 110 | private static String cleanup(String text) { 111 | try { 112 | return Jsoup.parse(text).text() 113 | .replace("'", "'").replace("\"", """); 114 | } catch (RuntimeException e) { 115 | log.error("Parser error: " + e, e); 116 | return text; 117 | } 118 | } 119 | 120 | } 121 | -------------------------------------------------------------------------------- /src/main/java/fiets/SessionDecorator.java: -------------------------------------------------------------------------------- 1 | package fiets; 2 | 3 | import java.io.IOException; 4 | import java.util.ArrayList; 5 | import java.util.HashMap; 6 | import java.util.List; 7 | import java.util.Map; 8 | 9 | import fi.iki.elonen.NanoHTTPD.IHTTPSession; 10 | import fi.iki.elonen.NanoHTTPD.ResponseException; 11 | 12 | public class SessionDecorator { 13 | 14 | private final IHTTPSession session; 15 | private final String path; 16 | private final String mainPath; 17 | 18 | public SessionDecorator(IHTTPSession theSession) { 19 | session = theSession; 20 | path = extractPath(session); 21 | mainPath = extractMainPath(path); 22 | } 23 | 24 | private static String extractMainPath(String thePath) { 25 | String mainPath = thePath; 26 | int slashPos = mainPath.indexOf('/'); 27 | if (slashPos > 0) { 28 | mainPath = thePath.substring(0, slashPos); 29 | } 30 | return mainPath; 31 | } 32 | 33 | private static String extractPath(IHTTPSession theSession) { 34 | String path = theSession.getUri(); 35 | path = path.replace("/..", ""); 36 | if (path.endsWith("/")) { 37 | path = path.substring(0, path.length()-1); 38 | } 39 | if (path.startsWith("/")) { 40 | path = path.substring(1); 41 | } 42 | return path; 43 | } 44 | 45 | public String getMainPath() { 46 | return mainPath; 47 | } 48 | 49 | public String getPath() { 50 | return path; 51 | } 52 | 53 | public int intParam(String name) { 54 | List values = session.getParameters().get(name); 55 | if (values == null || values.size() != 1) { 56 | throw new IllegalArgumentException(String.format( 57 | "Wrong value provided for '%s' parameter: %s", name, values)); 58 | } 59 | return Integer.parseInt(values.get(0)); 60 | } 61 | 62 | public int intParamOr(String name, int defaultValue) { 63 | List values = session.getParameters().get(name); 64 | if (values == null || values.size() != 1) { 65 | return defaultValue; 66 | } 67 | return Integer.parseInt(values.get(0)); 68 | } 69 | 70 | public List longParams(String name) { 71 | List values = session.getParameters().get(name); 72 | List longs = new ArrayList<>(); 73 | if (values != null) { 74 | for (String value : values) { 75 | String[] parts = value.split(","); 76 | for (String part : parts) { 77 | longs.add(Long.valueOf(part)); 78 | } 79 | } 80 | } 81 | return longs; 82 | } 83 | 84 | public long longParam(String name) { 85 | List values = session.getParameters().get(name); 86 | if (values == null || values.size() != 1) { 87 | throw new IllegalArgumentException(String.format( 88 | "Wrong value provided for '%s' parameter: %s", name, values)); 89 | } 90 | return Long.parseLong(values.get(0)); 91 | } 92 | 93 | public String stringParam(String name) { 94 | List stringParams = stringParams(name); 95 | return stringParams != null && stringParams.size() > 0 96 | ? stringParams.get(0) : null; 97 | } 98 | 99 | public List stringParams(String name) { 100 | return session.getParameters().get(name); 101 | } 102 | 103 | public Map> getParameters() { 104 | return session.getParameters(); 105 | } 106 | 107 | public Map> postParameters() 108 | throws IOException, ResponseException { 109 | session.parseBody(new HashMap<>()); 110 | return session.getParameters(); 111 | } 112 | 113 | public String getHostname() { 114 | String host = session.getHeaders().get("Host"); 115 | if (host == null) { 116 | return "unknown"; 117 | } 118 | return host; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/main/java/fiets/processors/ContentUnfolder.java: -------------------------------------------------------------------------------- 1 | package fiets.processors; 2 | 3 | import org.apache.logging.log4j.LogManager; 4 | import org.apache.logging.log4j.Logger; 5 | 6 | import de.l3s.boilerpipe.BoilerpipeProcessingException; 7 | import de.l3s.boilerpipe.extractors.ArticleExtractor; 8 | import jodd.http.HttpRequest; 9 | import jodd.http.HttpResponse; 10 | import jodd.http.HttpStatus; 11 | import jodd.jerry.Jerry; 12 | 13 | public class ContentUnfolder { 14 | private static final Logger log = LogManager.getLogger(); 15 | 16 | public String unfoldUrls(String snippet, String urlPrefix) { 17 | return unfoldUrls(snippet, urlPrefix, 0); 18 | } 19 | 20 | private String unfoldUrls(String snippet, String urlPrefix, int depth) { 21 | if (depth >= 10) { 22 | return snippet; 23 | } 24 | int urlPos = snippet.indexOf(urlPrefix); 25 | if (urlPos < 0) { 26 | return snippet; 27 | } 28 | int urlEnd = snippet.indexOf(' ', urlPos); 29 | if (urlEnd < 0) { 30 | urlEnd = snippet.length(); 31 | } 32 | if (urlPos == 0 && urlEnd == snippet.length()) { 33 | return snippet; 34 | } 35 | String url = snippet.substring(urlPos, urlEnd); 36 | String text = loadUrlContent(url); 37 | String prefix = snippet.substring(0, urlPos); 38 | String suffix = snippet.substring(urlEnd); 39 | log.debug("Unfolded twitter URL %s.", url); 40 | return unfoldUrls( 41 | String.format("%s %s %s", prefix, text, suffix), urlPrefix, depth+1); 42 | } 43 | 44 | private String loadUrlContent(String url) { 45 | return loadUrlContent(url, 0); 46 | } 47 | 48 | private String loadUrlContent(String url, int depth) { 49 | if (depth > 3) { 50 | return "Maximum redirect depth reached."; 51 | } 52 | try { 53 | boolean fb = url.contains("facebook.com/"); 54 | if (fb) { 55 | url = appendParam(url, "_fb_noscript=1"); 56 | } 57 | HttpResponse rsp = HttpRequest.get(url) 58 | .timeout(10000) 59 | .connectionTimeout(10000) 60 | .trustAllCerts(true) 61 | .send(); 62 | if (rsp.statusCode() == HttpStatus.HTTP_MOVED_PERMANENTLY) { 63 | String redirect = rsp.header("location"); 64 | return loadUrlContent(redirect, depth+1); 65 | } 66 | String media = rsp.mediaType(); 67 | if (media != null && media.startsWith("image/")) { 68 | return "[IMAGE]"; 69 | } 70 | String html = rsp.bodyText(); 71 | if (fb) { 72 | return extractFacebookArticle(url, html); 73 | } 74 | return extractArbitraryArticle(url, html); 75 | } catch (RuntimeException e) { 76 | String msg = String.format("Could not extract URL content for %s: %s.", 77 | url, e.getMessage()); 78 | log.error(msg, e); 79 | return msg; 80 | } 81 | } 82 | 83 | private static String appendParam(String url, String param) { 84 | char sep = '?'; 85 | if (url.indexOf('?') >= 0) { 86 | sep = '&'; 87 | } 88 | return String.format("%s%s%s", url, sep, param); 89 | } 90 | 91 | private String extractFacebookArticle(String url, String html) { 92 | Jerry jerry = Jerry.jerry(html); 93 | return jerry.find("#contentArea").text(); 94 | } 95 | 96 | private String extractArbitraryArticle(String url, String html) { 97 | String text; 98 | try { 99 | text = ArticleExtractor.INSTANCE.getText(html); 100 | } catch (BoilerpipeProcessingException e) { 101 | text = String.format("Could not extract integrated URL %s.", url); 102 | log.error(text, e); 103 | } 104 | return text; 105 | } 106 | 107 | public static void main(String[] args) { 108 | System.out.println(new ContentUnfolder().loadUrlContent("https://t.co/kTIPi1r5lz")); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/main/java/fiets/processors/FacebookProcessor.java: -------------------------------------------------------------------------------- 1 | package fiets.processors; 2 | 3 | import fiets.Filterer; 4 | import fiets.model.Feed; 5 | import fiets.model.Post; 6 | import jodd.jerry.Jerry; 7 | import org.apache.logging.log4j.LogManager; 8 | import org.apache.logging.log4j.Logger; 9 | 10 | import java.net.MalformedURLException; 11 | import java.net.URL; 12 | import java.util.*; 13 | 14 | public class FacebookProcessor implements FeedProcessor { 15 | 16 | private static final Logger log = LogManager.getLogger(); 17 | 18 | private static final String FB_HOST = "www.facebook.com"; 19 | 20 | @Override public boolean canHandle(Feed feed, String content) { 21 | try { 22 | return new URL(feed.getLocation()).getHost().equals(FB_HOST); 23 | } catch (MalformedURLException e) { 24 | log.error(e, e); 25 | return false; 26 | } 27 | } 28 | 29 | @Override public String parseTitle(Feed feed, String content) { 30 | return "Facebook - " + Jerry.jerry(content).find("title").text(); 31 | } 32 | 33 | @Override public List parsePosts( 34 | Feed feed, String content) { 35 | List result = new ArrayList<>(); 36 | Set doubleUrlsInOneTake = new HashSet<>(); 37 | Jerry jerry = Jerry.jerry(content); 38 | int maxLevel = findMaxHLevel(jerry); 39 | Jerry titles = jerry.find("h" + maxLevel); 40 | titles.forEach(title -> { 41 | Jerry parent = title.parent(); 42 | for (int i = 0; i < 20; i++) { 43 | int len = parent.text().length(); 44 | if (len > 100) { 45 | Post p = createPost(title, parent, feed); 46 | if (!doubleUrlsInOneTake.contains(p.getLocation())) { 47 | result.add(p); 48 | doubleUrlsInOneTake.add(p.getLocation()); 49 | } 50 | break; 51 | } 52 | parent = parent.parent(); 53 | } 54 | }); 55 | 56 | if (result.isEmpty()) { 57 | result.add(Process.errorPost(feed, "No posts found!")); 58 | } 59 | return result; 60 | } 61 | 62 | private Date tryFindDate(Jerry post) { 63 | try { 64 | String utime = post.find("[data-utime]").attr("data-utime"); 65 | if (utime != null) { 66 | return new Date(Long.parseLong(utime) * 1000); 67 | } else { 68 | return new Date(); 69 | } 70 | } catch (RuntimeException e) { 71 | return new Date(); 72 | } 73 | } 74 | 75 | private Post createPost(Jerry title, Jerry post, Feed feed) { 76 | String link = tryFindUrl(title, post); 77 | Date date = tryFindDate(post); 78 | post.find("[class^='timestamp']").remove(); 79 | String titleString = title.text(); 80 | if (titleString.length() < 20) { 81 | String postText = post.text(); 82 | int len = Math.min(postText.length(), 100); 83 | titleString = post.text().substring(0, len); 84 | } 85 | return new Post(0L, link, date, titleString, post.text(), false, feed); 86 | } 87 | 88 | private String tryFindUrl(Jerry title, Jerry parent) { 89 | String urlCandidate = null; 90 | Jerry a = title.find("a"); 91 | if (a.length() > 0) { 92 | urlCandidate = a.attr("href"); 93 | } 94 | a = parent.find("a[href^='/']"); 95 | if (a.length() > 0) { 96 | urlCandidate = a.attr("href"); 97 | } 98 | if (urlCandidate == null) { 99 | urlCandidate = "https://" + FB_HOST + "?" + System.currentTimeMillis(); 100 | } else if (!urlCandidate.startsWith("http://") && !urlCandidate.startsWith("https://")) { 101 | urlCandidate = "https://" + FB_HOST + urlCandidate; 102 | } 103 | return removeUrlQuery(urlCandidate); 104 | } 105 | 106 | private String removeUrlQuery(String urlCandidate) { 107 | int qm = urlCandidate.indexOf('?'); 108 | if (qm < 0) { 109 | return urlCandidate; 110 | } 111 | return urlCandidate.substring(0, qm); 112 | } 113 | 114 | private int findMaxHLevel(Jerry jerry) { 115 | int maxCount = 0; 116 | int maxLevel = 0; 117 | for (int i = 1; i <= 6; i++) { 118 | int thisCount = jerry.find("h" + i).length(); 119 | if (thisCount > maxCount) { 120 | maxCount = thisCount; 121 | maxLevel = i; 122 | } 123 | } 124 | return maxLevel; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/main/java/fiets/db/FilterDao.java: -------------------------------------------------------------------------------- 1 | package fiets.db; 2 | 3 | import fiets.model.Feed; 4 | import fiets.model.Filter; 5 | import fiets.model.FilterMatch; 6 | import org.apache.logging.log4j.LogManager; 7 | import org.apache.logging.log4j.Logger; 8 | import java.sql.Connection; 9 | import java.sql.PreparedStatement; 10 | import java.sql.ResultSet; 11 | import java.sql.SQLException; 12 | import java.util.ArrayList; 13 | import java.util.List; 14 | import java.util.Optional; 15 | 16 | public class FilterDao { 17 | private static final Logger log = LogManager.getLogger(); 18 | 19 | private Database db; 20 | private FeedDao fd; 21 | 22 | public FilterDao(Database theDb, FeedDao theFd) throws SQLException { 23 | db = theDb; 24 | fd = theFd; 25 | createTable(); 26 | } 27 | 28 | private int createTable() throws SQLException { 29 | try (PreparedStatement ps = db.getConnection().prepareStatement( 30 | "CREATE TABLE IF NOT EXISTS filter (" 31 | + "id BIGINT PRIMARY KEY AUTO_INCREMENT," 32 | + "feed BIGINT," 33 | + "url VARCHAR(2048)," 34 | + "urlmatch TINYINT," 35 | + "title VARCHAR(2048)," 36 | + "titlematch TINYINT," 37 | + "matchcount BIGINT DEFAULT 0" 38 | + ");")) { 39 | return ps.executeUpdate(); 40 | } 41 | } 42 | 43 | public Filter saveFilter(Filter filter) throws SQLException { 44 | return insertFilter(filter); 45 | } 46 | 47 | private Filter insertFilter(Filter filter) throws SQLException { 48 | try (PreparedStatement ps = db.getConnection().prepareStatement( 49 | "INSERT INTO filter (url, urlmatch, title, titlematch) " 50 | + "VALUES (?,?,?,?)")) { 51 | ps.setString(1, filter.getUrl()); 52 | ps.setInt(2, filter.getUrlMatch().ordinal()); 53 | ps.setString(3, filter.getTitle()); 54 | ps.setInt(4, filter.getTitleMatch().ordinal()); 55 | ps.executeUpdate(); 56 | } 57 | return filter; 58 | } 59 | 60 | public Filter updateFilterKeepMatchCount(Filter filter) throws SQLException { 61 | try (PreparedStatement ps = db.getConnection().prepareStatement( 62 | "UPDATE filter SET url=?, urlMatch=?, title=?, titleMatch=? WHERE id=?")) { 63 | ps.setString(1, filter.getUrl()); 64 | ps.setInt(2, filter.getUrlMatch().ordinal()); 65 | ps.setString(3, filter.getTitle()); 66 | ps.setInt(4, filter.getTitleMatch().ordinal()); 67 | ps.setLong(5, filter.getId()); 68 | ps.executeUpdate(); 69 | } 70 | return filter; 71 | } 72 | 73 | 74 | 75 | public List getAllFilters() throws SQLException { 76 | List filters = new ArrayList<>(); 77 | try (PreparedStatement ps = db.getConnection().prepareStatement( 78 | "SELECT id, url, urlmatch, title, titlematch, matchcount FROM filter")) { 79 | ResultSet rs = ps.executeQuery(); 80 | while (rs.next()) { 81 | filters.add(parseFilterResultSet(rs)); 82 | } 83 | } 84 | return filters; 85 | } 86 | 87 | private Filter parseFilterResultSet(ResultSet rs) throws SQLException { 88 | int i = 0; 89 | long id = rs.getLong(++i); 90 | String url = rs.getString(++i); 91 | FilterMatch urlMatch = FilterMatch.values()[(rs.getInt(++i))]; 92 | String title = rs.getString(++i); 93 | FilterMatch titleMatch = FilterMatch.values()[rs.getInt(++i)]; 94 | long matchCount = rs.getLong(++i); 95 | Filter filter = new Filter(id, url, urlMatch, title, titleMatch, matchCount); 96 | return filter; 97 | } 98 | 99 | public void deleteFilter(long id) throws SQLException { 100 | Connection conn = db.getConnection(); 101 | try (PreparedStatement ps = conn.prepareStatement( 102 | "DELETE FROM filter WHERE id=?")) { 103 | ps.setLong(1, id); 104 | ps.executeUpdate(); 105 | } 106 | log.info("Deleted filter with ID {}.", id); 107 | } 108 | 109 | public void updateMatchCount(Filter f) throws SQLException { 110 | Connection conn = db.getConnection(); 111 | try (PreparedStatement ps = conn.prepareStatement( 112 | "UPDATE filter SET matchcount=? WHERE id=?")) { 113 | ps.setLong(1, f.getMatchCount()); 114 | ps.setLong(2, f.getId()); 115 | ps.executeUpdate(); 116 | } 117 | log.debug("Increased match count for filter with ID {} to {}.", f.getId(), f.getMatchCount()); 118 | } 119 | 120 | public void updateMatchCounts(List filters) throws SQLException { 121 | for (Filter filter : filters) { 122 | updateMatchCount(filter); 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /scripts/generate_favicon.py: -------------------------------------------------------------------------------- 1 | """Generate favicon assets without third-party imaging libraries.""" 2 | 3 | import struct 4 | import zlib 5 | from pathlib import Path 6 | from typing import Tuple 7 | 8 | 9 | ROOT = Path(__file__).resolve().parents[1] 10 | ICON_DIR = ROOT / "src" / "main" / "resources" / "static" / "icons" 11 | 12 | # Colors (RGBA) 13 | BACKGROUND = (6, 15, 31, 255) 14 | ACCENT = (246, 96, 37, 255) 15 | FOREGROUND = (255, 255, 255, 255) 16 | 17 | 18 | def in_circle(x: float, y: float, radius: float = 0.38) -> bool: 19 | dx = x - 0.5 20 | dy = y - 0.5 21 | return dx * dx + dy * dy <= radius * radius 22 | 23 | 24 | def in_rounded_rect( 25 | x: float, y: float, x0: float, x1: float, y0: float, y1: float, radius: float 26 | ) -> bool: 27 | if not (x0 <= x <= x1 and y0 <= y <= y1): 28 | return False 29 | if radius <= 0: 30 | return True 31 | 32 | inner_x0 = x0 + radius 33 | inner_x1 = x1 - radius 34 | inner_y0 = y0 + radius 35 | inner_y1 = y1 - radius 36 | 37 | if inner_x0 <= x <= inner_x1 or inner_y0 <= y <= inner_y1: 38 | return True 39 | 40 | cx = inner_x0 if x < inner_x0 else inner_x1 41 | cy = inner_y0 if y < inner_y0 else inner_y1 42 | dx = x - cx 43 | dy = y - cy 44 | return dx * dx + dy * dy <= radius * radius 45 | 46 | 47 | def color_at(x: float, y: float) -> Tuple[int, int, int, int]: 48 | in_badge = in_circle(x, y) 49 | color = ACCENT if in_badge else BACKGROUND 50 | if not in_badge: 51 | return color 52 | 53 | radius = 0.025 54 | if in_rounded_rect(x, y, 0.35, 0.46, 0.28, 0.77, radius): 55 | return FOREGROUND 56 | if in_rounded_rect(x, y, 0.35, 0.74, 0.28, 0.38, radius): 57 | return FOREGROUND 58 | if in_rounded_rect(x, y, 0.35, 0.68, 0.46, 0.56, radius): 59 | return FOREGROUND 60 | 61 | return color 62 | 63 | 64 | def sample_pixel(x: int, y: int, size: int) -> Tuple[int, int, int, int]: 65 | offsets = (0.25, 0.75) 66 | accum = [0.0, 0.0, 0.0, 0.0] 67 | count = 0 68 | for ox in offsets: 69 | for oy in offsets: 70 | fx = (x + ox) / size 71 | fy = (y + oy) / size 72 | rgba = color_at(fx, fy) 73 | for i in range(4): 74 | accum[i] += rgba[i] 75 | count += 1 76 | return tuple(int(round(v / count)) for v in accum) 77 | 78 | 79 | def build_pixel_buffer(size: int) -> bytes: 80 | buf = bytearray() 81 | for y in range(size): 82 | for x in range(size): 83 | buf.extend(sample_pixel(x, y, size)) 84 | return bytes(buf) 85 | 86 | 87 | def chunk(tag: bytes, data: bytes) -> bytes: 88 | return ( 89 | struct.pack(">I", len(data)) 90 | + tag 91 | + data 92 | + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) 93 | ) 94 | 95 | 96 | def png_bytes(width: int, height: int, pixels: bytes) -> bytes: 97 | assert len(pixels) == width * height * 4 98 | raw = bytearray() 99 | stride = width * 4 100 | for y in range(height): 101 | raw.append(0) 102 | start = y * stride 103 | raw.extend(pixels[start : start + stride]) 104 | compressed = zlib.compress(bytes(raw)) 105 | header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) 106 | return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", compressed) + chunk(b"IEND", b"") 107 | 108 | 109 | def render_png(size: int) -> bytes: 110 | pixels = build_pixel_buffer(size) 111 | return png_bytes(size, size, pixels) 112 | 113 | 114 | def save_png(path: Path, size: int) -> bytes: 115 | data = render_png(size) 116 | path.write_bytes(data) 117 | print(f"Created {path.relative_to(ROOT)}") 118 | return data 119 | 120 | 121 | def save_ico(path: Path, png_data: bytes, size: int) -> None: 122 | width_byte = size if size < 256 else 0 123 | entry = struct.pack( 124 | " None: 140 | ICON_DIR.mkdir(parents=True, exist_ok=True) 141 | sizes = [512, 192, 180, 128, 64, 32] 142 | for size in sizes: 143 | png_path = ICON_DIR / f"icon-{size}.png" 144 | save_png(png_path, size) 145 | ico_path = ICON_DIR / "favicon.ico" 146 | ico_size = 256 147 | ico_png = render_png(ico_size) 148 | save_ico(ico_path, ico_png, size=ico_size) 149 | 150 | 151 | if __name__ == "__main__": 152 | main() 153 | -------------------------------------------------------------------------------- /src/main/java/fiets/Server.java: -------------------------------------------------------------------------------- 1 | package fiets; 2 | 3 | import fi.iki.elonen.NanoHTTPD; 4 | import fi.iki.elonen.NanoHTTPD.Response.Status; 5 | import fiets.db.Database; 6 | import fiets.model.Post; 7 | import fiets.views.View; 8 | import jodd.json.JsonObject; 9 | import org.apache.logging.log4j.LogManager; 10 | import org.apache.logging.log4j.Logger; 11 | 12 | import java.io.FileNotFoundException; 13 | import java.io.IOException; 14 | import java.io.InputStream; 15 | import java.sql.SQLException; 16 | import java.util.*; 17 | 18 | public class Server extends NanoHTTPD { 19 | 20 | private static final Logger log = LogManager.getLogger(); 21 | private final Timer timer = new Timer("fiets-timer", true); 22 | private FeedService fs; 23 | 24 | public Server(int port) { 25 | super(port); 26 | System.out.println("Fiets server listening at port " + port); 27 | } 28 | 29 | public static void main(String[] args) throws Exception { 30 | int port = 7000; 31 | if (args.length > 0) { 32 | port = Integer.parseInt(args[0]); 33 | } 34 | Server srv = new Server(port); 35 | srv.init(); 36 | } 37 | 38 | private void scheduleNowAndEvery(Runnable r, long everyMillis) { 39 | final TimerTask task = new TimerTask() { 40 | @Override public void run() { 41 | try { 42 | r.run(); 43 | } catch (Throwable t) { 44 | 45 | log.error( 46 | "Could not complete scheduled task: {}", t.getMessage(), t); 47 | } 48 | } 49 | }; 50 | timer.schedule(task, 0, everyMillis); 51 | } 52 | 53 | private void init() throws IOException, SQLException { 54 | start(); 55 | try (Database db = new Database()) { 56 | fs = new FeedService(db); 57 | scheduleNowAndEvery(() -> { 58 | log.info("Triggering regular post update."); 59 | fs.updateAllPosts(); 60 | }, minutesMillis(20)); 61 | scheduleNowAndEvery(() -> { 62 | log.info("Deleting {} outdated posts.", fs.getOutdatedCount()); 63 | fs.dropOutdated(); 64 | log.info("Done deleting outdated posts."); 65 | }, dayMillis()); 66 | waitForever(); 67 | } 68 | } 69 | 70 | private static int minutesMillis(int minutes) { 71 | return 1000*60*minutes; 72 | } 73 | 74 | private static int dayMillis() { 75 | return minutesMillis(24*60); 76 | } 77 | 78 | private static void waitForever() { 79 | do { 80 | try { 81 | Thread.sleep(1000); 82 | } catch (InterruptedException e) { 83 | Thread.currentThread().interrupt(); 84 | } 85 | } while (true); 86 | } 87 | 88 | @Override public Response serve(IHTTPSession session) { 89 | try { 90 | SessionDecorator sd = new SessionDecorator(session); 91 | PathMatch pm = PathMatch.match(sd); 92 | View view = pm.serve(sd, fs); 93 | Object content = view.getContent(); 94 | Response rsp; 95 | if (content instanceof String) { 96 | rsp = newFixedLengthResponse((String) content); 97 | rsp.setMimeType(view.getMimeType()); 98 | } else if (content instanceof InputStream) { 99 | rsp = newChunkedResponse( 100 | Status.OK, view.getMimeType(), (InputStream) content); 101 | } else if (content instanceof PathMatch) { 102 | rsp = redirect(((PathMatch) content).getUrl()); 103 | } else { 104 | throw new IllegalStateException( 105 | "Unknown content type: " + content.getClass()); 106 | } 107 | return rsp; 108 | } catch (FileNotFoundException e) { 109 | return error(Status.NOT_FOUND, "File does not exist.", e); 110 | } catch (IllegalArgumentException e) { 111 | return error(Status.BAD_REQUEST, "Bad request: " + e, e); 112 | } catch (Exception e) { 113 | return error(Status.INTERNAL_ERROR, "Unexpected issue.", e); 114 | } 115 | } 116 | 117 | public static JsonObject jsonOk() { 118 | return new JsonObject().put("status", "OK"); 119 | } 120 | 121 | public static Response error(Status status, String msg, Throwable t) { 122 | if (t == null) { 123 | log.error(msg); 124 | } else { 125 | msg = String.format("%s (%s)", msg, t.getMessage()); 126 | log.error(msg, t); 127 | } 128 | return newFixedLengthResponse(status, MIME_HTML, msg); 129 | } 130 | 131 | @SuppressWarnings("deprecation") 132 | private Response redirect(String target) { 133 | Response rsp = newFixedLengthResponse( 134 | Status.FOUND, "text/plain", "Redirecting to " + target); 135 | rsp.addHeader("Location", target); 136 | return rsp; 137 | } 138 | 139 | public static Set getIds(Collection posts) { 140 | Set ids = new HashSet<>(); 141 | for (Post p : posts) { 142 | ids.add(p.getId()); 143 | } 144 | return ids; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/main/resources/static/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | position: relative; 3 | min-height: 100%; 4 | } 5 | 6 | body { 7 | margin: 0; 8 | padding-bottom: 80px; 9 | font-family: 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif; 10 | background: #f3f4f6; 11 | color: #1f2937; 12 | } 13 | 14 | a { color: inherit; } 15 | 16 | .container { 17 | position: relative; 18 | } 19 | 20 | .chic-nav { 21 | margin-top: 22px; 22 | padding: 12px 18px; 23 | border-radius: 18px; 24 | background: #ffffff; 25 | box-shadow: 0 4px 14px rgba(17, 24, 39, 0.08); 26 | border: 1px solid rgba(31, 41, 55, 0.08); 27 | } 28 | 29 | .navbar-brand { 30 | letter-spacing: 0.02em; 31 | color: #111827 !important; 32 | } 33 | 34 | .nav-link { 35 | color: #334155 !important; 36 | padding: 8px 12px !important; 37 | border-radius: 12px; 38 | transition: all 0.2s ease; 39 | } 40 | 41 | .nav-item.active .nav-link, 42 | .nav-link:hover, 43 | .nav-link:focus { 44 | color: #0f172a !important; 45 | background: linear-gradient(135deg, #e2e8f0 0%, #f8fafc 100%); 46 | box-shadow: 0 8px 20px rgba(12, 18, 31, 0.08); 47 | } 48 | 49 | .footer { 50 | position: fixed; 51 | bottom: 0; 52 | width: 100%; 53 | height: 60px; 54 | line-height: 60px; 55 | background: #ffffff; 56 | border-top: 1px solid rgba(31, 41, 55, 0.08); 57 | z-index: 20; 58 | color: #475569; 59 | } 60 | 61 | .footer-content { 62 | display: flex; 63 | align-items: center; 64 | justify-content: space-between; 65 | height: 60px; 66 | line-height: 1.4; 67 | } 68 | 69 | .post, .feed { 70 | position: relative; 71 | } 72 | 73 | .list-group { 74 | gap: 12px; 75 | display: flex; 76 | flex-direction: column; 77 | } 78 | 79 | .list-group-item { 80 | border: none; 81 | border-radius: 14px !important; 82 | background: #ffffff; 83 | color: #1f2937; 84 | box-shadow: 0 4px 12px rgba(17, 24, 39, 0.08); 85 | transition: box-shadow 0.2s ease, background 0.2s ease; 86 | } 87 | 88 | .list-group-item:hover { 89 | background: #f8fafc; 90 | box-shadow: 0 8px 18px rgba(17, 24, 39, 0.1); 91 | } 92 | 93 | .feed-actions, .filter-actions { 94 | float: right; 95 | } 96 | 97 | @media (min-width: 576px) { 98 | .post-actions { 99 | visibility: hidden; 100 | } 101 | } 102 | 103 | .post:hover .post-actions { 104 | visibility: visible; 105 | } 106 | 107 | .post h3 { 108 | font-size: 1.15rem; 109 | color: #0f172a; 110 | margin-top: 8px; 111 | } 112 | 113 | .post h3 a { 114 | text-decoration: none; 115 | } 116 | 117 | .post h3 a:hover { 118 | text-decoration: underline; 119 | text-decoration-thickness: 2px; 120 | } 121 | 122 | .post small { 123 | color: #64748b; 124 | } 125 | 126 | .post-meta { 127 | display: flex; 128 | align-items: center; 129 | flex-wrap: wrap; 130 | gap: 6px; 131 | } 132 | 133 | .post-actions { 134 | margin-left: auto; 135 | display: flex; 136 | align-items: center; 137 | gap: 8px; 138 | } 139 | 140 | .post-date { 141 | color: #475569; 142 | } 143 | 144 | .meta-separator { 145 | color: #cbd5e1; 146 | } 147 | 148 | .feed-title-mobile { 149 | display: none; 150 | } 151 | 152 | .post.bookmarked .add-bookmark, .post:not(.bookmarked) .remove-bookmark { 153 | display: none; 154 | } 155 | 156 | .list-group-item.post.bookmarked { 157 | border: 1px solid rgba(51, 65, 85, 0.26); 158 | box-shadow: 0 6px 16px rgba(17, 24, 39, 0.12); 159 | background: #f8fafc; 160 | z-index: 1; 161 | } 162 | 163 | .btn-link { 164 | color: #334155; 165 | text-decoration: none; 166 | font-weight: 600; 167 | } 168 | 169 | .btn-link:hover, .btn-link:focus { 170 | color: #0f172a; 171 | } 172 | 173 | .nav-item.active { 174 | margin-bottom: 5px; 175 | } 176 | 177 | .deployment-info { 178 | font-size: 0.85rem; 179 | color: #64748b; 180 | text-align: right; 181 | } 182 | 183 | .post-actions .btn, 184 | .feed-actions .btn, 185 | .filter-actions .btn { 186 | background: rgba(51, 65, 85, 0.08); 187 | border-radius: 10px; 188 | border: 1px solid rgba(51, 65, 85, 0.22); 189 | color: #111827; 190 | padding: 4px 10px; 191 | transition: all 0.15s ease; 192 | } 193 | 194 | .post-actions .btn:hover, 195 | .feed-actions .btn:hover, 196 | .filter-actions .btn:hover { 197 | color: #0f172a; 198 | background: linear-gradient(135deg, #e2e8f0 0%, #f8fafc 100%); 199 | } 200 | 201 | .post-title { 202 | display: inline-block; 203 | padding: 8px 12px; 204 | border-radius: 12px; 205 | margin-top: 12px; 206 | transition: transform 0.15s ease, box-shadow 0.15s ease; 207 | box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45); 208 | } 209 | 210 | .post-title:hover { 211 | transform: translateY(-1px); 212 | box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.65), 0 4px 10px rgba(15, 23, 42, 0.12); 213 | } 214 | 215 | .post-actions .label-short { 216 | display: none; 217 | } 218 | 219 | .list-group-item .badge { 220 | background: rgba(51, 65, 85, 0.12); 221 | color: #111827; 222 | } 223 | 224 | @media (max-width: 575.98px) { 225 | .post-meta { 226 | line-height: 1.25; 227 | } 228 | 229 | .feed-title-full { 230 | display: none; 231 | } 232 | 233 | .feed-title-mobile { 234 | display: inline; 235 | font-weight: 600; 236 | } 237 | 238 | .post-actions { 239 | float: none; 240 | margin-left: auto; 241 | display: inline-flex; 242 | align-items: center; 243 | gap: 6px; 244 | } 245 | 246 | .post-actions .btn { 247 | padding: 2px 8px; 248 | line-height: 1.2; 249 | } 250 | 251 | .post-actions .label-full { 252 | display: none; 253 | } 254 | 255 | .post-actions .label-short { 256 | display: inline; 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /src/main/java/fiets/DeploymentInfo.java: -------------------------------------------------------------------------------- 1 | package fiets; 2 | 3 | import java.io.BufferedReader; 4 | import java.io.IOException; 5 | import java.io.InputStream; 6 | import java.io.InputStreamReader; 7 | import java.nio.charset.StandardCharsets; 8 | import java.time.OffsetDateTime; 9 | import java.time.ZoneId; 10 | import java.time.format.DateTimeFormatter; 11 | import java.time.format.DateTimeParseException; 12 | import java.util.ArrayList; 13 | import java.util.List; 14 | import java.util.Locale; 15 | import java.util.Properties; 16 | 17 | import org.apache.logging.log4j.LogManager; 18 | import org.apache.logging.log4j.Logger; 19 | 20 | public final class DeploymentInfo { 21 | private static final Logger log = LogManager.getLogger(); 22 | private static final String UNKNOWN = "unbekannt"; 23 | private static final String PROPERTIES_PATH = "/fiets/deployment-info.properties"; 24 | private static final Properties BUILD_PROPERTIES = loadBuildProperties(); 25 | private static final String BRANCH = determineBranch(); 26 | private static final String COMMIT_TIME = determineCommitTime(); 27 | private static final ZoneId DISPLAY_ZONE = ZoneId.of("Europe/Berlin"); 28 | private static final DateTimeFormatter COMMIT_TIME_INPUT_FORMAT = 29 | DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z", Locale.ROOT); 30 | private static final DateTimeFormatter COMMIT_TIME_OUTPUT_FORMAT = 31 | DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z", Locale.GERMAN); 32 | 33 | private DeploymentInfo() {} 34 | 35 | public static String getDisplayText() { 36 | String branch = defaultIfBlank(BRANCH, UNKNOWN); 37 | String commitTime = formatCommitTime(defaultIfBlank(COMMIT_TIME, UNKNOWN)); 38 | return String.format("%s (%s)", escape(commitTime), escape(branch)); 39 | } 40 | 41 | private static String determineBranch() { 42 | String value = System.getProperty("fiets.branch"); 43 | if (!isBlank(value)) { 44 | return value.trim(); 45 | } 46 | value = getBuildProperty("branch"); 47 | if (!isBlank(value)) { 48 | return value.trim(); 49 | } 50 | return runGitCommand("rev-parse", "--abbrev-ref", "HEAD"); 51 | } 52 | 53 | private static String determineCommitTime() { 54 | String value = System.getProperty("fiets.commitTime"); 55 | if (!isBlank(value)) { 56 | return value.trim(); 57 | } 58 | value = getBuildProperty("commitTime"); 59 | if (!isBlank(value)) { 60 | return value.trim(); 61 | } 62 | return runGitCommand("log", "-1", "--format=%cd", 63 | "--date=format:%Y-%m-%d %H:%M:%S %z"); 64 | } 65 | 66 | private static String getBuildProperty(String key) { 67 | return BUILD_PROPERTIES.getProperty(key); 68 | } 69 | 70 | private static String runGitCommand(String... args) { 71 | List command = new ArrayList<>(args.length + 1); 72 | command.add("git"); 73 | for (String arg : args) { 74 | command.add(arg); 75 | } 76 | Process process = null; 77 | try { 78 | process = new ProcessBuilder(command).redirectErrorStream(true).start(); 79 | String output = readProcessOutput(process); 80 | int exitCode = process.waitFor(); 81 | if (exitCode == 0) { 82 | return output.trim(); 83 | } 84 | log.warn("Git command {} exited with code {}", command, exitCode); 85 | } catch (IOException e) { 86 | log.warn("Could not execute git command {}", command, e); 87 | } catch (InterruptedException e) { 88 | Thread.currentThread().interrupt(); 89 | log.warn("Interrupted while executing git command {}", command, e); 90 | } finally { 91 | if (process != null) { 92 | process.destroy(); 93 | } 94 | } 95 | return null; 96 | } 97 | 98 | private static String readProcessOutput(Process process) throws IOException { 99 | StringBuilder builder = new StringBuilder(); 100 | try (BufferedReader reader = new BufferedReader(new InputStreamReader( 101 | process.getInputStream(), StandardCharsets.UTF_8))) { 102 | String line; 103 | while ((line = reader.readLine()) != null) { 104 | builder.append(line).append('\n'); 105 | } 106 | } 107 | return builder.toString(); 108 | } 109 | 110 | private static String defaultIfBlank(String value, String defaultValue) { 111 | return isBlank(value) ? defaultValue : value.trim(); 112 | } 113 | 114 | private static Properties loadBuildProperties() { 115 | Properties properties = new Properties(); 116 | try (InputStream inputStream = DeploymentInfo.class 117 | .getResourceAsStream(PROPERTIES_PATH)) { 118 | if (inputStream != null) { 119 | properties.load(inputStream); 120 | } 121 | } catch (IOException e) { 122 | log.warn("Could not read deployment info properties", e); 123 | } 124 | return properties; 125 | } 126 | 127 | private static String formatCommitTime(String commitTime) { 128 | if (isBlank(commitTime) || UNKNOWN.equals(commitTime)) { 129 | return UNKNOWN; 130 | } 131 | String trimmed = commitTime.trim(); 132 | try { 133 | OffsetDateTime parsed = OffsetDateTime.parse(trimmed, COMMIT_TIME_INPUT_FORMAT); 134 | return parsed.atZoneSameInstant(DISPLAY_ZONE).format(COMMIT_TIME_OUTPUT_FORMAT); 135 | } catch (DateTimeParseException e) { 136 | log.debug("Could not parse commit time '{}'", trimmed, e); 137 | return trimmed; 138 | } 139 | } 140 | 141 | private static boolean isBlank(String value) { 142 | return value == null || value.trim().isEmpty(); 143 | } 144 | 145 | private static String escape(String value) { 146 | return value.replace("&", "&") 147 | .replace("<", "<") 148 | .replace(">", ">"); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/main/java/fiets/views/PostsHtmlView.java: -------------------------------------------------------------------------------- 1 | package fiets.views; 2 | 3 | import java.util.Collections; 4 | import java.util.List; 5 | import java.util.Set; 6 | 7 | import fiets.model.Post; 8 | import fiets.views.Pages.Name; 9 | import org.apache.logging.log4j.LogManager; 10 | import org.apache.logging.log4j.Logger; 11 | 12 | public class PostsHtmlView implements View { 13 | 14 | private static final Logger log = LogManager.getLogger(); 15 | private static final int DEFAULT_PAGE_SIZE = 20; 16 | 17 | private final List posts; 18 | private final int unreadCount; 19 | private final Set bookmarked; 20 | private Name pageName; 21 | 22 | public PostsHtmlView( 23 | Name thePageName, 24 | List thePosts, Set theBookmarked, int theUnreadCount) { 25 | pageName = thePageName; 26 | posts = thePosts == null ? Collections.emptyList() : thePosts; 27 | bookmarked = theBookmarked == null ? Collections.emptySet() : theBookmarked; 28 | unreadCount = theUnreadCount; 29 | } 30 | 31 | @Override public String getMimeType() { 32 | return "text/html"; 33 | } 34 | 35 | @Override public String getContent() { 36 | StringBuilder sb = new StringBuilder() 37 | .append(header()) 38 | .append(String.format( 39 | "
    ", 40 | DEFAULT_PAGE_SIZE)); 41 | for (Post p : posts) { 42 | try { 43 | sb.append(post(p)); 44 | } catch (RuntimeException e) { 45 | log.warn(String.format("Could not render post %s", safePostId(p)), e); 46 | sb.append(errorPost(p)); 47 | } 48 | } 49 | return sb.append("
") 50 | .append(Pages.editFilterTemplate()) 51 | .append(Pages.footerTemplate(markReadLink())) 52 | .toString(); 53 | } 54 | 55 | private static String safePostId(Post p) { 56 | if (p == null) { 57 | return ""; 58 | } 59 | return String.valueOf(p.getId()); 60 | } 61 | 62 | private String post(Post p) { 63 | PostDisplay display = new PostDisplay(p); 64 | String bgColor = display.getFeedColor(); 65 | String textColor = display.getReadableTextColor(); 66 | String titleStyle = String.format(" style='background:%s;color:%s;'", 67 | bgColor, textColor); 68 | return String.format( 69 | "
  • " 70 | + "" 81 | + "

    %s

    " 82 | + "
    %s
  • ", 83 | isBookmarked(p) ? "bookmarked" : "", 84 | p.getId(), 85 | p.isRead(), 86 | display.getDate(), display.getFeedTitle(), display.getMobileFeedTitle(), 87 | display.getTitle(), titleStyle, p.getLocation(), 88 | display.getShortenedTitle(), 89 | display.getShortenedSnippet()); 90 | } 91 | 92 | private String addFilterLink(Post p) { 93 | return ""; 94 | } 95 | 96 | private String bookmarkLink(Post p) { 97 | return String.format( 98 | "" 99 | + "+Bookmark+Mark", 100 | bookmarkUrl(p.getId())); 101 | } 102 | private String removeBookmarkLink(Post p) { 103 | return String.format( 104 | "" 105 | + "-Bookmark-Mark", 106 | removeBookmarkUrl(p.getId())); 107 | } 108 | 109 | private boolean isBookmarked(Post p) { 110 | return bookmarked == null || bookmarked.contains(p.getId()); 111 | } 112 | 113 | private String markReadLink() { 114 | if (pageName == Name.bookmarks) { 115 | return ""; 116 | } 117 | int unread = unreadCount(); 118 | if (unread == 0) { 119 | return ""; 120 | } else { 121 | List postsToMark = unreadPostsToShow(); 122 | return String.format( 123 | "" 124 | + "Mark %d of %d read", 125 | unreadCount, markReadUrl(postsToMark), postsToMark.size(), unreadCount); 126 | } 127 | } 128 | 129 | private int unreadCount() { 130 | return (int) posts.stream().filter(p -> !p.isRead()).count(); 131 | } 132 | 133 | private String markReadUrl(List postsToMark) { 134 | StringBuilder ids = new StringBuilder(postsToMark.size() * 10); 135 | for (Post p : postsToMark) { 136 | if (ids.length() > 0) { 137 | ids.append(','); 138 | } 139 | ids.append(p.getId()); 140 | } 141 | return "/markread?posts=" + ids; 142 | } 143 | 144 | private String bookmarkUrl(long id) { 145 | return "/add-bookmark?post=" + id; 146 | } 147 | 148 | private String removeBookmarkUrl(long id) { 149 | return "/remove-bookmark?post=" + id; 150 | } 151 | 152 | private String errorPost(Post p) { 153 | boolean bookmarkedPost = p != null && isBookmarked(p); 154 | String classes = String.format( 155 | "list-group-item post error%s", 156 | bookmarkedPost ? " bookmarked" : ""); 157 | StringBuilder builder = new StringBuilder(String.format( 158 | "
  • Could not render post %s", 159 | classes, safePostId(p))); 160 | if (bookmarkedPost) { 161 | builder.append(" ") 162 | .append(removeBookmarkLink(p)) 163 | .append(""); 164 | } 165 | return builder.append("
  • ").toString(); 166 | } 167 | 168 | private String header() { 169 | if (unreadCount > 0) { 170 | return Pages.headerTemplate(pageName, String.format( 171 | "%d of %d posts - Fiets", displayedPostsCount(), unreadCount), 172 | unreadCount, bookmarked.size()); 173 | } else { 174 | return Pages.headerTemplate(pageName, 175 | String.format("%d posts - Fiets", displayedPostsCount()), 176 | unreadCount, bookmarked.size()); 177 | } 178 | } 179 | 180 | private int displayedPostsCount() { 181 | return Math.min(posts.size(), DEFAULT_PAGE_SIZE); 182 | } 183 | 184 | private List unreadPostsToShow() { 185 | return posts.stream() 186 | .filter(p -> !p.isRead()) 187 | .limit(DEFAULT_PAGE_SIZE) 188 | .toList(); 189 | } 190 | 191 | } 192 | -------------------------------------------------------------------------------- /src/main/resources/static/scripts.js: -------------------------------------------------------------------------------- 1 | function getEditFilterModal() { 2 | if (typeof bootstrap === 'undefined') { 3 | return null; 4 | } 5 | var modalElement = document.getElementById('edit-filter-modal'); 6 | if (!modalElement) { 7 | return null; 8 | } 9 | return bootstrap.Modal.getOrCreateInstance(modalElement); 10 | } 11 | 12 | function updateTitleCounts(visibleCount, unread) { 13 | var current = document.title || ''; 14 | var suffixIndex = current.indexOf(' - '); 15 | var suffix = suffixIndex >= 0 ? current.substring(suffixIndex) : ''; 16 | var prefix; 17 | 18 | if (unread > 0) { 19 | prefix = visibleCount + ' of ' + unread + ' posts'; 20 | } else { 21 | prefix = visibleCount + ' posts'; 22 | } 23 | 24 | document.title = suffix ? prefix + suffix : prefix; 25 | } 26 | 27 | function updateUnreadCount(unread, visibleCount) { 28 | var $unread = $('.unread-count'); 29 | if ($unread.length > 0) { 30 | $unread.text(unread); 31 | } 32 | 33 | var visible = typeof visibleCount === 'number' && !isNaN(visibleCount) 34 | ? visibleCount 35 | : $('.posts-list .post').length; 36 | 37 | updateTitleCounts(visible, unread); 38 | } 39 | 40 | function initPostPager() { 41 | var $postList = $('.posts-list'); 42 | var $markRead = $('.mark-read-action'); 43 | if ($postList.length === 0 || $markRead.length === 0) { 44 | return; 45 | } 46 | 47 | var pageSize = parseInt($postList.data('page-size'), 10) || 20; 48 | var totalUnread = parseInt($markRead.data('total-unread'), 10); 49 | if (isNaN(totalUnread)) { 50 | totalUnread = $postList.find('.post').length; 51 | } 52 | 53 | var postsCache = $postList.find('.post').detach().toArray(); 54 | 55 | function visiblePosts() { 56 | return postsCache.slice(0, pageSize); 57 | } 58 | 59 | function updateMarkReadLink() { 60 | var postsToMark = visiblePosts(); 61 | if (postsToMark.length === 0) { 62 | $postList.append('
  • No more posts.
  • '); 63 | $markRead.addClass('disabled').attr('aria-disabled', 'true'); 64 | $markRead.find('small').text('No more posts to mark'); 65 | return; 66 | } 67 | 68 | var ids = postsToMark.map(function (post) { 69 | return $(post).data('post-id'); 70 | }); 71 | 72 | $markRead 73 | .removeClass('disabled') 74 | .removeAttr('aria-disabled') 75 | .attr('href', '/markread?posts=' + ids.join(',')) 76 | .find('small') 77 | .text('Mark ' + postsToMark.length + ' of ' + totalUnread + ' read'); 78 | } 79 | 80 | function renderVisiblePosts() { 81 | $postList.empty(); 82 | visiblePosts().forEach(function (post) { 83 | $postList.append(post); 84 | }); 85 | updateMarkReadLink(); 86 | updateUnreadCount(totalUnread, visiblePosts().length); 87 | } 88 | 89 | renderVisiblePosts(); 90 | 91 | $markRead.on('click', function (evt) { 92 | var postsToMark = visiblePosts(); 93 | if (postsToMark.length === 0) { 94 | return; 95 | } 96 | evt.preventDefault(); 97 | 98 | var ids = postsToMark.map(function (post) { 99 | return $(post).data('post-id'); 100 | }); 101 | 102 | $.ajax({ 103 | url: '/markread?posts=' + ids.join(','), 104 | cache: false 105 | }) 106 | .fail(function(jqXHR, textStatus, errorThrown) { 107 | console.log(textStatus + " - " + errorThrown); 108 | alert(textStatus); 109 | }); 110 | 111 | postsCache = postsCache.slice(postsToMark.length); 112 | totalUnread = Math.max(totalUnread - postsToMark.length, 0); 113 | renderVisiblePosts(); 114 | window.scrollTo(0, 0); 115 | }); 116 | } 117 | 118 | setInterval(function () { 119 | $.ajax({ 120 | dataType: "json", 121 | url: "/counts", 122 | cache: false 123 | }) 124 | .done(function (data, textStatus, jqXHR) { 125 | updateUnreadCount(data.unread_count); 126 | }) 127 | .fail(function(jqXHR, textStatus, errorThrown) { 128 | console.log(textStatus + " - " + errorThrown); 129 | alert(textStatus); 130 | }); 131 | }, 30000); 132 | 133 | $('body') 134 | .on('click', '.post-actions .add-bookmark,.post-actions .remove-bookmark', function (evt) { 135 | evt.preventDefault(); 136 | var $link = $(this); 137 | var target = $link.attr('href'); 138 | var $post = $link.closest('.post'); 139 | $.ajax({ 140 | url: target, 141 | cache: false 142 | }) 143 | .done(function (data, textStatus, jqXHR) { 144 | var $bookmarkCount = $('.bookmark-count'); 145 | var count = parseInt($bookmarkCount.text()); 146 | if ($link.hasClass('add-bookmark')) { 147 | $post.addClass('bookmarked'); 148 | $bookmarkCount.text(count+1); 149 | } else if ($link.hasClass('remove-bookmark')) { 150 | $post.removeClass('bookmarked'); 151 | $bookmarkCount.text(count-1); 152 | } 153 | }) 154 | .fail(function(jqXHR, textStatus, errorThrown) { 155 | console.log(textStatus + " - " + errorThrown); 156 | alert(textStatus); 157 | }); 158 | }) 159 | .on('click', '.post-actions .add-filter', function (evt) { 160 | var link = $(this).closest('.post').find("h3 a"); 161 | var url = link.attr('href'); 162 | var title = link.closest('h3').attr('title'); 163 | $('#filter-url').val(url); 164 | $('#filter-title').val(title); 165 | $('#add-filter').show(); 166 | $('#edit-filter').hide(); 167 | var modal = getEditFilterModal(); 168 | if (modal) { 169 | modal.show(); 170 | } 171 | }) 172 | .on('click', '.filter-actions .edit-filter', function (evt) { 173 | var row = $(this).closest('ul'); 174 | var id = row.data('id'); 175 | var url = row.find('.url').val(); 176 | var urlMatch = row.find('.url-match').val(); 177 | var title = row.find('.title').val(); 178 | var titleMatch = row.find('.title-match').val(); 179 | $('#filter-id').val(id); 180 | $('#filter-url').val(url); 181 | $('#filter-url-match').val(urlMatch); 182 | $('#filter-title').val(title); 183 | $('#filter-title-match').val(titleMatch); 184 | $('#edit-filter').show(); 185 | $('#add-filter').hide(); 186 | var modal = getEditFilterModal(); 187 | if (modal) { 188 | modal.show(); 189 | } 190 | }) 191 | ; 192 | 193 | $('#add-filter').click(function () { 194 | var form = $('#edit-filter-modal form'); 195 | $.ajax({ 196 | type: 'POST', 197 | url: '/add-filter', 198 | data: form.serialize() 199 | }) 200 | .done(function (data, textStatus, jqXHR) { 201 | var modal = getEditFilterModal(); 202 | if (modal) { 203 | modal.hide(); 204 | } 205 | }) 206 | .fail(function(jqXHR, textStatus, errorThrown) { 207 | console.log(textStatus + " - " + errorThrown); 208 | alert(textStatus); 209 | }); 210 | }); 211 | $('#edit-filter').click(function () { 212 | var form = $('#edit-filter-modal form'); 213 | $.ajax({ 214 | type: 'POST', 215 | url: '/edit-filter', 216 | data: form.serialize() 217 | }) 218 | .done(function (data, textStatus, jqXHR) { 219 | var modal = getEditFilterModal(); 220 | if (modal) { 221 | modal.hide(); 222 | } 223 | }) 224 | .fail(function(jqXHR, textStatus, errorThrown) { 225 | console.log(textStatus + " - " + errorThrown); 226 | alert(textStatus); 227 | }); 228 | }); 229 | $('.bookmarklet').each(function () { 230 | var $a = $(this); 231 | var href = $a.attr("href"); 232 | var url = window.location.href; 233 | var pos = url.indexOf('://') + 3; 234 | pos = url.indexOf('/', pos); 235 | href = href.replace("*HOST*", url.substring(0, pos)); 236 | $a.attr("href", href); 237 | }); 238 | 239 | $(function () { 240 | initPostPager(); 241 | }); 242 | -------------------------------------------------------------------------------- /src/main/java/fiets/FeedService.java: -------------------------------------------------------------------------------- 1 | package fiets; 2 | 3 | import java.sql.Date; 4 | import java.sql.SQLException; 5 | import java.util.ArrayList; 6 | import java.util.HashSet; 7 | import java.util.List; 8 | import java.util.Set; 9 | import java.util.stream.Collectors; 10 | 11 | import fiets.model.*; 12 | import org.apache.logging.log4j.LogManager; 13 | import org.apache.logging.log4j.Logger; 14 | import org.xml.sax.SAXParseException; 15 | 16 | import fiets.db.Database; 17 | import fiets.db.FeedDao; 18 | import fiets.db.FilterDao; 19 | import fiets.db.PostDao; 20 | import fiets.processors.Process; 21 | import jodd.http.HttpException; 22 | 23 | public class FeedService { 24 | private static final Logger log = LogManager.getLogger(); 25 | private final FeedDao fed; 26 | private final FilterDao fid; 27 | private final PostDao pd; 28 | private final FeedColorService colorService; 29 | 30 | public FeedService(Database theDb) 31 | throws SQLException { 32 | fed = new FeedDao(theDb); 33 | fid = new FilterDao(theDb, fed); 34 | pd = new PostDao(theDb); 35 | colorService = new FeedColorService(fed, pd); 36 | } 37 | 38 | public List addFeeds(List urls) 39 | throws Exception { 40 | List feeds = new ArrayList<>(); 41 | for (String url : urls) { 42 | log.info("Analysing feed {}.", url); 43 | try { 44 | Feed feed = new Feed(url, null, null); 45 | String title = Process.parseTitle(feed); 46 | feed = new Feed(url, title, null); 47 | feed = fed.saveFeed(feed); 48 | feeds.add(feed); 49 | } catch (IllegalArgumentException e) { 50 | log.error(e, e); 51 | } catch (SAXParseException e) { 52 | log.error("Parse error in feed {}, ignoring this.", url); 53 | } catch (HttpException e) { 54 | log.error("Could not connect {}, ignoring this.", url); 55 | } 56 | } 57 | return feeds; 58 | } 59 | 60 | public void updateAllPosts() { 61 | List feeds; 62 | try { 63 | feeds = fed.getAllFeeds(); 64 | updateFeedPosts(feeds); 65 | } catch (SQLException e) { 66 | throw new RuntimeException(e); 67 | } 68 | } 69 | 70 | public void updateFeedPosts(List feeds) throws SQLException { 71 | List allFilters = fid.getAllFilters(); 72 | Filterer ff = new Filterer(allFilters); 73 | for (Feed feed : feeds) { 74 | try { 75 | colorService.ensureColor(feed); 76 | List posts = Process.parsePosts(feed); 77 | posts = posts.stream() 78 | .map(p -> { 79 | if (ff.isAllowed(p)) { 80 | return p; 81 | } else { 82 | return new Post(true, p); 83 | } 84 | }).collect(Collectors.toList()); 85 | pd.savePosts(posts, feed); 86 | fed.touchFeed(feed, "OK"); 87 | } catch (Exception e) { 88 | fed.touchFeed(feed, e.getMessage()); 89 | log.error("Could not update posts for {}.", feed.getLocation(), e); 90 | } 91 | } 92 | fid.updateMatchCounts(allFilters); 93 | } 94 | 95 | public Set getBookmarks() throws SQLException { 96 | return pd.getBookmarks(); 97 | } 98 | 99 | public int getUnreadCount() throws SQLException { 100 | return pd.getUnreadCount(); 101 | } 102 | 103 | public List getUnreadPosts(int num) throws SQLException { 104 | List posts = pd.getUnreadPosts(num); 105 | ensureFeedColors(posts); 106 | return posts; 107 | } 108 | 109 | public List getReadPosts(int num) throws SQLException { 110 | List posts = pd.getReadPosts(num); 111 | ensureFeedColors(posts); 112 | return posts; 113 | } 114 | 115 | public void markPostsRead(List postIds) throws SQLException { 116 | pd.markPostsRead(postIds); 117 | } 118 | 119 | public List getBookmarkedPosts() throws SQLException { 120 | List posts = pd.getBookmarkedPosts(); 121 | ensureFeedColors(posts); 122 | return posts; 123 | } 124 | 125 | public void bookmarkPost(long postId) throws SQLException { 126 | pd.bookmarkPost(postId); 127 | } 128 | 129 | public void removeBookmarkPost(long postId) throws SQLException { 130 | pd.removeBookmarkPost(postId); 131 | } 132 | 133 | public List getAllFeedInfos() throws SQLException { 134 | return fed.getAllFeedInfos(); 135 | } 136 | 137 | public int getBookmarksCount() throws SQLException { 138 | return pd.getBookmarksCount(); 139 | } 140 | 141 | public Feed getFeed(long feedId) throws SQLException { 142 | return fed.getFeed(feedId).get(); 143 | } 144 | 145 | public void deleteFeed(long feedId) throws SQLException { 146 | fed.deleteFeed(feedId); 147 | pd.deletePostsOfFeed(feedId); 148 | } 149 | 150 | public int getFullCount() throws SQLException { 151 | return pd.getFullCount(); 152 | } 153 | 154 | public int getOutdatedCount() { 155 | try { 156 | return pd.getOutdatedCount(); 157 | } catch (SQLException e) { 158 | throw new RuntimeException(e); 159 | } 160 | } 161 | 162 | public void dropOutdated() { 163 | try { 164 | pd.deleteOutdated(); 165 | } catch (SQLException e) { 166 | throw new RuntimeException(e); 167 | } 168 | } 169 | 170 | public List getAllFeeds() throws SQLException { 171 | return fed.getAllFeeds(); 172 | } 173 | 174 | public List getAllFilters() throws SQLException { 175 | return fid.getAllFilters(); 176 | } 177 | 178 | public long lastFeedUpdate() throws SQLException { 179 | return fed.lastFeedUpdate(); 180 | } 181 | 182 | public List postsAfter(long sinceId) throws SQLException { 183 | List posts = pd.postsAfter(sinceId); 184 | ensureFeedColors(posts); 185 | return posts; 186 | } 187 | 188 | public List postsBefore(long maxId) throws SQLException { 189 | List posts = pd.postsBefore(maxId); 190 | ensureFeedColors(posts); 191 | return posts; 192 | } 193 | 194 | public List posts(List withIds) throws SQLException { 195 | List posts = pd.posts(withIds); 196 | ensureFeedColors(posts); 197 | return posts; 198 | } 199 | 200 | public void markPostRead(long id) throws SQLException { 201 | pd.markPostRead(id); 202 | } 203 | 204 | public void markPostUnread(long id) throws SQLException { 205 | pd.markPostUnread(id); 206 | } 207 | 208 | private void ensureFeedColors(List posts) { 209 | Set seen = new HashSet<>(); 210 | posts.stream() 211 | .map(Post::getFeed) 212 | .filter(f -> f != null) 213 | .filter(f -> seen.add(f.getId())) 214 | .forEach(colorService::ensureColor); 215 | } 216 | 217 | public void markAllRead(Date before) throws SQLException { 218 | pd.markAllRead(before); 219 | } 220 | 221 | public void addFilter(String url, FilterMatch urlMatch, String title, FilterMatch titleMatch) throws SQLException { 222 | Filter filter = new Filter(0L, url, urlMatch, title, titleMatch, 0L); 223 | fid.saveFilter(filter); 224 | } 225 | 226 | public void updateFilter(long id, String url, FilterMatch urlMatch, String title, FilterMatch titleMatch) throws SQLException { 227 | Filter filter = new Filter(id, url, urlMatch, title, titleMatch, 0L); 228 | fid.updateFilterKeepMatchCount(filter); 229 | } 230 | 231 | public void deleteFilter(long filterId) throws SQLException { 232 | fid.deleteFilter(filterId); 233 | } 234 | 235 | public void resetFeedColors() { 236 | try { 237 | fed.clearAllFeedColors(); 238 | } catch (SQLException e) { 239 | throw new RuntimeException(e); 240 | } 241 | } 242 | 243 | } 244 | -------------------------------------------------------------------------------- /src/main/java/fiets/FeverApi.java: -------------------------------------------------------------------------------- 1 | package fiets; 2 | 3 | import java.io.FileNotFoundException; 4 | import java.io.IOException; 5 | import java.math.BigInteger; 6 | import java.nio.charset.StandardCharsets; 7 | import java.security.MessageDigest; 8 | import java.security.NoSuchAlgorithmException; 9 | import java.sql.Date; 10 | import java.sql.SQLException; 11 | import java.util.List; 12 | import java.util.Map; 13 | import java.util.Set; 14 | 15 | import org.apache.logging.log4j.LogManager; 16 | import org.apache.logging.log4j.Logger; 17 | 18 | import fi.iki.elonen.NanoHTTPD.ResponseException; 19 | import fiets.model.Feed; 20 | import fiets.model.HasId; 21 | import fiets.model.Post; 22 | import fiets.views.JsonView; 23 | import fiets.views.View; 24 | import jodd.json.JsonArray; 25 | import jodd.json.JsonObject; 26 | 27 | public class FeverApi { 28 | 29 | private static final Logger log = LogManager.getLogger(); 30 | private String key; 31 | 32 | public FeverApi() { 33 | String email = System.getProperty("feverapi.email"); 34 | String pwd = System.getProperty("feverapi.password"); 35 | if (email == null || pwd == null) { 36 | throw new IllegalArgumentException( 37 | "Both system properties feverapi.email and feverapi.password " 38 | + "have to be set to enable Fever API."); 39 | } 40 | key = md5(email + ':' + pwd); 41 | } 42 | 43 | public View serve(SessionDecorator sd, FeedService fs) 44 | throws IOException, ResponseException, SQLException { 45 | Map> getParams = sd.getParameters(); 46 | log.info("GET params: {}", getParams); 47 | if (getParams.containsKey("refresh")) { 48 | return new JsonView(new JsonObject()); 49 | } else if (getParams.containsKey("api")) { 50 | Map> postParams = sd.postParameters(); 51 | log.info("POST params: {}", postParams); 52 | String apiKey = sd.stringParam("api_key"); 53 | boolean auth = key.equals(apiKey); 54 | JsonObject json = new JsonObject() 55 | .put("api_version", 3) 56 | .put("auth", auth ? 1 : 0); 57 | if (auth) { 58 | json.put("last_refreshed_on_time", fs.lastFeedUpdate()); 59 | if (getParams.containsKey("feeds")) { 60 | List feeds = fs.getAllFeeds(); 61 | json 62 | .put("feeds", feedsArray(feeds)); 63 | } 64 | if (getParams.containsKey("groups")) { 65 | log.info("Unsupported 'groups' command. ({})", getParams); 66 | } 67 | if (getParams.containsKey("unread_item_ids")) { 68 | json.put("unread_item_ids", unreadIds(fs)); 69 | } 70 | if (getParams.containsKey("saved_item_ids")) { 71 | json.put("saved_item_ids", savedIds(fs)); 72 | } 73 | if (getParams.containsKey("items")) { 74 | List posts; 75 | if (getParams.containsKey("since_id")) { 76 | long sinceId = sd.longParam("since_id"); 77 | posts = fs.postsAfter(sinceId); 78 | } else if (getParams.containsKey("max_id")) { 79 | long maxId = sd.longParam("max_id"); 80 | posts = fs.postsBefore(maxId); 81 | } else if (getParams.containsKey("with_ids")) { 82 | List withIds = sd.longParams("with_ids"); 83 | posts = fs.posts(withIds); 84 | } else { 85 | String msg = String.format( 86 | "Unsupported items request: %s", getParams); 87 | log.error(msg); 88 | throw new IllegalArgumentException(msg); 89 | } 90 | Set bookmarks = fs.getBookmarks(); 91 | json 92 | .put("items", itemsArray(posts, bookmarks)) 93 | .put("total_items", fs.getFullCount()); 94 | } 95 | if (postParams.containsKey("mark")) { 96 | mark(fs, sd.stringParam("mark"), sd.stringParam("as"), 97 | sd.stringParam("id"), sd.stringParam("before")); 98 | } 99 | } 100 | return new JsonView(json); 101 | } else { 102 | throw new FileNotFoundException( 103 | String.format("API query %s not supported.", getParams)); 104 | } 105 | } 106 | 107 | private void mark(FeedService fs, String mark, String as, String idString, 108 | String beforeString) 109 | throws SQLException { 110 | long id = Long.parseLong(idString); 111 | if ("item".equals(mark)) { 112 | if ("read".equals(as)) { 113 | fs.markPostRead(id); 114 | } else if ("unread".equals(as)) { 115 | fs.markPostUnread(id); 116 | } else if ("saved".equals(as)) { 117 | fs.bookmarkPost(id); 118 | } else if ("unsaved".equals(as)) { 119 | fs.removeBookmarkPost(id); 120 | } else { 121 | log.error("Cannot mark '{}:{}' as '{}'.", mark, id, as); 122 | } 123 | } else if ("group".equals(mark) && id == 0l && "read".equals(as)) { 124 | long before = Long.parseLong(beforeString); 125 | fs.markAllRead(new Date(before*1000L)); 126 | } else { 127 | log.error("Cannot mark '{}:{}' as '{}'.", mark, id, as); 128 | } 129 | } 130 | 131 | private String savedIds(FeedService fs) throws SQLException { 132 | List posts = fs.getBookmarkedPosts(); 133 | return idsToString(posts); 134 | } 135 | 136 | private String unreadIds(FeedService fs) throws SQLException { 137 | List posts = fs.getUnreadPosts(0); 138 | return idsToString(posts); 139 | } 140 | 141 | private String idsToString(List haveIds) { 142 | StringBuilder sb = new StringBuilder(); 143 | for (HasId i : haveIds) { 144 | if (sb.length() > 0) { 145 | sb.append(','); 146 | } 147 | sb.append(i.getId()); 148 | } 149 | return sb.toString(); 150 | } 151 | 152 | private JsonArray feedsArray(List feeds) throws SQLException { 153 | JsonArray jab = new JsonArray(); 154 | for (Feed f : feeds) { 155 | jab.add(toJson(f)); 156 | } 157 | return jab; 158 | } 159 | 160 | private JsonArray itemsArray(List posts, Set bookmarks) { 161 | JsonArray jab = new JsonArray(); 162 | for (Post p : posts) { 163 | jab.add(toJson(p, bookmarks.contains(p.getId()))); 164 | } 165 | return jab; 166 | } 167 | 168 | private JsonObject toJson(Feed f) { 169 | java.util.Date lastAccess = f.getLastAccess(); 170 | return new JsonObject() 171 | .put("id", f.getId()) 172 | .put("title", f.getTitle()) 173 | .put("url", f.getLocation()) 174 | .put("site_url", f.getLocation()) 175 | .put("is_spark", 0) 176 | .put("last_updated_on_time", 177 | lastAccess == null ? 0 : unixtime(lastAccess.getTime())); 178 | } 179 | 180 | private JsonObject toJson(Post p, boolean bookmarked) { 181 | return new JsonObject() 182 | .put("id", p.getId()) 183 | .put("feed_id", p.getFeed().getId()) 184 | .put("title", p.getTitle()) 185 | .put("html", p.getSnippet()) 186 | .put("url", p.getLocation()) 187 | .put("is_read", p.isRead() ? 1 : 0) 188 | .put("is_saved", bookmarked ? 1 : 0) 189 | .put("created_on_time", unixtime(p.getDate().getTime())); 190 | } 191 | 192 | private static int unixtime(long time) { 193 | return (int) (time / 1000L); 194 | } 195 | 196 | private static String md5(String string) { 197 | try { 198 | MessageDigest md = MessageDigest.getInstance("MD5"); 199 | byte[] digest = md.digest(string.getBytes(StandardCharsets.US_ASCII)); 200 | return String.format("%032x", new BigInteger(1, digest)); 201 | } catch (NoSuchAlgorithmException e) { 202 | throw new IllegalStateException("No MD5 available.", e); 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/main/java/fiets/PathMatch.java: -------------------------------------------------------------------------------- 1 | package fiets; 2 | 3 | import java.io.FileNotFoundException; 4 | import java.io.InputStream; 5 | import java.sql.SQLException; 6 | import java.util.Collections; 7 | import java.util.List; 8 | import java.util.Map; 9 | import java.util.Set; 10 | 11 | import fiets.model.*; 12 | import fiets.views.*; 13 | import fiets.views.Pages.Name; 14 | import jodd.json.JsonObject; 15 | import org.apache.logging.log4j.LogManager; 16 | import org.apache.logging.log4j.Logger; 17 | 18 | public enum PathMatch { 19 | showUnreadPosts("") { 20 | @Override public View serve( 21 | SessionDecorator sd, FeedService fs) 22 | throws SQLException { 23 | List posts = fs.getUnreadPosts(sd.intParamOr("num", 0)); 24 | Set bookmarks = fs.getBookmarks(); 25 | int allCount = fs.getUnreadCount(); 26 | return new PostsHtmlView( 27 | Name.unread, posts, bookmarks, allCount); 28 | } 29 | }, 30 | outdatedCount("outdated-count") { 31 | @Override public View serve( 32 | SessionDecorator sd, FeedService fs) { 33 | int count = fs.getOutdatedCount(); 34 | return new JsonView(new JsonObject().put("outdated", count)); 35 | } 36 | }, 37 | showReadPosts("show-read") { 38 | @Override public View serve( 39 | SessionDecorator sd, FeedService fs) throws SQLException { 40 | List posts = fs.getReadPosts(sd.intParamOr("num", 20)); 41 | Set bookmarks = fs.getBookmarks(); 42 | int allCount = fs.getUnreadCount(); 43 | return new PostsHtmlView( 44 | Name.read, posts, bookmarks, allCount); 45 | } 46 | }, 47 | markPostRead("markread") { 48 | @Override public View serve(SessionDecorator sd, FeedService fs) 49 | throws SQLException { 50 | fs.markPostsRead(sd.longParams("posts")); 51 | return new RedirectView(PathMatch.showUnreadPosts); 52 | } 53 | }, 54 | showBookmarks("bookmarks") { 55 | @Override public View serve(SessionDecorator sd, FeedService fs) { 56 | List posts = Collections.emptyList(); 57 | Set bookmarkedIds = Collections.emptySet(); 58 | try { 59 | posts = fs.getBookmarkedPosts(); 60 | bookmarkedIds = Server.getIds(posts); 61 | } catch (SQLException e) { 62 | log.warn("Could not load bookmarked posts.", e); 63 | } 64 | int unreadCount = -1; 65 | try { 66 | unreadCount = fs.getUnreadCount(); 67 | } catch (SQLException e) { 68 | log.warn("Could not load unread count for bookmarks page.", e); 69 | } 70 | return new PostsHtmlView(Name.bookmarks, posts, bookmarkedIds, unreadCount); 71 | } 72 | }, 73 | addBookmark("add-bookmark") { 74 | @Override public View serve(SessionDecorator sd, FeedService fs) 75 | throws SQLException { 76 | fs.bookmarkPost(sd.intParam("post")); 77 | return new JsonView(Server.jsonOk()); 78 | } 79 | }, 80 | removeBookmark("remove-bookmark") { 81 | @Override public View serve(SessionDecorator sd, FeedService fs) 82 | throws SQLException { 83 | fs.removeBookmarkPost(sd.intParam("post")); 84 | return new RedirectView(PathMatch.showUnreadPosts); 85 | } 86 | }, 87 | showFeeds("feeds") { 88 | @Override public View serve(SessionDecorator sd, FeedService fs) 89 | throws SQLException { 90 | List feeds = fs.getAllFeedInfos(); 91 | return new FeedsHtmlView( 92 | sd.getHostname(), feeds, fs.getUnreadCount(), fs.getBookmarksCount()); 93 | } 94 | }, 95 | showFilters("filters") { 96 | @Override public View serve(SessionDecorator sd, FeedService fs) 97 | throws SQLException { 98 | List filters = fs.getAllFilters(); 99 | return new FiltersHtmlView( 100 | sd.getHostname(), filters, fs.getUnreadCount(), fs.getBookmarksCount() 101 | ); 102 | } 103 | }, 104 | addFeed("add-feed") { 105 | @Override public View serve(SessionDecorator sd, FeedService fs) 106 | throws Exception { 107 | List urls = sd.stringParams("url"); 108 | String callback = sd.stringParam("callback"); 109 | List added = fs.addFeeds(urls); 110 | fs.updateFeedPosts(added); 111 | if (callback == null) { 112 | return new RedirectView(PathMatch.showFeeds); 113 | } else { 114 | return new JavaScriptView( 115 | String.format("%s(%s)", callback, Server.jsonOk())); 116 | } 117 | } 118 | }, 119 | updateFeed("update-feed") { 120 | @Override public View serve( 121 | SessionDecorator sd, FeedService fs) 122 | throws SQLException { 123 | Feed feed = fs.getFeed(sd.longParam("id")); 124 | fs.updateFeedPosts( 125 | Collections.singletonList(feed)); 126 | return new RedirectView(PathMatch.showFeeds); 127 | } 128 | }, 129 | deleteFeed("delete-feed") { 130 | @Override public View serve( 131 | SessionDecorator sd, FeedService fs) 132 | throws SQLException { 133 | fs.deleteFeed(sd.longParam("id")); 134 | return new RedirectView(PathMatch.showFeeds); 135 | } 136 | }, 137 | updatePosts("update") { 138 | @Override public View serve(SessionDecorator sd, FeedService fs) { 139 | fs.updateAllPosts(); 140 | return new RedirectView(PathMatch.showUnreadPosts); 141 | } 142 | }, 143 | feverApi("fever") { 144 | @Override public View serve( 145 | SessionDecorator sd, FeedService fs) 146 | throws Exception { 147 | return new FeverApi().serve(sd, fs); 148 | } 149 | }, 150 | counts("counts") { 151 | @Override public View serve( 152 | SessionDecorator sd, FeedService fs) 153 | throws SQLException { 154 | return new JsonView( 155 | new JsonObject() 156 | .put("unread_count", fs.getUnreadCount()) 157 | .put("full_count", fs.getFullCount()) 158 | ); 159 | } 160 | }, 161 | resetColors("reset-colors") { 162 | @Override public View serve(SessionDecorator sd, FeedService fs) { 163 | fs.resetFeedColors(); 164 | return new JsonView(Server.jsonOk()); 165 | } 166 | }, 167 | staticFile("static") { 168 | @Override public View serve( 169 | SessionDecorator sd, FeedService fs) 170 | throws FileNotFoundException { 171 | return new FileView(sd); 172 | } 173 | }, 174 | addFilter("add-filter") { 175 | @Override public View serve(SessionDecorator sd, FeedService fs) 176 | throws Exception { 177 | Map> post = sd.postParameters(); 178 | String url = post.get("url").get(0); 179 | FilterMatch urlMatch = FilterMatch.valueOf(post.get("urlMatch").get(0)); 180 | String title = post.get("title").get(0); 181 | FilterMatch titleMatch = FilterMatch.valueOf(post.get("titleMatch").get(0)); 182 | fs.addFilter(url, urlMatch, title, titleMatch); 183 | return new JsonView(Server.jsonOk()); 184 | } 185 | }, 186 | editFilter("edit-filter") { 187 | @Override public View serve(SessionDecorator sd, FeedService fs) 188 | throws Exception { 189 | Map> post = sd.postParameters(); 190 | Long id = Long.parseLong(post.get("id").get(0)); 191 | String url = post.get("url").get(0); 192 | FilterMatch urlMatch = FilterMatch.valueOf(post.get("urlMatch").get(0)); 193 | String title = post.get("title").get(0); 194 | FilterMatch titleMatch = FilterMatch.valueOf(post.get("titleMatch").get(0)); 195 | fs.updateFilter(id, url, urlMatch, title, titleMatch); 196 | return new JsonView(Server.jsonOk()); 197 | } 198 | }, 199 | deleteFilter("delete-filter") { 200 | @Override public View serve( 201 | SessionDecorator sd, FeedService fs) 202 | throws SQLException { 203 | fs.deleteFilter(sd.longParam("id")); 204 | return new RedirectView(PathMatch.showFilters); 205 | } 206 | }; 207 | 208 | private static final Logger log = LogManager.getLogger(); 209 | 210 | private String base; 211 | 212 | PathMatch(String theBase) { 213 | base = theBase; 214 | } 215 | 216 | public String getUrl() { 217 | return base + '/'; 218 | } 219 | 220 | public abstract View serve( 221 | SessionDecorator sd, FeedService fs) 222 | throws Exception; 223 | 224 | public static PathMatch match(SessionDecorator sd) { 225 | String path = sd.getMainPath(); 226 | for (PathMatch pm : values()) { 227 | if (path.equals(pm.base)) { 228 | return pm; 229 | } 230 | } 231 | throw new IllegalArgumentException("Path not found: " + sd.getMainPath()); 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /scripts/create_icons.py: -------------------------------------------------------------------------------- 1 | import math 2 | import os 3 | import struct 4 | import zlib 5 | 6 | OUTPUT_DIR = "src/main/resources/static/icons" 7 | os.makedirs(OUTPUT_DIR, exist_ok=True) 8 | 9 | WIDTH = HEIGHT = 512 10 | 11 | 12 | def create_canvas(width, height, color=(0, 0, 0, 0)): 13 | r, g, b, a = color 14 | return [[[r, g, b, a] for _ in range(width)] for _ in range(height)] 15 | 16 | 17 | def blend_pixel(pixels, x, y, color): 18 | height = len(pixels) 19 | width = len(pixels[0]) 20 | if not (0 <= x < width and 0 <= y < height): 21 | return 22 | dst = pixels[y][x] 23 | alpha = color[3] / 255.0 24 | inv = 1.0 - alpha 25 | for i in range(3): 26 | dst[i] = int(color[i] * alpha + dst[i] * inv) 27 | dst[3] = int(255 - (1.0 - alpha) * (255 - dst[3])) 28 | 29 | 30 | def draw_disc(pixels, cx, cy, radius, color): 31 | min_x = int(max(0, math.floor(cx - radius - 1))) 32 | max_x = int(min(len(pixels[0]) - 1, math.ceil(cx + radius + 1))) 33 | min_y = int(max(0, math.floor(cy - radius - 1))) 34 | max_y = int(min(len(pixels) - 1, math.ceil(cy + radius + 1))) 35 | radius_sq = radius * radius 36 | for y in range(min_y, max_y + 1): 37 | for x in range(min_x, max_x + 1): 38 | dx = x + 0.5 - cx 39 | dy = y + 0.5 - cy 40 | if dx * dx + dy * dy <= radius_sq: 41 | blend_pixel(pixels, x, y, color) 42 | 43 | 44 | def draw_ring(pixels, cx, cy, radius, thickness, color): 45 | outer = radius + thickness / 2 46 | inner = max(0, radius - thickness / 2) 47 | outer_sq = outer * outer 48 | inner_sq = inner * inner 49 | min_x = int(max(0, math.floor(cx - outer - 1))) 50 | max_x = int(min(len(pixels[0]) - 1, math.ceil(cx + outer + 1))) 51 | min_y = int(max(0, math.floor(cy - outer - 1))) 52 | max_y = int(min(len(pixels) - 1, math.ceil(cy + outer + 1))) 53 | for y in range(min_y, max_y + 1): 54 | for x in range(min_x, max_x + 1): 55 | dx = x + 0.5 - cx 56 | dy = y + 0.5 - cy 57 | dist_sq = dx * dx + dy * dy 58 | if inner_sq <= dist_sq <= outer_sq: 59 | blend_pixel(pixels, x, y, color) 60 | 61 | 62 | def draw_thick_line(pixels, start, end, width, color): 63 | steps = int(max(abs(end[0] - start[0]), abs(end[1] - start[1])) * 2) + 1 64 | for i in range(steps + 1): 65 | t = i / steps if steps else 0 66 | x = start[0] * (1 - t) + end[0] * t 67 | y = start[1] * (1 - t) + end[1] * t 68 | draw_disc(pixels, x, y, width / 2, color) 69 | 70 | 71 | def draw_polyline(pixels, points, width, color): 72 | for i in range(len(points) - 1): 73 | draw_thick_line(pixels, points[i], points[i + 1], width, color) 74 | 75 | 76 | def apply_glow(pixels, intensity=0.18): 77 | height = len(pixels) 78 | width = len(pixels[0]) 79 | cx = width / 2 80 | cy = height / 2 81 | radius = min(width, height) * 0.45 82 | for y in range(height): 83 | for x in range(width): 84 | dx = x - cx 85 | dy = y - cy 86 | dist = math.sqrt(dx * dx + dy * dy) 87 | glow = max(0.0, 1.0 - dist / radius) 88 | if glow <= 0: 89 | continue 90 | blend = glow * intensity 91 | pixel = pixels[y][x] 92 | for i in range(3): 93 | pixel[i] = int(pixel[i] * (1 - blend) + 255 * blend) 94 | 95 | 96 | def lerp_color(a, b, t): 97 | return [int(a[i] + (b[i] - a[i]) * t) for i in range(4)] 98 | 99 | 100 | def resize_image(pixels, target_size): 101 | src_h = len(pixels) 102 | src_w = len(pixels[0]) 103 | dst = [[[0, 0, 0, 0] for _ in range(target_size)] for _ in range(target_size)] 104 | scale_x = src_w / target_size 105 | scale_y = src_h / target_size 106 | for y in range(target_size): 107 | src_y = (y + 0.5) * scale_y - 0.5 108 | y0 = max(0, min(src_h - 1, int(math.floor(src_y)))) 109 | y1 = max(0, min(src_h - 1, y0 + 1)) 110 | ty = src_y - y0 111 | for x in range(target_size): 112 | src_x = (x + 0.5) * scale_x - 0.5 113 | x0 = max(0, min(src_w - 1, int(math.floor(src_x)))) 114 | x1 = max(0, min(src_w - 1, x0 + 1)) 115 | tx = src_x - x0 116 | c00 = pixels[y0][x0] 117 | c10 = pixels[y0][x1] 118 | c01 = pixels[y1][x0] 119 | c11 = pixels[y1][x1] 120 | top = [c00[i] * (1 - tx) + c10[i] * tx for i in range(4)] 121 | bottom = [c01[i] * (1 - tx) + c11[i] * tx for i in range(4)] 122 | value = [int(top[i] * (1 - ty) + bottom[i] * ty) for i in range(4)] 123 | dst[y][x] = value 124 | return dst 125 | 126 | 127 | def write_png(path, pixels): 128 | height = len(pixels) 129 | width = len(pixels[0]) 130 | raw = bytearray() 131 | for row in pixels: 132 | raw.append(0) 133 | for r, g, b, a in row: 134 | raw.extend([r, g, b, a]) 135 | compressed = zlib.compress(bytes(raw), 9) 136 | 137 | def chunk(tag, data): 138 | return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) 139 | 140 | header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) 141 | png_data = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", compressed) + chunk(b"IEND", b"") 142 | with open(path, "wb") as handle: 143 | handle.write(png_data) 144 | 145 | 146 | def main(): 147 | pixels = create_canvas(WIDTH, HEIGHT) 148 | top_color = [11, 52, 100, 255] 149 | bottom_color = [15, 145, 182, 255] 150 | for y in range(HEIGHT): 151 | t = y / (HEIGHT - 1) 152 | row_color = lerp_color(top_color, bottom_color, t) 153 | for x in range(WIDTH): 154 | pixels[y][x] = row_color.copy() 155 | apply_glow(pixels) 156 | 157 | accent = (255, 255, 255, 240) 158 | secondary = (220, 247, 255, 180) 159 | wheel_radius = WIDTH * 0.18 160 | wheel_thickness = WIDTH * 0.035 161 | centers = [(WIDTH * 0.3, HEIGHT * 0.68), (WIDTH * 0.7, HEIGHT * 0.68)] 162 | for cx, cy in centers: 163 | draw_ring(pixels, cx, cy, wheel_radius, wheel_thickness, accent) 164 | draw_ring(pixels, cx, cy, wheel_radius * 0.45, wheel_thickness * 0.5, secondary) 165 | 166 | frame = [ 167 | (WIDTH * 0.28, HEIGHT * 0.48), 168 | (WIDTH * 0.44, HEIGHT * 0.68), 169 | (WIDTH * 0.64, HEIGHT * 0.46), 170 | (WIDTH * 0.72, HEIGHT * 0.68), 171 | ] 172 | draw_polyline(pixels, frame[:3], wheel_thickness * 0.9, accent) 173 | draw_polyline(pixels, frame[1:], wheel_thickness * 0.9, accent) 174 | 175 | draw_thick_line(pixels, frame[1], (WIDTH * 0.46, HEIGHT * 0.36), wheel_thickness * 0.9, accent) 176 | draw_thick_line(pixels, (WIDTH * 0.42, HEIGHT * 0.32), (WIDTH * 0.56, HEIGHT * 0.32), wheel_thickness * 0.6, accent) 177 | draw_thick_line(pixels, frame[2], (WIDTH * 0.74, HEIGHT * 0.28), wheel_thickness * 0.8, accent) 178 | draw_thick_line(pixels, (WIDTH * 0.7, HEIGHT * 0.24), (WIDTH * 0.84, HEIGHT * 0.24), wheel_thickness * 0.55, accent) 179 | 180 | sparkle_center = (WIDTH * 0.2, HEIGHT * 0.2) 181 | draw_ring(pixels, sparkle_center[0], sparkle_center[1], WIDTH * 0.06, WIDTH * 0.02, secondary) 182 | draw_thick_line(pixels, (sparkle_center[0], sparkle_center[1] - WIDTH * 0.05), (sparkle_center[0], sparkle_center[1] + WIDTH * 0.05), WIDTH * 0.015, secondary) 183 | draw_thick_line(pixels, (sparkle_center[0] - WIDTH * 0.05, sparkle_center[1]), (sparkle_center[0] + WIDTH * 0.05, sparkle_center[1]), WIDTH * 0.015, secondary) 184 | 185 | base_path = os.path.join(OUTPUT_DIR, "icon-512.png") 186 | write_png(base_path, pixels) 187 | 188 | for size in [192, 180, 128, 64, 32]: 189 | resized = resize_image(pixels, size) 190 | write_png(os.path.join(OUTPUT_DIR, f"icon-{size}.png"), resized) 191 | 192 | icon32 = resize_image(pixels, 32) 193 | icon16 = resize_image(pixels, 16) 194 | 195 | def png_bytes(pix, name): 196 | path = os.path.join(OUTPUT_DIR, name) 197 | write_png(path, pix) 198 | with open(path, "rb") as handle: 199 | data = handle.read() 200 | os.remove(path) 201 | return data 202 | 203 | data32 = png_bytes(icon32, "_tmp32.png") 204 | data16 = png_bytes(icon16, "_tmp16.png") 205 | 206 | def ico_dir_entry(size, offset, data): 207 | return struct.pack("= 400) { 94 | return null; 95 | } 96 | byte[] bytes = rsp.bodyBytes(); 97 | if (bytes == null || bytes.length == 0) { 98 | return null; 99 | } 100 | return averageColor(bytes); 101 | } catch (RuntimeException e) { 102 | log.debug("Could not read favicon for {}: {}", baseUri, e.toString()); 103 | return null; 104 | } 105 | } 106 | 107 | private String averageColor(byte[] bytes) { 108 | try { 109 | BufferedImage img = ImageIO.read(new ByteArrayInputStream(bytes)); 110 | if (img == null) { 111 | img = readPngFromIco(bytes); 112 | } 113 | if (img == null) { 114 | return null; 115 | } 116 | return dominantColor(img); 117 | } catch (Exception e) { 118 | log.debug("Could not determine favicon color: {}", e.toString()); 119 | return null; 120 | } 121 | } 122 | 123 | private BufferedImage readPngFromIco(byte[] bytes) { 124 | if (bytes.length < 6) { 125 | return null; 126 | } 127 | int count = Byte.toUnsignedInt(bytes[4]) | (Byte.toUnsignedInt(bytes[5]) << 8); 128 | int headerSize = 6 + (count * 16); 129 | if (headerSize > bytes.length) { 130 | return null; 131 | } 132 | BufferedImage best = null; 133 | for (int i = 0; i < count; i++) { 134 | int entryOffset = 6 + (i * 16); 135 | int imageSize = readInt(bytes, entryOffset + 8); 136 | int imageOffset = readInt(bytes, entryOffset + 12); 137 | if (imageOffset < 0 || imageSize <= 0 || imageOffset + imageSize > bytes.length) { 138 | continue; 139 | } 140 | if (!isPng(bytes, imageOffset)) { 141 | continue; 142 | } 143 | try { 144 | BufferedImage img = ImageIO.read(new ByteArrayInputStream(bytes, imageOffset, imageSize)); 145 | if (img != null && (best == null || (img.getWidth() * img.getHeight()) > (best.getWidth() * best.getHeight()))) { 146 | best = img; 147 | } 148 | } catch (Exception e) { 149 | log.debug("Could not read PNG frame from ICO: {}", e.toString()); 150 | } 151 | } 152 | return best; 153 | } 154 | 155 | private static int readInt(byte[] bytes, int offset) { 156 | return Byte.toUnsignedInt(bytes[offset]) 157 | | (Byte.toUnsignedInt(bytes[offset + 1]) << 8) 158 | | (Byte.toUnsignedInt(bytes[offset + 2]) << 16) 159 | | (Byte.toUnsignedInt(bytes[offset + 3]) << 24); 160 | } 161 | 162 | private static boolean isPng(byte[] bytes, int offset) { 163 | if (offset + 8 > bytes.length) { 164 | return false; 165 | } 166 | return bytes[offset] == (byte) 0x89 167 | && bytes[offset + 1] == 0x50 168 | && bytes[offset + 2] == 0x4e 169 | && bytes[offset + 3] == 0x47 170 | && bytes[offset + 4] == 0x0d 171 | && bytes[offset + 5] == 0x0a 172 | && bytes[offset + 6] == 0x1a 173 | && bytes[offset + 7] == 0x0a; 174 | } 175 | 176 | private String dominantColor(BufferedImage img) { 177 | Map histogram = new HashMap<>(); 178 | int width = img.getWidth(); 179 | int height = img.getHeight(); 180 | for (int x = 0; x < width; x++) { 181 | for (int y = 0; y < height; y++) { 182 | int argb = img.getRGB(x, y); 183 | int alpha = (argb >> 24) & 0xff; 184 | if (alpha == 0) { 185 | continue; 186 | } 187 | histogram.merge(argb & 0x00ffffff, 1, Integer::sum); 188 | } 189 | } 190 | if (histogram.isEmpty()) { 191 | return null; 192 | } 193 | int dominant = histogram.entrySet().stream() 194 | .max(Map.Entry.comparingByValue()) 195 | .map(Map.Entry::getKey) 196 | .orElse(0); 197 | Color color = new Color(dominant); 198 | return FeedMetadata.normalizeColor(String.format("#%02x%02x%02x", 199 | color.getRed(), color.getGreen(), color.getBlue())); 200 | } 201 | 202 | private String distinctColor(String proposed) { 203 | String normalized = FeedMetadata.normalizeColor(proposed); 204 | if (normalized == null) { 205 | return proposed; 206 | } 207 | List existingHues = existingHues(); 208 | if (existingHues.isEmpty()) { 209 | return normalized; 210 | } 211 | float[] hsb = Color.RGBtoHSB( 212 | Integer.parseInt(normalized.substring(1, 3), 16), 213 | Integer.parseInt(normalized.substring(3, 5), 16), 214 | Integer.parseInt(normalized.substring(5, 7), 16), 215 | null); 216 | double hue = hsb[0] * 360.0d; 217 | double minDistance = minHueDistance(hue, existingHues); 218 | if (minDistance >= 18d) { 219 | return normalized; 220 | } 221 | double bestHue = hue; 222 | double widestGap = -1d; 223 | List sorted = new ArrayList<>(existingHues); 224 | sorted.sort(Double::compareTo); 225 | for (int i = 0; i < sorted.size(); i++) { 226 | double start = sorted.get(i); 227 | double end = sorted.get((i + 1) % sorted.size()); 228 | double gap = end - start; 229 | if (gap < 0) { 230 | gap += 360d; 231 | } 232 | if (gap > widestGap) { 233 | widestGap = gap; 234 | bestHue = start + gap / 2d; 235 | } 236 | } 237 | while (bestHue >= 360d) { 238 | bestHue -= 360d; 239 | } 240 | int rgb = Color.HSBtoRGB((float) (bestHue / 360d), hsb[1], hsb[2]); 241 | Color color = new Color(rgb); 242 | return FeedMetadata.normalizeColor(String.format("#%02x%02x%02x", 243 | color.getRed(), color.getGreen(), color.getBlue())); 244 | } 245 | 246 | private List existingHues() { 247 | try { 248 | List colors = feedDao.getAllFeedColors(); 249 | List hues = new ArrayList<>(); 250 | for (String color : colors) { 251 | if (color == null) { 252 | continue; 253 | } 254 | float[] hsb = Color.RGBtoHSB( 255 | Integer.parseInt(color.substring(1, 3), 16), 256 | Integer.parseInt(color.substring(3, 5), 16), 257 | Integer.parseInt(color.substring(5, 7), 16), 258 | null); 259 | hues.add(hsb[0] * 360.0d); 260 | } 261 | return hues; 262 | } catch (SQLException e) { 263 | log.debug("Could not load stored feed colors: {}", e.toString()); 264 | return List.of(); 265 | } 266 | } 267 | 268 | private static double minHueDistance(double hue, List existingHues) { 269 | double minDistance = 360d; 270 | for (double existing : existingHues) { 271 | double direct = Math.abs(hue - existing); 272 | double wrapped = 360d - direct; 273 | minDistance = Math.min(minDistance, Math.min(direct, wrapped)); 274 | } 275 | return minDistance; 276 | } 277 | 278 | private URI firstPostBaseUri(long feedId) { 279 | try { 280 | String postLocation = postDao.getMostRecentPostLocation(feedId); 281 | if (postLocation == null) { 282 | return null; 283 | } 284 | return baseUri(postLocation); 285 | } catch (SQLException e) { 286 | log.debug("Could not load most recent post for feed {}: {}", feedId, e.toString()); 287 | return null; 288 | } 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /src/main/java/fiets/db/FeedDao.java: -------------------------------------------------------------------------------- 1 | package fiets.db; 2 | 3 | import java.sql.Connection; 4 | import java.sql.PreparedStatement; 5 | import java.sql.ResultSet; 6 | import java.sql.SQLException; 7 | import java.sql.Statement; 8 | import java.util.ArrayList; 9 | import java.util.Date; 10 | import java.util.List; 11 | import java.util.Optional; 12 | 13 | import org.apache.logging.log4j.LogManager; 14 | import org.apache.logging.log4j.Logger; 15 | 16 | import fiets.model.Feed; 17 | import fiets.model.FeedInfo; 18 | import fiets.model.FeedMetadata; 19 | 20 | /** 21 | * DAO for the feeds table. 22 | */ 23 | public class FeedDao { 24 | 25 | private static final Logger log = LogManager.getLogger(); 26 | private Database db; 27 | 28 | public FeedDao(Database theDb) throws SQLException { 29 | db = theDb; 30 | createTable(); 31 | createIndexes(); 32 | } 33 | 34 | private int createTable() throws SQLException { 35 | try (PreparedStatement ps = db.getConnection().prepareStatement( 36 | "CREATE TABLE IF NOT EXISTS feed (" 37 | + "id BIGINT PRIMARY KEY AUTO_INCREMENT," 38 | + "lastAccess DATETIME," 39 | + "lastStatus VARCHAR(1024)," 40 | + "location VARCHAR(2048)," 41 | + "title VARCHAR(1024)" 42 | + ");")) { 43 | return ps.executeUpdate(); 44 | } 45 | } 46 | 47 | private void createIndex(String column) throws SQLException { 48 | db.createIndexIfNotExists("feed", column); 49 | } 50 | 51 | private void createIndexes() throws SQLException { 52 | createIndex("id"); 53 | createIndex("location"); 54 | } 55 | 56 | public Feed saveFeed(Feed feed) throws SQLException { 57 | if (feed.getId() == 0) { 58 | if (existsFeed(feed.getLocation())) { 59 | updateFeedByLocation(feed); 60 | } else { 61 | return insertFeed(feed); 62 | } 63 | } else { 64 | updateFeedById(feed); 65 | } 66 | return feed; 67 | } 68 | 69 | public void touchFeed(Feed feed, String status) throws SQLException { 70 | Connection conn = db.getConnection(); 71 | try (PreparedStatement ps = conn.prepareStatement( 72 | "UPDATE feed SET lastAccess=?,lastStatus=? WHERE id=?")) { 73 | int i = 0; 74 | ps.setTimestamp(++i, Database.toTimestamp(new Date())); 75 | ps.setString(++i, FeedMetadata.mergeStatusAndColor(status, 76 | feed.getLastStatus(), null)); 77 | ps.setLong(++i, feed.getId()); 78 | ps.executeUpdate(); 79 | } 80 | } 81 | 82 | public long lastFeedUpdate() throws SQLException { 83 | Connection conn = db.getConnection(); 84 | try (PreparedStatement ps = conn.prepareStatement( 85 | "SELECT MAX(lastAccess) FROM feed")) { 86 | try (ResultSet rs = ps.executeQuery()) { 87 | if (rs.next()) { 88 | return rs.getTimestamp(1).getTime(); 89 | } else { 90 | return 0l; 91 | } 92 | } 93 | } 94 | } 95 | 96 | private void updateFeedById(Feed feed) throws SQLException { 97 | Connection conn = db.getConnection(); 98 | try (PreparedStatement ps = conn.prepareStatement( 99 | "UPDATE feed SET location=?,title=?,lastAccess=?" 100 | + "WHERE id=?")) { 101 | ps.setString(1, feed.getLocation()); 102 | ps.setString(2, feed.getTitle()); 103 | ps.setTimestamp(3, Database.toTimestamp(feed.getLastAccess())); 104 | ps.setLong(4, feed.getId()); 105 | ps.executeUpdate(); 106 | } 107 | log.debug("Updated feed {} with ID {}.", feed.getLocation(), feed.getId()); 108 | } 109 | 110 | private void updateFeedByLocation(Feed feed) throws SQLException { 111 | Connection conn = db.getConnection(); 112 | try (PreparedStatement ps = conn.prepareStatement( 113 | "UPDATE feed SET lastAccess=? WHERE location=?")) { 114 | ps.setTimestamp(1, Database.toTimestamp(feed.getLastAccess())); 115 | ps.setString(2, feed.getLocation()); 116 | ps.executeUpdate(); 117 | } 118 | log.debug("Updated feed {} with ID {}.", feed.getLocation(), feed.getId()); 119 | } 120 | 121 | private boolean existsFeed(String location) throws SQLException { 122 | try (PreparedStatement ps = db.getConnection().prepareStatement( 123 | "SELECT * FROM feed WHERE location=?")) { 124 | ps.setString(1, location); 125 | return Database.hasResult(ps); 126 | } 127 | } 128 | 129 | private Feed insertFeed(Feed feed) throws SQLException { 130 | try (PreparedStatement ps = db.getConnection().prepareStatement( 131 | "INSERT INTO feed (location, title, lastAccess, lastStatus) " 132 | + "VALUES (?,?,?,?)", 133 | Statement.RETURN_GENERATED_KEYS)) { 134 | ps.setString(1, feed.getLocation()); 135 | ps.setString(2, feed.getTitle()); 136 | ps.setTimestamp(3, Database.toTimestamp(feed.getLastAccess())); 137 | ps.setString(4, FeedMetadata.mergeStatusAndColor( 138 | feed.getLastStatus(), feed.getLastStatus(), null)); 139 | ps.executeUpdate(); 140 | feed = new Feed(Database.getGeneratedKey(ps), 141 | feed.getLocation(), feed.getTitle(), feed.getLastAccess(), 142 | feed.getLastStatus()); 143 | } 144 | log.debug("Inserted feed {} with ID {}", feed.getLocation(), feed.getId()); 145 | return feed; 146 | } 147 | 148 | public Optional getFeed(long id) throws SQLException { 149 | try (PreparedStatement ps = db.getConnection().prepareStatement( 150 | "SELECT id,location,title,lastAccess,lastStatus FROM feed WHERE id=?")) { 151 | ps.setLong(1, id); 152 | ResultSet rs = ps.executeQuery(); 153 | if (rs.next()) { 154 | return Optional.of(parseFeedResultSet(rs)); 155 | } else { 156 | return Optional.empty(); 157 | } 158 | } 159 | } 160 | 161 | public List getAllFeeds() throws SQLException { 162 | List feeds = new ArrayList<>(); 163 | try (PreparedStatement ps = db.getConnection().prepareStatement( 164 | "SELECT id,location,title,lastAccess,lastStatus FROM feed ORDER BY title ASC")) { 165 | ResultSet rs = ps.executeQuery(); 166 | while (rs.next()) { 167 | feeds.add(parseFeedResultSet(rs)); 168 | } 169 | } 170 | return feeds; 171 | } 172 | 173 | public static void main(String[] args) throws SQLException { 174 | try (Database db = new Database()) { 175 | FeedDao fd = new FeedDao(db); 176 | System.out.println(fd.getAllFeedInfos().size()); 177 | } 178 | 179 | } 180 | public List getAllFeedInfos() throws SQLException { 181 | List feeds = new ArrayList<>(); 182 | try (PreparedStatement ps = db.getConnection().prepareStatement( 183 | "SELECT DISTINCT(feed.id),feed.location,feed.title,feed.lastAccess,feed.lastStatus," 184 | + "COUNT(CASE WHEN post.read=0 THEN 1 END)," 185 | + "COUNT(CASE WHEN post.read=1 THEN 1 END)," 186 | + "MAX(post.date) " 187 | + "FROM feed " 188 | + "LEFT JOIN postfeed ON feed.id=postfeed.feed " 189 | + "LEFT JOIN post ON postfeed.post=post.id " 190 | + "GROUP BY feed.id ORDER BY feed.id")) { 191 | ResultSet rs = ps.executeQuery(); 192 | while (rs.next()) { 193 | feeds.add(parseFeedInfoResultSet(rs)); 194 | } 195 | } 196 | return feeds; 197 | } 198 | 199 | public List getAllFeedColors() throws SQLException { 200 | List colors = new ArrayList<>(); 201 | try (PreparedStatement ps = db.getConnection().prepareStatement( 202 | "SELECT lastStatus FROM feed")) { 203 | ResultSet rs = ps.executeQuery(); 204 | while (rs.next()) { 205 | String color = FeedMetadata.extractColor(rs.getString(1)); 206 | if (color != null) { 207 | colors.add(color); 208 | } 209 | } 210 | } 211 | return colors; 212 | } 213 | 214 | private static Feed parseFeedResultSet(ResultSet rs) throws SQLException { 215 | return parseFeedResultSet(rs, new int[] {0}); 216 | } 217 | private static Feed parseFeedResultSet(ResultSet rs, int[] ctrRef) throws SQLException { 218 | int i = ctrRef[0]; 219 | long id = rs.getLong(++i); 220 | String location = rs.getString(++i); 221 | String title = rs.getString(++i); 222 | Date lastAccess = rs.getTimestamp(++i); 223 | String lastStatus = rs.getString(++i); 224 | if (lastStatus == null) { 225 | lastStatus = "unknown"; 226 | } 227 | ctrRef[0] = i; 228 | Feed feed = new Feed(id, location, title, lastAccess, lastStatus); 229 | return feed; 230 | } 231 | 232 | private static FeedInfo parseFeedInfoResultSet(ResultSet rs) throws SQLException { 233 | int[] ctrRef = new int[] {0}; 234 | Feed f = parseFeedResultSet(rs, ctrRef); 235 | int i = ctrRef[0]; 236 | int unread = rs.getInt(++i); 237 | int read = rs.getInt(++i); 238 | Date mostRecent = rs.getTimestamp(++i); 239 | return new FeedInfo(f, unread, read, mostRecent); 240 | } 241 | 242 | public void deleteFeed(long id) throws SQLException { 243 | Feed f = getFeed(id).get(); 244 | Connection conn = db.getConnection(); 245 | try (PreparedStatement ps = conn.prepareStatement( 246 | "DELETE FROM feed WHERE id=?")) { 247 | ps.setLong(1, id); 248 | ps.executeUpdate(); 249 | } 250 | log.debug("Deleted feed {} with ID {}.", f.getLocation(), id); 251 | } 252 | 253 | public void storeFeedColor(Feed feed, String color) throws SQLException { 254 | Connection conn = db.getConnection(); 255 | try (PreparedStatement ps = conn.prepareStatement( 256 | "UPDATE feed SET lastStatus=? WHERE id=?")) { 257 | ps.setString(1, FeedMetadata.mergeStatusAndColor(null, 258 | feed.getLastStatus(), color)); 259 | ps.setLong(2, feed.getId()); 260 | ps.executeUpdate(); 261 | } 262 | } 263 | 264 | public void clearAllFeedColors() throws SQLException { 265 | List feeds = getAllFeeds(); 266 | Connection conn = db.getConnection(); 267 | try (PreparedStatement ps = conn.prepareStatement( 268 | "UPDATE feed SET lastStatus=? WHERE id=?")) { 269 | for (Feed feed : feeds) { 270 | ps.setString(1, FeedMetadata.stripColor(feed.getLastStatus())); 271 | ps.setLong(2, feed.getId()); 272 | ps.addBatch(); 273 | } 274 | ps.executeBatch(); 275 | } 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/main/java/fiets/db/PostDao.java: -------------------------------------------------------------------------------- 1 | package fiets.db; 2 | 3 | import java.sql.Connection; 4 | import java.sql.PreparedStatement; 5 | import java.sql.ResultSet; 6 | import java.sql.SQLException; 7 | import java.sql.Statement; 8 | import java.util.ArrayList; 9 | import java.util.Date; 10 | import java.util.HashSet; 11 | import java.util.List; 12 | import java.util.Set; 13 | 14 | import org.apache.logging.log4j.LogManager; 15 | import org.apache.logging.log4j.Logger; 16 | 17 | import fiets.model.Feed; 18 | import fiets.model.Post; 19 | 20 | /** 21 | * A post is considered equal to an existing one when either the location or the 22 | * title matches. Only when both the location and the title differ is a post 23 | * treated as new. 24 | */ 25 | public class PostDao { 26 | 27 | private static final String OUTDATED_SPEC = 28 | " WHERE post.read = true AND " 29 | + "post.lastaccess <= DATEADD('month', -1, current_timestamp()) AND " 30 | + "post.id NOT IN (SELECT bookmarkedpost.post FROM bookmarkedpost)"; 31 | private static final Logger log = LogManager.getLogger(); 32 | 33 | private Database db; 34 | 35 | public PostDao(Database theDb) throws SQLException { 36 | db = theDb; 37 | createTables(); 38 | createIndexes(); 39 | upgradeTables(); 40 | } 41 | 42 | private void upgradeTables() throws SQLException { 43 | addLastaccessColumn(); 44 | } 45 | 46 | public void createTables() throws SQLException { 47 | createPostTable(); 48 | createPostFeedTable(); 49 | createBookmarkTable(); 50 | } 51 | 52 | private void createIndex(String column) throws SQLException { 53 | db.createIndexIfNotExists("post", column); 54 | } 55 | 56 | private void createIndexes() throws SQLException { 57 | createIndex("id"); 58 | createIndex("date"); 59 | createIndex("read"); 60 | createIndex("location"); 61 | } 62 | 63 | public Post savePost(Post post, Feed feed) throws SQLException { 64 | if (post.getId() == 0L) { 65 | Post existing = loadPostByLocation(post.getLocation()); 66 | if (existing == null) { 67 | existing = loadPostByTitle(post.getTitle()); 68 | } 69 | if (existing == null) { 70 | post = insertPost(post); 71 | } else { 72 | post = existing; 73 | log.debug("Post {} already exists with ID {}.", 74 | post.getLocation(), post.getId()); 75 | touchPost(post); 76 | } 77 | } else { 78 | updatePostById(post); 79 | } 80 | savePostFeed(post, feed); 81 | return post; 82 | } 83 | 84 | private Post loadPostByTitle(String title) throws SQLException { 85 | Connection conn = db.getConnection(); 86 | try (PreparedStatement ps = conn.prepareStatement( 87 | selectPost("WHERE post.title=?"))) { 88 | ps.setString(1, title); 89 | try (ResultSet rs = ps.executeQuery()) { 90 | if (!rs.next()) { 91 | return null; 92 | } 93 | return parsePostResultSet(rs); 94 | } 95 | } 96 | } 97 | 98 | public void savePosts(List posts, Feed feed) throws SQLException { 99 | for (Post post : posts) { 100 | savePost(post, feed); 101 | } 102 | } 103 | 104 | public Post loadPostByLocation(String location) throws SQLException { 105 | Connection conn = db.getConnection(); 106 | try (PreparedStatement ps = conn.prepareStatement( 107 | selectPost("WHERE post.location=?"))) { 108 | ps.setString(1, location); 109 | try (ResultSet rs = ps.executeQuery()) { 110 | if (!rs.next()) { 111 | return null; 112 | } 113 | return parsePostResultSet(rs); 114 | } 115 | } 116 | } 117 | 118 | public Set getBookmarks() throws SQLException { 119 | Connection conn = db.getConnection(); 120 | try (PreparedStatement ps = conn.prepareStatement( 121 | "SELECT post FROM bookmarkedpost")) { 122 | try (ResultSet rs = ps.executeQuery()) { 123 | List posts = new ArrayList<>(); 124 | while (rs.next()) { 125 | posts.add(rs.getLong(1)); 126 | } 127 | return new HashSet<>(posts); 128 | } 129 | } 130 | } 131 | 132 | public int getUnreadCount() throws SQLException { 133 | return getCount(" WHERE post.read=false"); 134 | } 135 | 136 | public int getReadCount() throws SQLException { 137 | return getCount(" WHERE post.read=true"); 138 | } 139 | 140 | public int getOutdatedCount() throws SQLException { 141 | return getCount(OUTDATED_SPEC); 142 | } 143 | 144 | public void deleteOutdated() throws SQLException { 145 | try (PreparedStatement ps = db.getConnection().prepareStatement( 146 | "DELETE FROM post" + OUTDATED_SPEC)) { 147 | ps.executeUpdate(); 148 | } 149 | } 150 | 151 | public void deletePost(long id) throws SQLException { 152 | try (PreparedStatement ps = db.getConnection().prepareStatement( 153 | "DELETE FROM post WHERE id=?")) { 154 | ps.setLong(1, id); 155 | ps.executeUpdate(); 156 | } 157 | } 158 | 159 | public void deletePostsOfFeed(long feedId) throws SQLException { 160 | try (PreparedStatement ps = db.getConnection().prepareStatement( 161 | "SELECT post.id FROM post " + 162 | "INNER JOIN postfeed ON post.id=postfeed.post " + 163 | "WHERE postfeed.feed=?")) { 164 | ps.setLong(1, feedId); 165 | try (ResultSet rs = ps.executeQuery()) { 166 | List posts = new ArrayList<>(); 167 | while (rs.next()) { 168 | long id = rs.getLong(1); 169 | deletePost(id); 170 | } 171 | } 172 | } 173 | 174 | try (PreparedStatement ps = db.getConnection().prepareStatement( 175 | "DELETE FROM postfeed WHERE postfeed.feed=?")) { 176 | ps.setLong(1, feedId); 177 | ps.executeUpdate(); 178 | } 179 | } 180 | 181 | public int getFullCount() throws SQLException { 182 | return getCount(""); 183 | } 184 | 185 | public int getBookmarksCount() throws SQLException { 186 | Connection conn = db.getConnection(); 187 | try (PreparedStatement ps = conn.prepareStatement( 188 | "SELECT COUNT(post) FROM bookmarkedpost")) { 189 | try (ResultSet rs = ps.executeQuery()) { 190 | rs.next(); 191 | return rs.getInt(1); 192 | } 193 | } 194 | } 195 | 196 | public String getMostRecentPostLocation(long feedId) throws SQLException { 197 | Connection conn = db.getConnection(); 198 | try (PreparedStatement ps = conn.prepareStatement( 199 | selectPost("INNER JOIN postfeed ON post.id=postfeed.post " 200 | + "WHERE postfeed.feed=? ORDER BY post.date DESC, post.id DESC LIMIT 1"))) { 201 | ps.setLong(1, feedId); 202 | try (ResultSet rs = ps.executeQuery()) { 203 | if (!rs.next()) { 204 | return null; 205 | } 206 | return rs.getString("location"); 207 | } 208 | } 209 | } 210 | 211 | private int getCount(String appendix) throws SQLException { 212 | Connection conn = db.getConnection(); 213 | try (PreparedStatement ps = conn.prepareStatement( 214 | "SELECT COUNT(id) FROM post" + appendix)) { 215 | try (ResultSet rs = ps.executeQuery()) { 216 | rs.next(); 217 | return rs.getInt(1); 218 | } 219 | } 220 | } 221 | 222 | public List getUnreadPosts(int num) throws SQLException { 223 | Connection conn = db.getConnection(); 224 | String appendix = "WHERE post.read=false ORDER BY post.date ASC"; 225 | if (num > 0) { 226 | appendix += " LIMIT 0," + num; 227 | } 228 | return loadPosts(conn, appendix); 229 | } 230 | 231 | public List getReadPosts(int num) throws SQLException { 232 | Connection conn = db.getConnection(); 233 | String appendix = "WHERE post.read=true ORDER BY post.date DESC"; 234 | if (num > 0) { 235 | appendix += " LIMIT 0," + num; 236 | } 237 | return loadPosts(conn, appendix); 238 | } 239 | 240 | private List loadPosts(Connection conn, String appendix) 241 | throws SQLException { 242 | try (PreparedStatement ps = conn.prepareStatement( 243 | selectPost(appendix))) { 244 | try (ResultSet rs = ps.executeQuery()) { 245 | List posts = new ArrayList<>(); 246 | while (rs.next()) { 247 | posts.add(parsePostResultSet(rs)); 248 | } 249 | return posts; 250 | } 251 | } 252 | } 253 | 254 | public List postsAfter(long sinceId) throws SQLException { 255 | Connection conn = db.getConnection(); 256 | String appendix = String.format( 257 | "WHERE post.id > %d ORDER BY post.id ASC", sinceId); 258 | return loadPosts(conn, appendix); 259 | } 260 | 261 | public List postsBefore(long maxId) throws SQLException { 262 | Connection conn = db.getConnection(); 263 | String appendix = String.format( 264 | "WHERE post.id < %d ORDER BY post.id ASC", maxId); 265 | return loadPosts(conn, appendix); 266 | } 267 | 268 | public List posts(List withIds) throws SQLException { 269 | String idString = withIds.toString(); 270 | idString = idString.substring(1, idString.length()-1); 271 | Connection conn = db.getConnection(); 272 | String appendix = String.format( 273 | "WHERE post.id IN (%s) ORDER BY post.id ASC", idString); 274 | return loadPosts(conn, appendix); 275 | } 276 | 277 | public List getBookmarkedPosts() throws SQLException { 278 | Connection conn = db.getConnection(); 279 | String appendix = 280 | "ORDER BY post.date ASC"; 281 | try (PreparedStatement ps = conn.prepareStatement( 282 | selectBookmarkedPost(appendix))) { 283 | try (ResultSet rs = ps.executeQuery()) { 284 | List posts = new ArrayList<>(); 285 | while (rs.next()) { 286 | long bookmarkedId = rs.getLong(1); 287 | posts.add(parsePostResultSet(rs, 1, bookmarkedId)); 288 | } 289 | return posts; 290 | } 291 | } 292 | } 293 | 294 | private static String selectPost(String appendix) { 295 | return "SELECT " 296 | + "post.id,post.date,post.location,post.snippet,post.title,post.read," 297 | + "feed.id,feed.location,feed.title,feed.lastAccess,feed.lastStatus " 298 | + "FROM post " 299 | + "LEFT JOIN postfeed ON post.id=postfeed.post " 300 | + "LEFT JOIN feed ON postfeed.feed=feed.id " 301 | + (appendix == null ? "" : appendix); 302 | } 303 | 304 | private static String selectBookmarkedPost(String appendix) { 305 | return "SELECT " 306 | + "bookmarkedpost.post," 307 | + "post.id,post.date,post.location,post.snippet,post.title,post.read," 308 | + "feed.id,feed.location,feed.title,feed.lastAccess,feed.lastStatus " 309 | + "FROM bookmarkedpost " 310 | + "LEFT JOIN post ON bookmarkedpost.post=post.id " 311 | + "LEFT JOIN postfeed ON post.id=postfeed.post " 312 | + "LEFT JOIN feed ON postfeed.feed=feed.id " 313 | + (appendix == null ? "" : appendix); 314 | } 315 | 316 | private Post parsePostResultSet(ResultSet rs) throws SQLException { 317 | return parsePostResultSet(rs, 0, null); 318 | } 319 | 320 | private Post parsePostResultSet(ResultSet rs, int offset, Long fallbackId) 321 | throws SQLException { 322 | int index = offset; 323 | long id = rs.getLong(++index); 324 | if (rs.wasNull() && fallbackId != null) { 325 | id = fallbackId; 326 | } 327 | Date date = rs.getTimestamp(++index); 328 | String location = rs.getString(++index); 329 | String snippet = rs.getString(++index); 330 | String title = rs.getString(++index); 331 | boolean read = rs.getBoolean(++index); 332 | long feedId = rs.getLong(++index); 333 | String feedLocation = rs.getString(++index); 334 | String feedTitle = rs.getString(++index); 335 | Date feedLastAccess = rs.getTimestamp(++index); 336 | String feedLastStatus = rs.getString(++index); 337 | return new Post(id, location, date, title, snippet, read, 338 | new Feed(feedId, feedLocation, feedTitle, feedLastAccess, feedLastStatus)); 339 | } 340 | 341 | private void updatePostById(Post post) throws SQLException { 342 | Connection conn = db.getConnection(); 343 | try (PreparedStatement ps = conn.prepareStatement( 344 | "UPDATE post " 345 | + "SET date=?,location=?,snippet=?,title=?,read=?,lastaccess=? " 346 | + "WHERE id=?")) { 347 | int index = preparePostStatement(ps, post); 348 | ps.setLong(++index, post.getId()); 349 | ps.executeUpdate(); 350 | } 351 | log.debug("Updated post {} with ID {}.", post.getLocation(), post.getId()); 352 | } 353 | 354 | private void touchPost(Post post) throws SQLException { 355 | Connection conn = db.getConnection(); 356 | try (PreparedStatement ps = conn.prepareStatement( 357 | "UPDATE post SET lastaccess=? WHERE id=?")) { 358 | int index = 0; 359 | ps.setTimestamp(++index, Database.toTimestamp(new Date())); 360 | ps.setLong(++index, post.getId()); 361 | ps.executeUpdate(); 362 | } 363 | log.debug("Touched post {} with ID {}.", post.getLocation(), post.getId()); 364 | } 365 | 366 | private Post insertPost(Post post) throws SQLException { 367 | Connection conn = db.getConnection(); 368 | log.debug("Insert post " + post.getLocation()); 369 | try (PreparedStatement ps = conn.prepareStatement( 370 | "INSERT INTO post (date,location,snippet,title,read,lastaccess) " 371 | + "VALUES (?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS)) { 372 | preparePostStatement(ps, post); 373 | ps.executeUpdate(); 374 | post = new Post(Database.getGeneratedKey(ps), post); 375 | } 376 | log.debug("Inserted post {} with ID {}.", post.getLocation(), post.getId()); 377 | return post; 378 | } 379 | 380 | private int preparePostStatement(PreparedStatement ps, Post post) 381 | throws SQLException { 382 | int index = 0; 383 | ps.setTimestamp(++index, Database.toTimestamp(post.getDate())); 384 | ps.setString(++index, post.getLocation()); 385 | ps.setString(++index, shorten(post.getSnippet(), 4096)); 386 | ps.setString(++index, post.getTitle()); 387 | ps.setBoolean(++index, post.isRead()); 388 | ps.setTimestamp(++index, Database.toTimestamp(new Date())); 389 | return index; 390 | } 391 | 392 | private String shorten(String snippet, int len) { 393 | if (snippet.length() > len) { 394 | return snippet.substring(0, len); 395 | } 396 | return snippet; 397 | } 398 | 399 | private int createPostTable() throws SQLException { 400 | try (PreparedStatement ps = db.getConnection().prepareStatement( 401 | "CREATE TABLE IF NOT EXISTS post (" 402 | + "id BIGINT PRIMARY KEY AUTO_INCREMENT," 403 | + "date DATETIME," 404 | + "location VARCHAR(2048)," 405 | + "snippet VARCHAR(4096)," 406 | + "title VARCHAR(1024)," 407 | + "read TINYINT," 408 | + "lastaccess DATETIME" 409 | + ")")) { 410 | return ps.executeUpdate(); 411 | } 412 | } 413 | 414 | private void addLastaccessColumn() throws SQLException { 415 | try (PreparedStatement ps = db.getConnection().prepareStatement( 416 | "ALTER TABLE post " 417 | + "ADD COLUMN IF NOT EXISTS lastaccess DATETIME DEFAULT ?")) { 418 | ps.setTimestamp(1, Database.toTimestamp(new Date())); 419 | ps.executeUpdate(); 420 | } 421 | } 422 | 423 | private int createPostFeedTable() throws SQLException { 424 | try (PreparedStatement ps = db.getConnection().prepareStatement( 425 | "CREATE TABLE IF NOT EXISTS postfeed (" 426 | + "post BIGINT," 427 | + "feed BIGINT" 428 | + ")")) { 429 | return ps.executeUpdate(); 430 | } 431 | } 432 | 433 | private int createBookmarkTable() throws SQLException { 434 | try (PreparedStatement ps = db.getConnection().prepareStatement( 435 | "CREATE TABLE IF NOT EXISTS bookmarkedpost (" 436 | + "post BIGINT" 437 | + ")")) { 438 | return ps.executeUpdate(); 439 | } 440 | } 441 | 442 | private boolean existsPostFeed(Post post, Feed feed) throws SQLException { 443 | try (PreparedStatement ps = db.getConnection().prepareStatement( 444 | "SELECT post, feed FROM postfeed WHERE post=? AND feed=?")) { 445 | ps.setLong(1, post.getId()); 446 | ps.setLong(2, feed.getId()); 447 | return Database.hasResult(ps); 448 | } 449 | } 450 | 451 | private void savePostFeed(Post post, Feed feed) throws SQLException { 452 | if (!existsPostFeed(post, feed)) { 453 | try (PreparedStatement ps = db.getConnection().prepareStatement( 454 | "INSERT INTO postfeed (post, feed) VALUES (?, ?)")) { 455 | ps.setLong(1, post.getId()); 456 | ps.setLong(2, feed.getId()); 457 | ps.executeUpdate(); 458 | } 459 | } 460 | } 461 | 462 | public void markPostsRead(List postIds) throws SQLException { 463 | int num = postIds.size(); 464 | try (PreparedStatement ps = db.getConnection().prepareStatement( 465 | "UPDATE post SET post.read=true WHERE post.id IN " + inCondition(num))) { 466 | int i = 0; 467 | for (Long postId : postIds) { 468 | ps.setLong(++i, postId); 469 | } 470 | ps.executeUpdate(); 471 | } 472 | } 473 | 474 | public void markPostRead(long postId) throws SQLException { 475 | try (PreparedStatement ps = db.getConnection().prepareStatement( 476 | "UPDATE post SET post.read=true WHERE post.id=?")) { 477 | ps.setLong(1, postId); 478 | ps.executeUpdate(); 479 | } 480 | } 481 | 482 | public void markPostUnread(long postId) throws SQLException { 483 | try (PreparedStatement ps = db.getConnection().prepareStatement( 484 | "UPDATE post SET post.read=false WHERE post.id=?")) { 485 | ps.setLong(1, postId); 486 | ps.executeUpdate(); 487 | } 488 | } 489 | 490 | public void bookmarkPost(long postId) throws SQLException { 491 | try (PreparedStatement ps = db.getConnection().prepareStatement( 492 | "INSERT INTO bookmarkedpost (post) VALUES (?)")) { 493 | ps.setLong(1, postId); 494 | ps.executeUpdate(); 495 | } 496 | } 497 | 498 | public void removeBookmarkPost(long postId) throws SQLException { 499 | try (PreparedStatement ps = db.getConnection().prepareStatement( 500 | "DELETE FROM bookmarkedpost WHERE post=?")) { 501 | ps.setLong(1, postId); 502 | ps.executeUpdate(); 503 | } 504 | } 505 | 506 | private static String inCondition(int num) { 507 | StringBuilder sb = new StringBuilder(num*3); 508 | for (int i = 0; i < num; i++) { 509 | sb.append("?,"); 510 | } 511 | return '(' + sb.deleteCharAt(sb.length()-1).toString() + ')'; 512 | } 513 | 514 | public void markAllRead(Date before) throws SQLException { 515 | try (PreparedStatement ps = db.getConnection().prepareStatement( 516 | "UPDATE post SET post.read=true WHERE post.date < ?")) { 517 | ps.setTimestamp(1, Database.toTimestamp(before)); 518 | ps.executeUpdate(); 519 | } 520 | } 521 | 522 | } 523 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | --------------------------------------------------------------------------------