tag was encountered
+ See !192
+
+# 1.8.15
+* Improved webview: cookie dialogs should no longer be off screen
+ See !190
+
+# 1.8.14
+* Fixed crash on tablets
+ See !189 #191
+* Fixed handling of URLs with only user (such as http://user@...)
+ See !188
+
+# 1.8.13
+* Fixed edit dialog starting with the wrong theme
+ See !187
+* Fixed spelling error in Spanish
+ See !185
+* Fixed webview resetting night mode
+ See !185 #172
+* Migrated to single activity; app should feel faster
+ See !185
+* Fixed thumbnails not showing in Engadget feed
+ See !183 #186
+
+# 1.8.12
+* Fixed webview being obscured by the action bar
+ See !182 #179 #173
+* Added Spanish translation
+ Thanks to Khar Khamal
+ See !180
+
+# 1.8.11
+
+Removed "mark as read when scrolling". It had a bug when toggling display of read items, and it was very "surprising" to some users.
+
+Will be back when bug free and off by default.
+
+# 1.8.10
+* Update Simplified Chinese Translation
+ Thanks to linsui
+ See !179
+* Added option to mark items as read as you scroll (defaults to true)
+
+# 1.8.9
+* Increased http timeouts to 30 seconds from 5 seconds
+ See !175
+* Changed so time of publication (and not just date) is shown in Article
+ See !174 #61
+
+# 1.8.8
+* Changed plaintext conversion to stop formatting as markdown
+ See !172
+* Fixed not being able to parse dates in certain feeds
+ See !170
+* Fixed so feeds without publication dates gets some when synced
+ See !169 #178
+
+# 1.8.7
+* Added support for RTL
+ Some devices might still not render perfectly though
+ See !165 #176
+* Fixed youtube previews not showing
+ See !168
+* Changed plaintext rendering to not include '[image alt text]' in text
+ See !167
+* Changed so that notification actions do not open the app after pressing Back
+ See !166
+
+# 1.8.6
+* Fixed notification "Open in"-actions not working
+ See !164
+
+# 1.8.5
+* Fixed parsing of feeds without unique guids or links (NixOS)
+ See !162
+* Changed so feed search finds alternate links in body of documents
+ See !162
+* Fixed feed results not showing error message on *second* search
+ See !162
+* Feeder can now be used to *open* links, not just accept *shared* ones
+ See !161 #174
+* Fixed notifications so that all actions will mark item as read also
+ See !160
+* Fixed app losing state if in reader and switching to another app and back again
+ See !159
+* Fixed action bar overlaying web view
+ See !157 #173
+* Fixed custom feed titles not being displayed
+ See !154 #168 #167
+* Updated Simplified Chinese Translation
+ Thanks to linsui
+ See !153
+* Fixed feeds with no link not working
+ See !150 #165
+* Fixed some parsing errors on feeds with slash-comments
+ See #166
+
+# 1.8.4
+* Fixed long blog title overlapping date
+ See !149 #164
+* Fixed crash when loading certain videos
+ See !148 #163
+* Fixed opening in browser from notification not marking as read or dismissing
+ See !146 #155
+
+# 1.8.3
+* Tweaked colors in themes
+ See !144 #159
+* Fixed crash when loading bad images
+* Fixed scrolling position getting reset during sync in Reader
+ See !142 #160
+* Fixed crash when loading bad images
+ See !140
+* Fixed theme-specific place holder image for articles
+ See !139
+
+# 1.8.2
+* Fixed crash when image could not be loaded on pre Lollipop
+ See !138 #156
+* Added menu item for sending a bug report via email
+ See !137
+
+# 1.8.1
+* Fixed crash when clearing notifications
+ See !136 #153
+* Update Simplified Chinese
+ Thanks to linsui
+ See !134
+* Fixed screenshots in README
+ Thanks to DJCrashdummy
+ See !135
+
+# 1.8.0
+* Removed option to sync on Hotspots
+ Fixed automatic synchronization never running on mobile data
+ Added option to sync when app is opened
+ Improved caching so less data traffic will be used during sync
+ Improved sync speed by only parsing feeds with new content
+ See !131
+* Improved error handling in Add Feed dialog
+ See !132
+* Simplified Chinese Translation
+ Thanks to linsui
+ See !128
+
+# 1.7.1
+* Fixed possible crash when marking all items as read
+ See !127 #145
+* Fixed text for show unread toggle
+ See !125
+
+# 1.7.0
+* Moved notification toggle to options menu
+ See !123 #125 #66
+* Added a light theme
+ See !122 #38
+* Fixed size of FAB icon on high density screens
+ See !119
+* Fixed crash for certain feeds with slash comment meta-data
+ See !117 #140
+* Added additional sync frequency options (15min and 30min)
+ Also removed the need for an account and related system permission
+ See #49
+* Added menu option in reader to mark item as unread
+ See !111 #134
+
+# 1.6.8
+* Fixed crash when supplying bad URL to add feed dialog
+ See !110 #137
+* Fix typo in German translation
+ Thanks to Swen Krüger
+ See !109
+
+# 1.6.7
+* Fixed crash on older Android versions when opening a web view
+ See !108
+* Fixed update of views when pressing 'mark all as read' button
+ See !107
+* Improved network caching
+ See !105
+* German translations updated and added
+ Thanks to Chris
+ See !106
+
+# 1.6.6
+
+- Fixed a crash in Reader
+
+# 1.6.5
+* Added support for username/password in URLs
+ See !100 #128
+* Fixed https compatibility on older versions of Android
+ See !102 #113
+* Fixed crash for HorribleSubs.info
+ See !103 #131
+
+# 1.6.4
+* Added paging to lists
+ See !99
+* Added option for maximum number of items per feed
+ See !98 #126
+
+# 1.6.3
+* Now all links are explicitly opened in new browser tabs
+ See !97 #117
+* Fixed buggy back stack
+ See !96
+
+# 1.6.2
+* Block cookies from webview
+ See !95
+
+# 1.6.1
+* Fixed parsing of some OPML formats
+ See !94 #111
+
+# 1.6.0
+* Added option of how to open articles.
+ One of Reader, WebView or Browser.
+ See !93 #39 #102
+* Fixed resolution of relative links
+ See !92 #101
+
+# 1.5.0
+* Fixed notifications
+ See !91 #10 #88
+* Changed to allow installation on internal storage
+ This has always been implied by the limitations of Android but now
+ it is explicit to avoid issues for people who try to move it to
+ external storage.
+ See !78 #79
+* Added special handling for finding Youtube feeds
+ See !90 #100
+* Fixed HTML encoded titles not being decoded in list
+ See !89 #91
+* Changed so more feeds display thumbnail images
+ See !88 #96
+* Fixed various crashes
+
+# 1.4.3
+* Fixed crash for missing video urls
+ See !84 #90
+* Improved UI responsiveness but throttling database loaders
+ See !81
+* Fixed existing tag not being shown in edit feed dialog
+ See !80 #82
+* Improved rendering of
text"),
+ URL("http://foo.com"),
+ Point(100, 100),
+ builder,
+ null
+ )
+
+ assertEquals(2, builder.getAllSpansWithType().size)
+ assertEquals(1, builder.getAllSpansWithType().size)
+ assertEquals(1, builder.getAllSpansWithType().size)
+
+ assertTrue(builder.toString().contains("pre code formatted"))
+ }
+}
diff --git a/app/src/androidTest/java/com/nononsenseapps/feeder/util/BugReportKTest.kt b/app/src/androidTest/java/com/nononsenseapps/feeder/util/BugReportKTest.kt
new file mode 100644
index 0000000..3cc6da2
--- /dev/null
+++ b/app/src/androidTest/java/com/nononsenseapps/feeder/util/BugReportKTest.kt
@@ -0,0 +1,87 @@
+package com.nononsenseapps.feeder.util
+
+import android.content.Context
+import android.content.Intent.ACTION_SENDTO
+import android.content.Intent.ACTION_VIEW
+import android.content.Intent.EXTRA_EMAIL
+import android.content.Intent.EXTRA_SUBJECT
+import android.content.Intent.EXTRA_TEXT
+import android.net.Uri
+import android.os.Build
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.nononsenseapps.feeder.BuildConfig
+import com.nononsenseapps.feeder.R
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class BugReportKTest {
+ @Test
+ fun bodyContainsAndroidInformation() {
+ assertEquals(
+ """
+ ${BuildConfig.APPLICATION_ID} (flavor ${BuildConfig.BUILD_TYPE.ifBlank { "None" }})
+ version ${BuildConfig.VERSION_NAME} (code ${BuildConfig.VERSION_CODE})
+ on Android ${Build.VERSION.RELEASE} (SDK-${Build.VERSION.SDK_INT})
+ on a Tablet? No
+
+ Describe your issue and how to reproduce it below:
+ """.trimIndent(),
+ emailBody(false)
+ )
+ }
+
+ @Test
+ fun bodyContainsAndroidInformationAsTablet() {
+ assertEquals(
+ """
+ ${BuildConfig.APPLICATION_ID} (flavor ${BuildConfig.BUILD_TYPE.ifBlank { "None" }})
+ version ${BuildConfig.VERSION_NAME} (code ${BuildConfig.VERSION_CODE})
+ on Android ${Build.VERSION.RELEASE} (SDK-${Build.VERSION.SDK_INT})
+ on a Tablet? Yes
+
+ Describe your issue and how to reproduce it below:
+ """.trimIndent(),
+ emailBody(true)
+ )
+ }
+
+ @Test
+ fun subjectIsSensible() {
+ assertEquals(
+ "Bug report for Feeder",
+ emailSubject()
+ )
+ }
+
+ @Test
+ fun emailAddressIsCorrect() {
+ assertEquals(
+ "jonas.feederbugs@cowboyprogrammer.org",
+ emailReportAddress()
+ )
+ }
+
+ @Test
+ fun emailIntentIsCorrect() {
+ val intent = emailBugReportIntent(getApplicationContext())
+
+ assertEquals(ACTION_SENDTO, intent.action)
+ assertEquals(Uri.parse("mailto:${emailReportAddress()}"), intent.data)
+ assertEquals(emailSubject(), intent.getStringExtra(EXTRA_SUBJECT))
+ assertEquals(emailBody(getApplicationContext().resources.getBoolean(R.bool.isTablet)), intent.getStringExtra(EXTRA_TEXT))
+ assertEquals(emailReportAddress(), intent.getStringExtra(EXTRA_EMAIL))
+ }
+
+ @Test
+ fun issuesIntentIsCorrect() {
+ val intent = openGitlabIssues()
+
+ assertEquals(ACTION_VIEW, intent.action)
+ assertEquals(Uri.parse("https://gitlab.com/spacecowboy/Feeder/issues"), intent.data)
+ }
+}
diff --git a/app/src/androidTest/resources/com/nononsenseapps/feeder/model/cowboyprogrammer_atom.xml b/app/src/androidTest/resources/com/nononsenseapps/feeder/model/cowboyprogrammer_atom.xml
new file mode 100644
index 0000000..1dfc66b
--- /dev/null
+++ b/app/src/androidTest/resources/com/nononsenseapps/feeder/model/cowboyprogrammer_atom.xml
@@ -0,0 +1,1757 @@
+
+
+ https://cowboyprogrammer.org/
+ Cowboy Programmer
+ 2018-03-05T23:00:00+02:00
+
+ Space Cowboy
+ jonas@cowboyprogrammer.org
+
+
+
+
+ Hugo -- gohugo.io
+ Recent content in Cowboy Programmer on Cowboy Programmer
+ https://cowboyprogrammer.org/css/images/logo.png
+ Powered by [Hugo](//gohugo.io) and [Icarus Theme](http://themes.gohugo.io/theme/hugo-icarus/).
+
+
+
+ https://cowboyprogrammer.org/2018/03/fixed-vs-variable-interest-rates/
+
+ A comparison between fixed and variable interest rates
+ 2018-03-05T23:00:00+02:00
+
+ Space Cowboy
+ jonas@cowboyprogrammer.org
+
+
+
+
+
+ The data I am using is originally from SwedBank and all data and
+code is available at GitLab. The data contains interest
+rates at 5 years fixed term, 2 years fixed term, and 3 months fixed
+term (also called variable rate in Sweden) for those dates when any
+rate was changed. The first rates are from 1989-11-01 and the last are
+from 2018-02-12. Example of the data:
+
+
+
+
+
+
5y
+
2y
+
3m
+
+
+
Date
+
+
+
+
+
+
+
+
1989-11-22
+
13.50
+
13.50
+
12.75
+
+
+
1991-01-14
+
14.00
+
14.75
+
15.25
+
+
+
1993-01-13
+
12.75
+
13.00
+
13.75
+
+
+
1994-11-21
+
11.75
+
11.50
+
9.75
+
+
+
1996-03-12
+
9.85
+
8.95
+
9.10
+
+
+
2005-09-09
+
3.55
+
2.97
+
3.15
+
+
+
2005-10-03
+
3.69
+
3.09
+
3.15
+
+
+
2007-12-21
+
5.36
+
5.25
+
5.15
+
+
+
2008-01-24
+
5.13
+
4.94
+
5.15
+
+
+
2009-03-20
+
4.26
+
2.83
+
2.20
+
+
+
+
+
To make the calculations more convenient I assume that loans are only
+fixed the first day of the month. Example:
+
+
+
+
+
+
5y
+
2y
+
3m
+
+
+
Date
+
+
+
+
+
+
+
+
1990-06-01
+
14.50
+
14.50
+
13.95
+
+
+
1992-03-01
+
12.50
+
13.00
+
14.75
+
+
+
1993-06-01
+
10.75
+
10.50
+
11.50
+
+
+
1998-02-01
+
6.70
+
6.40
+
5.80
+
+
+
2001-09-01
+
6.55
+
5.95
+
5.90
+
+
+
2004-11-01
+
4.85
+
3.90
+
3.65
+
+
+
2009-05-01
+
4.15
+
2.73
+
1.97
+
+
+
2010-08-01
+
3.99
+
2.90
+
2.17
+
+
+
2011-05-01
+
5.29
+
4.39
+
3.88
+
+
+
2011-11-01
+
4.59
+
4.14
+
4.35
+
+
+
+
+
If we graph the interest rates we get:
+
+
+
+
You can see a clear peak in the variable rate when the riksbank set
+the repo rate at 500% (mortgages “only” reached 24%). You can also see
+that during the early nineties the variable rate was higher than the
+fixed rates during relatively long periods. But to compare the actual
+cost over the fixed term we have to compare average rates.
+
+
For example, let us compare the actual average rates from the first of
+July 1991 during 5 years for variable rate (11.96%) and 5 years fixed
+term (12.25%). Even though with variable rate you’d have had a rate of
+24% during a quarter you’d still pay less in total over the 5 years.
+
+
If the same calculation is made for every month you can see how much
+you would have earned/lost depending on when you started your fixed
+term. Since 5 years is not evenly divisible by 2 years, the 2 years
+fixed term refers to what the average rate would have been during the
+first 5 of the 6 years.
+
+
+
+
It’s quite clear that variable rate has nearly always been the most
+profitable alternative. At three seperate occasions it would have been
+more profitable to pick a 5 year fixed term: at the of 1989, the
+beginning of 1997, and in the middle of 2005. I won’t comment on the 2
+years fixed term since it’s not a fair comparison to only look at 5 out of
+6 years.
+
+
If we compare 2 years fixed term with variable rate:
+
+
+
+
Also here the most profitable choice is generally the variable rate
+however during times of rising interest rates getting a fixed 2 year
+term has been the better choice on several occasions. An important
+difference to the 5 years term is that you’re not locked in for long
+when the rates finally go down again (and you’re able to switch to
+variable rate).
+
+
If we compare all terms during 10 years:
+
+
+
+
Here it is clear that the variable rate is the most profitable.
+
+
Even though it has been possible at certain occasions (29 years and
+only 3 short occasions!) to get a fixed term for 5 years and pay less
+overall than with variable rate, I think it’s far too improbable that
+one is able to do it at the right time. You’re almost guaranteed to be
+paying more in the end.
+
+
Getting a fixed term for 2 years is more probable to be profitable,
+but even here it is more probable not to be.
+]]>
+
+
+
+ https://cowboyprogrammer.org/2016/10/reduce-colors-in-images/
+
+ Reduce the size of images even further by reducing number of colors with Gimp
+ 2016-10-21T00:27:00+02:00
+
+ Space Cowboy
+ jonas@cowboyprogrammer.org
+
+
+
+
+
+ In Gimp you go to Image in the top menu bar and select Mode
+followed by Indexed. Now you see a popup where you can select the
+number of colors for a generated optimum palette.
+
+
You’ll have to experiment a little because it will depend on your
+image.
+
+
I used this approach to shrink the size of the cover image in
+the_zopfli post from a 37KB (JPG) to just 15KB
+(PNG, all PNG sizes listed include Zopfli compression btw).
+
+
Straight JPG to PNG conversion: 124KB
+
+
+
+
First off, I exported the JPG file as a PNG file. This PNG file had a
+whopping 124KB! Clearly there was some bloat being stored.
+
+
256 colors: 40KB
+
+
Reducing from RGB to only 256 colors has no visible effect to my eyes.
+
+
+
+
128 colors: 34KB
+
+
Still no difference.
+
+
+
+
64 colors: 25KB
+
+
You can start to see some artifacting in the shadow behind the text.
+
+
+
+
32 colors: 15KB
+
+
In my opinion this is the sweet spot. The shadow artifacting is barely
+noticable but the size is significantly reduced.
+
+
+
+
16 colors: 11KB
+
+
Clear artifacting in the text shadow and the yellow (fire?) in the
+background has developed an outline.
+
+
+
+
8 colors: 7.3KB
+
+
The broom has shifted in color from a clear brown to almost grey. Text
+shadow is just a grey blob at this point. Even clearer outline
+developed on the yellow background.
+
+
+
+
4 colors: 4.3KB
+
+
Interestingly enough, I think 4 colors looks better than 8 colors. The outline in the background has disappeared because there’s not enough color spectrum to render it. The broom is now black and filled areas tend to get a white separator to the outlines.
+
+
+
+
2 colors: 2.4KB
+
+
Well, at least the silhouette is well defined at this point I guess.
+
+
+]]>
+
+
+
+ https://cowboyprogrammer.org/2016/10/dont-start-service-on-install-of-debian-package/
+
+ Don't start service on installation of Debian package
+ 2016-10-19T00:00:00+02:00
+
+ Space Cowboy
+ jonas@cowboyprogrammer.org
+
+
+
+
+
+ A clear difference between Debian/Ubuntu and for example Red
+Hat/Fedora is that packages which include system services will enable
+and start those services at install time in Debian/Ubuntu whereas they
+will not start automatically in Red Hat/Fedora.
+
+
Sometimes it would be very convenient if the service would not start
+automatically, for example if you need to configure the service before
+starting it for the first time.
+
+
To prevent the automatic start of system services at install time in
+Debian, just set the RUNLEVEL environment variable like so:
+
+
RUNLEVEL=1 apt install -y PKG_NAME
+
+
+
Then you are free to configure your system before you start the
+service for real:
+]]>
+
+
+
+ https://cowboyprogrammer.org/2016/09/reboot_machine_on_wrong_password/
+
+ Rebooting on wrong password
+ 2016-09-28T22:57:21+02:00
+
+ Space Cowboy
+ jonas@cowboyprogrammer.org
+
+
+
+ Having an encrypted hard drive is all well and good, but chances are
+that if someone is gonna steal your laptop, it’s probably not going to
+be turned off. Most likely, it will be stolen in a powered-on
+state. And so your encrypted hard drive doesn’t increase your security
+at all since it’s currently unlocked.
+
+
In my mind, it’s a slight improvement if the computer somehow can
+shutdown if someone is trying to gain access to it. That way, the hard
+drive is no longer accessible and the number of possible attack
+vectors go down drastically. And so, if you type the wrong password 3
+times on my laptop, it shuts down.
+
+
This is accomplished by using PAM, and its ability to invoke an
+arbitrary script as part of the login flow via pam_exec.so. The
+script itself looks like this:
+
#!/bin/bash
+# Do not add -eu, you need to allow empty variables here!
+
+# To be used with PAM. Look in /etc/pam.d for the script that your
+# screensaver etc uses. Typically it references common-account and common-auth.
+#
+# In common-auth, add this as the first line
+#auth optional pam_exec.so debug /path/to/wrongpassword.sh
+#
+# In common-account, add this as the first line
+#account required pam_exec.so debug /path/to/wrongpassword.sh
+#
+
+COUNTFILE="/var/log/failed_login_count"
+
+# Make sure file exists
+if[ ! -f "${COUNFILE}"];then
+ touch "${COUNTFILE}"
+ chmod 777"${COUNTFILE}"
+fi
+
+# Read value in it
+COUNT=$(cat "${COUNTFILE}")
+# Increment it
+COUNT=$((COUNT+1))
+echo"${COUNT}" > "${COUNTFILE}"
+
+# if authentication
+if["${PAM_TYPE}"=="auth"]; then
+ # The count will be at 4 after 3 wrong tries
+ if["${COUNT}" -ge 4]; then
+ # Shutdown in 1 min
+ #/usr/bin/shutdown --no-wall -h +1
+ # This is a hack because the line above gives a segfault in logind
+ echo"0" > "${COUNTFILE}"
+ systemctl poweroff
+ fi
+# If authentication succeeded, and we are now in account phase
+elif["${PAM_TYPE}"=="account"]; then
+ echo"0" > "${COUNTFILE}"
+ # Cancel shutdown which was just issued
+ shutdown -c
+fi
+
+exit0
+
+
+
On my Debian system, PAM ends up looking at /etc/pam.d/common-auth
+and /etc/pam.d/common-account. These are invoked in different parts
+of the authentication flow. In common-auth, add this as the first
+line:
You can try it immediately if it works. Lock your screen, and type the
+wrong password 4 times. If it works, your computer should shut down.
+
+
WARNING: DO NOT ENABLE ON SERVERS
+
+
This is NOT something you want to do on any machine. Most notably,
+it’s probably a huge mistake to copy this verbatim on a machine which
+accepts remote connections. In that case, you essentially enable
+anyone to DOS you by entering the wrong password via SSH or
+similarly. So don’t do this if you allow remote connections to your
+machine (which shouldn’t be a thing on a laptop).
+]]>
+
+
+
+ https://cowboyprogrammer.org/2016/08/zopfli_all_the_things/
+
+ Compress all the images!
+ 2016-08-26T13:17:40+02:00
+
+ Space Cowboy
+ jonas@cowboyprogrammer.org
+
+
+
+
+
+ Update 2016-11-22: Made the Makefile compatible with BSD sed (MacOS)
+
+
One advantage that static sites, such as those built by Hugo,
+provide is fast loading times. Because there is no processing to be
+done, no server side rendering, no database lookups, loading times are
+just as fast as you can serve the files that make up the page. This
+means that bandwidth becomes the primary bottleneck, which
+incidentally is
+one of the factors used by Google to calculate your search ranking. See
+also
+Pagespeed Insights.
+
+
Compressing images
+
+
Because the largest pieces of a page typically consist of images, it
+stands to reason that if we can make the images smaller, we can make
+the page load faster. Luckily there exists methods that can compress
+images losslessly. That means that the quality stays exactly the
+same, the page only loads faster. That seemed like a no-brainer to me
+so I compressed all the images on the site using PNGout as
+advised by Jeff Atwood. I mean, who doesn’t
+like free bandwidth?
+
+
A new algorithm called Zopfli (open sourced by Google,
+also mentioned by Jeff) claims even better
+results than PNGout though. Results on this site’s images confirm
+those claims. Running the tool on images already compressed by
+PNGout gives output such as this:
+
./zopflipng --prefix="zopfli_" static/images/2014/Dec/Screenshot-from-2014-12-29-13-28-29.png
+Optimizing static/images/2014/Dec/Screenshot-from-2014-12-29-13-28-29.png
+Input size: 89420 (87K)
+Result size: 90361 (88K). Percentage of original: 101.052%
+Preserving original PNG since it was smaller
+
+./zopflipng --prefix="zopfli_" static/images/2014/Jun/Jenkins_install_git.png
+Optimizing static/images/2014/Jun/Jenkins_install_git.png
+Input size: 189406 (184K)
+Result size: 166362 (162K). Percentage of original: 87.834%
+Result is smaller
+
+./zopflipng --prefix="zopfli_" static/images/2014/Jun/jenkins_batch.png
+Optimizing static/images/2014/Jun/jenkins_batch.png
+Input size: 21933 (21K)
+Result size: 16255 (15K). Percentage of original: 74.112%
+Result is smaller
+
+./zopflipng --prefix="zopfli_" static/images/2014/Jun/jenkins_build_step.png
+Optimizing static/images/2014/Jun/jenkins_build_step.png
+Input size: 8184 (7K)
+Result size: 6809 (6K). Percentage of original: 83.199%
+Result is smaller
+
+./zopflipng --prefix="zopfli_" static/images/2014/Jun/jenkins_config_git.png
+Optimizing static/images/2014/Jun/jenkins_config_git.png
+Input size: 57897 (56K)
+Result size: 47164 (46K). Percentage of original: 81.462%
+Result is smaller
+
+
+
The first result in the example output shows a case where Zopfli would
+actually have made the file bigger (because it was already compressed
+by PNGout, remember). This is nothing you have to worry about because
+it’s actually smart enough that it simply copies the original file in
+that case.
+
+
Comparing to both before any compression, and PNGout, yielded the
+following results:
And this is with the default arguments. It is possible squeeze yet a
+couple of more bytes out of this if you’re willing to wait longer.
+
+
Automate it with Make
+
+
Another joy of using a simple static site is that it is possible to
+compose regular tools to do useful things. Tools like
+Make. And we can use Make to build the site, as well as
+compressing images which have not already been compressed. You could
+do it manually for each new image that you add of course but be
+honest, you know that you’re gonna forget to do it at some point. So
+let’s automate it instead!
+
+
This is the Makefile that I use to build this site with, note that
+public depends on $(PNG_SENTINELS), so I literally can’t forget to
+compress any new images added:
+
.PHONY: help build server server-with-drafts clean zopfli
+
+PNG_SENTINELS:=$(shell find . -path ./public -prune -o -name '*.png' -print | sed 's|\(.\+/\)\(.\+.png\)|\1.\2.zopfli|g')
+
+help:## Print this help text
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$'$(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
+
+server:## Run hugo server
+ hugo server
+
+server-with-drafts:## Run hugo server and include drafts
+ hugo server -D
+
+build: public ## Build site (will also compress images using zopfli)
+
+zopfli:$(PNG_SENTINELS)## Compress new images using zopfli
+
+clean:## Remove the built directory
+ @rm -rf public
+
+public:$(PNG_SENTINELS)
+ @rm -rf public
+ hugo
+
+# Zopfli sentinel rule, assumes zopflipng binary is in the same folder
+.%.png.zopfli: %.png
+ ./zopflipng --prefix="zopfli_" $<
+ @mv $(dir $<)zopfli_$(notdir $<) $<
+ @touch $@
+
+
+
For best performance, run make with parallel jobs (change 4 to your
+number CPUs): make -j4 zopfli.
+
+
To know which files have already been compressed without actually
+running Zopfli on it again (which takes a while), sentinel files are
+created with this pattern: .<imgfilename>.zopfli. Thus, the next
+time around, zopfli is only invoked for files which have not already
+been compressed, making it a one-time operation. And when everything
+has already been compressed, you’ll just get this:
+
make: Nothing to be done for 'zopfli'.
+
+]]>
+
+
+
+ https://cowboyprogrammer.org/2016/07/migrating_from_ghost_to_hugo/
+
+ Migrating from Ghost to Hugo
+ 2016-07-25T23:55:38+02:00
+
+ Space Cowboy
+ jonas@cowboyprogrammer.org
+
+
+
+
+
+ So I recently migrated this site from Ghost to Hugo
+after reading a nice article about the Hugo in
+Linux Voice #20 (funnily enough, the same issue also
+features an article about Ghost). I originally made the switch to
+Ghost from Jekyll back in 2014 or so mainly because I could
+not find a good theme to use. Ghost also seemed to have a lot of cool
+features and it’s fun to try new things.
+
+
I think it’s safe to say that I am hardly a prolific blogger. I mainly
+write about stuff which I personally cannot find on the web which I
+think should exist, because I will likely need it myself sometime in
+the future. So it’s hardly a surprise that I am not in the target
+audience for Ghost.
+
+
Things about Ghost which annoy me
+
+
+
It’s written in NodeJS — people who think JS is a good server
+language also tend to think that it’s a good idea to depend on just
+about any package, and download it in every single build. Which
+becomes really funny sometimes.
+
Poor selection of themes — this is subjective of
+course, but it seems to me that the free options don’t have much in
+terms of diversity. Heck, they even call it a marketplace which
+rubs me the wrong way.
+
Themes end up being quite reliant on JS if you want necessary
+features like syntax highlighting on code snippets — I often
+browse with JS disabled and should be able to view my own site.
+
Markdown parser treats newlines as significant — meaning you can’t
+have properly aligned paragraphs in your editor.
+
+
+
That last point irritates me deeply but it’s not as bad as the next point.
+
+
+
You can effectively lock an account by entering the wrong password 3
+times.
+
+
+
This requires some explanation. So Ghost, targeting teams of bloggers
+really, naturally have an account system much like Wordpress. Now, as
+I was surveying the security status of other services I am running, I
+was wondering how Ghost handled someone trying to brute force your
+account and decided to simply try it out. Type the wrong password once
+too many, and this happens:
+
+
+
+
It doesn’t lock it for a single IP address (I tried from several), it
+locks the entire account. Effectively, someone can just set up a
+script to try an account indefinitely simply with the intention to
+block someone from logging in.
+
+
The log doesn’t even show login attempts, so there is no way to
+implement sensible blocking strategies using something like fail2ban.
+
+
The whole thing left a bad taste my mouth so it was a very suitable timing to read an article on Hugo.
+
+
Things about Hugo which excite me
+
+
+
Markdown parser treats newlines correctly
+
It’s a static site generator and not a service — this meant 100MB
+(10%) of RAM became available on my server and there is no account
+to hack (or block).
+
Supports everything of Ghost (that I am aware of).
Can do server side syntax highlighting using Pygments.
+
Some really nice themes are available, and they are
+all free.
+
+
+
Migrating all data from Ghost
+
+
Migrating from Ghost also turned about to be really painless. There
+were several scripts around for exactly this but they all turned out
+to be written in odd languages, and did not actually
+migrate all the metadata in Ghost. So I wrote my own in Python with
+these killer features:
+
+
+
Migrates tags.
+
Migrates dates.
+
Migrates drafts as drafts.
+
Creates aliases in your posts which makes sure that old permalinks
+will still work!
+
Migrates cover pictures as banner images, just select a theme which
+support them.
+
Rewrites all relative links so they all still work (this includes
+images).
+
Code blocks with language definitions like ```language-java
+are changed to ```java.
+
+
#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+'''
+A simple program which migrates an exported Ghost blog to Hugo.
+It assumes your blog is using the hugo-icarus theme, but should
+work for any theme. The script will migrate your posts, including
+tags and banner images. Furthermore, it will make sure that
+all your old post urls will keep working by adding aliases to them.
+
+The only thing you need to do yourself is copying the `images/`
+directory in your ghost directory to `static/images/` in your hugo
+directory. That way, all images will work. The script will rewrite
+all urls linking to `/content/images` to just `/images`.
+'''
+
+importargparse
+importjson
+fromdatetimeimport date
+fromosimport path
+fromcollectionsimport defaultdict
+importre
+
+_post ='''
++++
+date = "{date}"
+draft = {draft}
+title = """{title}"""
+slug = "{slug}"
+tags = {tags}
+banner = "{banner}"
+aliases = {aliases}
++++
+
+{markdown}
+'''
+
+
+defmigrate(filepath, hugodir):
+ '''
+ Parse the Ghost json file and write post files
+ '''
+ withopen(filepath, "r") as fp:
+ ghost = json.load(fp)
+
+ data = ghost['db'][0]['data']
+
+ tags = {}
+ for tag in data["tags"]:
+ tags[tag["id"]] = tag["name"]
+
+ posttags = defaultdict(list)
+
+ for posttag in data["posts_tags"]:
+ posttags[posttag["post_id"]].append(tags[posttag["tag_id"]])
+
+ for post in data['posts']:
+ draft ="true"if post["status"] =="draft"else"false"
+ ts =int(post["created_at"]) /1000
+
+ banner =""if post["image"] isNoneelse post["image"]
+ # /content/ should not be part of uri anymore
+ banner = re.sub("^.*/content[s]?/", "/", banner)
+
+ target = path.join(hugodir, "content/post",
+ "{}.md".format(post["slug"]))
+
+ aliases = ["/{}/".format(post["slug"])]
+
+ print("Migrating '{}' to {}".format(post["title"],
+ target))
+
+ hugopost = _post.format(markdown=post["markdown"],
+ title=post["title"],
+ draft=draft,
+ slug=post["slug"],
+ date=date.fromtimestamp(ts).isoformat(),
+ tags=posttags[post["id"]],
+ banner=banner,
+ aliases=aliases)
+
+ # this is no longer relevant
+ hugopost = hugopost.replace("```language-", "```")
+ # /content/ should not be part of uri anymore
+ hugopost = hugopost.replace("/content/", "/")
+ hugopost = re.sub("^.*/content[s]?/", "/", hugopost)
+
+ withopen(target, 'w') as fp:
+ print(hugopost, file=fp)
+
+
+defmain():
+ parser = argparse.ArgumentParser(
+ description="Migrate an exported Ghost blog to Hugo")
+ req = parser.add_argument_group(title="required arguments")
+ req.add_argument("-f", "--file", help="JSON file exported from Ghost",
+ required=True)
+ req.add_argument("-d", "--dir", help="Directory (root) of Hugo site",
+ required=True)
+
+ args = parser.parse_args()
+
+ migrate(args.file, args.dir)
+
+
+if__name__=="__main__":
+ main()
+
+
+
Next post, I might write about what changes I made to the theme, and
+some nifty Nginx tricks you can use to stay compatible with old links.
+]]>
+
+
+
+ https://cowboyprogrammer.org/2016/05/set-refresh-rate-of-screen-from-script/
+
+ Set refresh rate of screen from script
+ 2016-05-18T00:00:00+00:00
+
+ Space Cowboy
+ jonas@cowboyprogrammer.org
+
+
+
+
+
+ Getting a great new 100 Hz Ultra Wide monitor does not come without its share of tweaking. So it turns out that the refresh you set on your monitor in Nvidia settings (as explained in a previous post does not apply to all the display ports. They apparently count as different screens with different settings or something.
+
+
So, here’s a handy script which you can add to your window manager’s autostart applications to set the refresh rate and resolution of your screen, regardless of which actual port you use:
+
#!/bin/bash -eu
+RES="3440x1440"
+RR="100"
+
+# Do for every output, so that it doesn't matter where you plug in
+# your monitor.
+for output in $(xrandr | grep "DP-" | sed -e "s/\(DP-.\).*/\1/"); do
+ echo"Trying to set mode on $output"
+ if xrandr --output "$output" --mode "$RES" -r "$RR"; then
+ echo"Success: $RES$RR Hz set on $output"
+ fi
+done
+
+
+
It iterates over all the display ports on your graphics card, so it doesn’t matter where you plug your monitor in.
+
+
In XFCE, you’d add this script to Application Autostart:
+
+
+]]>
+
+
+
+ https://cowboyprogrammer.org/2016/04/fixing-the-up-button-in-python-shell-history/
+
+ Fixing the up button in Python shell history
+ 2016-04-02T00:00:00+00:00
+
+ Space Cowboy
+ jonas@cowboyprogrammer.org
+
+
+
+
+
+ In case your python/ipython shell doesn’t have a working history, e.g. pressing ↑ only prints some nonsensical ^[[A, then you are missing either the readline or ncurses library.
+
+
+
+
Ipython is more descriptive that something is wrong, but if you’re in the habit of mostly using python as a quick calculator, you might never notice:
+
+
+]]>
+
+
+
+ https://cowboyprogrammer.org/2016/03/nvidia-gsync-on-linux/
+
+ Nvidia G-Sync and Linux
+ 2016-03-05T00:00:00+00:00
+
+ Space Cowboy
+ jonas@cowboyprogrammer.org
+
+
+
+
+
+ After getting a fancy new monitor with G-Sync support, I was eager to try it out in my Linux gaming setup. While Nvidia fully supports G-Sync in their Linux drivers, it turns out that other components of the system can get in the way. As explained by a post on the Nvidia forums:
+
+
+
For G-SYNC to work, the application has to be able to flip and the symptoms you’re describing here sound like it’s not able to flip in your configuration. There are a variety of reasons why flipping might not be working, but the most likely culprits here are either the compositor getting in the way, or the game not being completely full-screen. The full-screen requirement includes the game being completely unoccluded, so if your window manager is drawing something on top of the game, even just by one pixel, it will prevent flipping. Full-screen also means that it has to cover the entire X screen, which includes both monitors if you have them both enabled.
+
+
Can you please try a different window manager / desktop environment to see if the behavior changes?
+
+
+
Since only a minority of PC-gamers are actually on Linux, and only a minority of those actually have G-Sync capable monitors, Googling for assistance was… challenging. So, for any other Linux gamers out there, here is a short guide on how to enable G-Sync and verify that it works. Some of the steps are XFCE specific, as this is my window manager of choice on my gaming PC. If you are using a different window manager, you’ll have to look through your options to find the equivalent settings.
+
+
Nvidia settings
+
+
+
Sync to VBlank: Optional
+
Allow Flipping: Required
+
Allow G-SYNC: Required
+
Enable G-SYNC Visual Indicator: Optional
+
+
+
The only two required settings are flipping and G-Sync, the others are optional. Enabling Sync to VBlank (VSync) in combination with G-Sync only prevents the GPU from generating an FPS beyond your monitor’s max refresh rate (which you can’t see anyway). It is turned off below the max refresh rate when G-Sync is enabled.
+
+
The visual indicator is useful here to see that G-Sync is working. If all goes well, you should see a green “G-SYNC” text in the corner when running a game.
+
+
+
+
Disable compositor
+
+
As mentioned in the forum post, a compositor will prevent G-Sync from activating because essentially something is rendering above the game. The same reason prevents G-Sync from working in Window mode (unlike Windows, where G-Sync does not require fullscreen).
+
+
For XFCE, go to Window Manager Tweaks under Settings
+
+
+
Then under the Compositor tab, make sure the compositor is disabled
+
+
+
In addition, depending on your setup, make sure you don’t have things like Compton or Compiz enabled.
+
+
Start a game in fullscreen
+
+
As mentioned, you must run the game in fullscreen mode. G-Sync does not work with window mode in Linux.
+
+
I did notice that there are games which do not enable G-Sync. One example is “Cities: Skylines”. So make sure to try several games if you don’t see the G-Sync logo.
+
+
A good candidate here is Dota 2 since it is free to play. Dota 2 running in “Desktop-Friendly Fullscreen” does enable G-Sync. As does Portal 2 and XCOM 2.
+]]>
+
+
+
+ https://cowboyprogrammer.org/2014/12/encrypt-a-btrfs-raid5-array-in-place/
+
+ Encrypt a BTRFS RAID5-array in-place
+ 2014-12-28T00:00:00+00:00
+
+ Space Cowboy
+ jonas@cowboyprogrammer.org
+
+
+
+ When I decided I needed more disk space for media and virtual machine (VM) images, I decided to throw some more money at the problem and get three 3TB hard drives and run BTRFS in RAID5. It’s still somewhat experimental, but has proven very solid for me.
+
+
RAID5 means that one drive can completely fail, but all the data is still intact. All one has to do is insert a new drive and the drive will be reconstructed. While RAID5 protects against a complete drive failure, it does nothing to prevent a single bit to be flipped to due cosmic rays or electricity spikes.
+
+
BTRFS is a new filesystem for Linux which does what ZFS does for BSD. The two important features which it offers over previous systems is: copy-on-write (COW), and bitrot protection. See, when running RAID with BTRFS, if a single bit is flipped, BTRFS will detect it when you try to read the file and correct it (if running in RAID so there’s redundancy). COW means you can take snapshots of the entire drive instantly without using extra space. Space will only be required when stuff change and diverge from your snapshots.
+
+
See Arstechnica for why BTRFS is da shit for your next drive or system.
+
+
What I did not do at the time was encrypt the drives. Linux Voice #11 had a very nice article on encryption so I thought I’d set it up. And because I’m using RAID5, it is actually possible for me to encrypt my drives using dm-crypt/LUKS in-place, while the whole shebang is mounted, readable and usable :)
+
+
Some initial mistakes meant I had to actually reboot the system, so I thought I’d write down how to do it correctly. So to summarize, the goal is to convert three disks to three encrypted disks. BTRFS will be moved from using the drives directly, to using the LUKS-mapped.
+
+
Unmount the raid system (time 1 second)
+
+
Sadly, we need to unmount the volume to be able to “remove” the drive. This needs to be done so the system can understand that the drive has “vanished”. It will only stay unmounted for about a minute though.
+
+
sudo umount /path/to/vol
+
+
+
This is assuming you have configured your fstab with all the details. For example, with something like this (ALWAYS USE UUID!!)
+
+
# BTRFS Systems
+UUID="ac21dd50-e6ee-4a9e-abcd-459cba0e6913" /mnt/btrfs btrfs defaults 0 0
+
+
+
Note that no modification of the fstab will be necessary if you have used UUID.
+
+
Encrypt one of the drives (time 10 seconds)
+
+
Pick one of the drives to encrypt. Here it’s /dev/sdc:
+
+
sudo cryptsetup luksFormat -v /dev/sdc
+
+
+
Open the encrypted drive (time 30 seconds)
+
+
To use it, we have to open the drive. You can pick any name you want:
+
+
sudo cryptsetup luksOpen /dev/sdc DRIVENAME
+
+
+
To make this happen on boot, find the new UUID of /dev/sdc with blkid:
+
+
sudo blkid
+
+
+
+
+
So for me, the drive has a the following UUID:f5d3974c-529e-4574-bbfa-7f3e6db05c65. Add the following line to /etc/crypttab with your desired drive name and your UUID (without any quotes):
The final step is to remove the old drive. We can use the special name missing to remove it:
+
+
sudo btrfs device delete missing /path/to/vol
+
+
+
This can take a really long time, and by long I mean ~15 hours if you have a terrabyte of data. But, you can still use the drive during this process so just be patient.
+
+
+
+
For me it took 14 hours 34 minutes. The reason for the delay is because the delete command will force the system to rebuild the missing drive on your new encrypted volume.
+
+
Next drive, rinse and repeat
+
+
Just unmount the raid, encrypt the drive, add it back and delete the missing. Repeat for all drives in your array. Once the last drive is done, unmount the array and remount it without the -o degraded option. Now you have an encrypted RAID array.
+]]>
+
+
+
+ https://cowboyprogrammer.org/2014/08/making-an-rss-reader-app/
+
+ Making an RSS reader app
+ 2014-08-28T00:00:00+00:00
+
+ Space Cowboy
+ jonas@cowboyprogrammer.org
+
+
+
+ So I’ve been busy building my own RSS reader for the last few weeks. My motivation to make this app is because I got angry at gReader for displaying fullscreen-ads. The source is available on GitHub.
+
+
I started with an idea of targeting Android-L, but because it’s only in preview any app targeting L will be completely incompatible with earler versions. Hence I was forced to refrain from using the new RecyclerView which I really liked. In general I’ve been stealing as much code as possible from the Google-IO app.
+
+
It’s early still, but here are two screenshots of current progress:
+
+
+
+
+
+
To parse RSS feeds I have forked Simplistic-RSS by ShirwaM. To display images I am using Picasso by Square (awesome library). I don’t have any intention of uploading this app to the Play store at this time, at least not until I feel that it is fairly stable and feature complete. I am building it all for myself as this is the only kind of app which I actually use everyday. I figure I can talk about the difficulties that I encounter and how to solve them. So today’s topic will be:
+
+
Displaying formatted text with images
+
+
RSS feeds generally have stories formatted in HTML. For example, see the RSS feed of this blog. This is good because it means all we need to do is decode it and display it. You could use a WebView, but that would be unacceptably ugly and disgusting for an app of mine. A nicer solution is to use a normal TextView. You can actually format HTML easily and display it with:
This simple act gets you most of the way. Here’s what a story looks like with this:
+
+
+
+
+
+
Notice that in the first image, the image is missing and you don’t see that there is a list in the beginning. In the second image, the source code has no special formatting and it’s hard to tell when it starts or stops.
+
+
fromHtml is great, but it is missing functionality to handle some tags. Lucky for us, it is possible to hand it some tagHandlers for those cases. Because I am downloading images, I do the formatting in a background thread using a Loader. To this end I created the ImageTextLoader. What it does instead is:
Where the imageHandler is really simple (notice that I use Picasso to get the image from the network):
+
imgThing =new Html.ImageGetter(){
+ /**
+ * This methos is called when the HTML parser encounters an
+ * <img> tag. The <code>source</code> argument is the
+ * string from the "src" attribute; the return value should be
+ * a Drawable representation of the image or <code>null</code>
+ * for a generic replacement image. Make sure you call
+ * setBounds() on your Drawable if it doesn't already have
+ * its bounds set.
+ *
+ * @param source
+ */
+ @Override
+ public Drawable getDrawable(final String source){
+ Drawable d =null;
+ try{
+ final Bitmap b = Picasso.with(appContext).load(source).get();
+ // Get original size
+ int w = b.getWidth();
+ int h = b.getHeight();
+ // Shrink if big
+ if(w > maxSize.x|| h > maxSize.y){
+ Point newSize = scaleImage(w, h);
+ w = newSize.x;
+ h = newSize.y;
+ }
+ // Need to return a drawable
+ d =new BitmapDrawable(appContext.getResources(), b);
+ d.setBounds(0,0, w, h);
+ }catch(IOException e){
+ Log.e("JONAS",""+ e.getMessage());
+ }
+ return d;
+ }
+ };
+
+
+
The tag handler contains a bit more code, and I won’t paste all of it here. The tags which are handled can be seen in handleTag:
Note that fromHtml only notifies your handler about img-tags when they have ended, so I use that to insert a newline after each image. I would have liked to use it to get the configured size of the image, but that will have to wait for another day. For code-tags, I reduce the size of the text and make it Monospace:
+
// Source code
+privatevoidhandleCode(final Editable text,
+ finalboolean start){
+ // Should be monospace
+ if(start){
+ start(text,new Monospace());
+ start(text,new RelativeSize());
+ }else{
+ end(text, Monospace.class,
+ new TypefaceSpan("monospace"));
+ end(text, RelativeSize.class,
+ new RelativeSizeSpan(0.8f));
+ }
+}
+
+
+
The start and end methods were simply stolen straight from android.Html.
+
+
Result
+
+
Here’s the result using the added tagHandlers:
+
+
+
+
+
+
Handling clicks on links
+
+
Thankfully I had already solved the issue of clickable spans in NoNonsense Notes. See [ReaderFragment]() for this:
+
// Catch clicks on links
+mBodyTextView.setOnTouchListener(new View.OnTouchListener(){
+ @Override
+ publicbooleanonTouch(final View v,
+ final MotionEvent event){
+ TextView widget =(TextView) v;
+ Object text = widget.getText();
+ if(text instanceof Spanned){
+ Spanned buffer =(Spanned) text;
+
+ int action = event.getAction();
+
+ if(action == MotionEvent.ACTION_UP||
+ action == MotionEvent.ACTION_DOWN){
+ int x =(int) event.getX();
+ int y =(int) event.getY();
+
+ x -= widget.getTotalPaddingLeft();
+ y -= widget.getTotalPaddingTop();
+
+ x += widget.getScrollX();
+ y += widget.getScrollY();
+
+ Layout layout = widget.getLayout();
+ int line = layout.getLineForVertical(y);
+ int off = layout.getOffsetForHorizontal(line, x);
+
+ ClickableSpan[] link =
+ buffer.getSpans(off, off, ClickableSpan.class);
+
+ // Cant click to the right of a span,
+ // if the line ends with the span!
+ if(x > layout.getLineRight(line)){
+ // Don't call the span
+ }elseif(link.length!=0){
+ link[0].onClick(widget);
+ returntrue;
+ }
+ }
+ }
+ returnfalse;
+ }
+ });
+
+
+
Thus clicking on links in the TextView will open them in the browser. You could do whatever you want instead of calling link[0].onClick() however.
+
+
That’s it for today. I’ll write more about other pieces of the app soon. Things like how the database is structured or how to use ExpandableListView.
+]]>
+
+
+
+ https://cowboyprogrammer.org/2014/06/building-python-wheels-for-windows/
+
+ Building Python wheels for Windows
+ 2014-06-04T00:00:00+00:00
+
+ Space Cowboy
+ jonas@cowboyprogrammer.org
+
+
+
+
+
+ One group in particular suffers from lack of package management in Windows (as I griped about here): developers. This post will largely be a big howto on how to build Python packages with Fortran/C-extensions (especially Fortran extensions seem problematic on Windows). You’d think that something like that would be clearly explained somewhere. So did I, and I was wrong. So here is my guide to building Python packages with native extensions (both C and Fortran) on Windows.
+
+
Installing Python packages
+
+
The lack of a compiler means most Windows users can’t do what *nix users do when faced with a package containing some c or fortran extensions:
+
+
python setup.py install
+
+
+
Or if it’s publicly available on PyPi for example:
+
+
pip install package
+
+
+
pip will download the source, and on any system with a compiler, compile it, then install it. So it becomes necessary to provide pre-built binaries for Windows users who don’t have a compiler. Something which no one offers a concise explanation of… until now that is. If you upload your package to PyPi, once you have followed this guide, even Windows users will be able to do pip install package.
+
+
1. Set up a Windows machine
+
+
To build Windows binaries you will need access to a Windows machine. If you don’t have a copy of Windows lying around to install in a virtual machine, you can create a free virtual machine on Amazon with Windows Server 2012. Selecting the most basic options will be fine and the machine will be free for atleast a year, at which point you can pay the few dollars per year or register for another free account.
+
+
Another note: make sure to use 64-bit Windows (Server 2012 only comes in 64-bit versions).
+
+
2. Install 32-bit compilers
+
+
Don’t ask me why Microsoft didn’t want to ship the 64-bit compiler together with the 32-bit one… The versions here are final. You cannot use newer compilers. In other words, don’t get Visual Studio 2012 and expect it to work… It’s a simple fact that you need to compile your packages with the same compiler as was used to build Python itself.
+
+
Install Visual C++ 2010 Express (for Python3)
+
+
Python3.3⁄3.4 is built with 2010 and hence all extensions must be as well.
For building Python2.7, 2008 version is required. Google for “Visual C++ 2008 Express” or try this link
+
+
3. Install 64-bit compilers
+
+
Why did you do this Microsoft, why?!
+
+
Install the Windows SDK for Visual Studio 2010 (for Python 3)
+
+
The free Visual C++ 2010 Express compiler does not include 64-bit support. That is what we need the SDK to provide. Google for “Microsoft Windows SDK for Windows 7 and .NET Framework 4” or try this link. You need the Windows 7 SDK even if you are running Windows 8. And make sure it is the version with .NET Framework 4, the one with .NET Framework 3 is for Visual Studio 2008.
+
+
Note: if you have C++ 2010 Redistributables installed, you might have
+to uninstall them first or this install might fail. It might work even if some parts of the installer fails since you only need the compiler bits.
+
+
Install the Windows SDK for Visual Studio 2008 for (Python 2.7)
+
+
Same story for Visual C++ 2008 Express which is used for Python2.7. Find “Microsoft Windows SDK for Windows 7 and .NET Framework 3.5” or try this link
Download both the 32-bit and 64 bit versions. Python2 or Python3 versions do not matter as we will be using conda environments, but you do need both 32-bit and 64-bit versions! During the installation procedure, I recommend you select the following:
+
+
+
Install for current user only (this is the default)
+
Install into: Users\YOURNAME\Anaconda and Users\YOURNAME\Anaconda-64 respectively
+
Do NOT modify the PATH, this will be done explicitly in the build script
+
Do NOT make it the default Python, we need to be able to switch easily
+
+
+
5. Create the environments
+
+
Do this for both the 32-bit and 64-bit versions.
+
+
Open a command line window and navigate to Users\YOURNAME\Anaconda\Scripts (and same for Anaconda-64 later) (Protip: use the file browser to get to the directory then shift-click
+somewhere and select ‘open command line here’).
Remember to repeat that process for the 64-bit/32-bit version as well!
+
+
6. Install git
+
+
This has nothing to do with the build process, but I will assume that you want to do git clone at some point. Download it here. In this case you absolutely DO want it to modify your PATH.
+
+
Actually building stuff
+
+
Believe it or not, but you are actually ready to compile your package. Due to multiple compilers and all that, I have made a bat-file which builds wheels for Python 2.7, 3.3 and 3.4, both for 32-bit and 64-bit:
+
+
+
+
Edit the information at the top. Now assuming everything was installed in the right place, you should just have to double click the bat-file and have built the wheel files which you can then upload to PyPi.
+
+
Building wheels automatically on commits
+
+
Having to do this manually is a drag and so I have also come up with a fully automated solution using Jenkins, a continuous integration system which monitors your git-repo and clones, builds new files as changes are committed.
+
+
Install Jenkins
+
+
Just download the native package from [jenkins-ci.org]().
+
+
Configure Jenkins
+
+
Once Jenkins is installed, it will start itself as a Windows service. Open you web browser and head to [http://localhost:8080](). You then want to go to Manage Jenkins, followed by Manage Plugins:
+
+
+
+
Go to the available tab, and filter on “GIT plugin” (already installed in the screenshot):
+
+
+
+
OK, now go back to the top (click Jenkins in upper left) and create a New Item. You want to select “free-style software project” and give it a name:
+
+
+
+
First thing you need to configure is the git source. Scroll down to Source Code Management, select git, and fill in the repo-address. If you input a public GitHub address you don’t need any credentials:
+
+
+
+
I also recommend you add one Additional behaviour: Clean before checkout to guarantee that builds do not affect each other:
+
+
+
+
Next you can setup the automatic behaviour. The easiest way is to have Jenkins poll GitHub every X minutes and check if there’s a change. Here I have configured Jenkins to check every 15 minutes:
+
+
+
+
So Jenkins knows what to do when it detects a change, you want to add a Build step, specifically Execute a Windows batch file:
+
+
+
+
In the box, just copy paste the batch file I included above. Fill in the paths to your Anaconda installs and set the repo to:
+
+
set PKG_REPO=.
+
+
+
+
+
Jenkins will handle the cloning and simply execute the script in the correct directory. As a final configuration step, tell Jenkins to archive build artifacts under Post-Build Actions since you want to be able to download the wheel files:
+
+
+
+
If you don’t upload wheels to PyPi, then you can install wheels with pip from anywhere with:
Now you’re all done. You can manually trigger builds in the left menu. Each build will have links for you to download the wheelfiles and the job’s main page will always display the links to the latest artifacts.
+
+
+
+
There are so many plugins and options available for Jenkins so play around if you want even more stuff. Some things you can do include:
+
+
+
Automatically uploading artifacts to an FTP/SSH-server.
+
Sending E-mail notifications on success/failures.
+
Build only specific branches/tags.
+
Make the server public and tie login to GitHub accounts.
+]]>
+
+
+
+ https://cowboyprogrammer.org/2014/05/people-have-been-trained-to-install-malware/
+
+ People have been trained to install malware
+ 2014-05-11T00:00:00+00:00
+
+ Space Cowboy
+ jonas@cowboyprogrammer.org
+
+
+
+ disclaimer: I get angry when I have to fix Windows. Expect explicit content. You have been warned.
+
+
Being computer literate can be something of a curse. Anyone with even the most rudimentary skill set has probably sometime gotten asked if they could help someone with their computer. The other day I got asked if I could help, let’s call him Roger, as he was having some problems with Windows Update. The initial symptoms could be clearly relayed by Roger:
+
+
+
The update gets to 30% then it just stops and reboots.
+
+
+
First step is always to recreate the problem and see it in action. No problem there. Telling Windows Update to proceed resulted in precisely the described result, after a fair bit of time waiting for a frigging restore point to be created. I’d read about the failing 8.1 upgrade so I half expected it to be Microsoft’s fault, even though this machine was running Windows 7. Roger didn’t need anything from the update so worst case I thought, I’ll just disable Windows Update entirely.
+
+
First things first
+
+
Once I had confirmed that there was a problem, I begun by clearing out various crapware that was installed, mainly different kinds of toolbars and some video player that seemed to be a repackaging of VLC mainly. It’s hard to see why this software is installed or where it came from. Roger uses only Word and the browser. I figure he’s the sort that clicks on various malicious ads for some reason. At least Roger has been coerced into using Chrome instead of IE…
+
+
So I uninstall everything I don’t recognize and reboot, because rebooting is something you do a lot in Windows land… OK, maybe the update will work now without all the crap installed. It’s worth a try at least.
+
+
+
Initiate the update… It creates a restore point… wait… wait… wait…. Reboot. Update still fails at 30%.
+
+
+
Trial and error
+
+
Now the real work begins. Maybe Microsoft screwed up their patches or something? There were 5 security patches waiting to be installed so let’s try them one by one.
+
+
+
First one fails.
+
Second one fails.
+
Everyone but the first and second one fails.
+
+
+
OK… Let’s just do the damn IE patches first. They also fail. And for every try, I’m forced to wait for Windows to create another damn restore point which takes several minutes. This on an almost brand new Intel NUC with an SSD.
+
+
Bored…
+
+
While waiting for the damn restore points, I am seriously considering if I can just wipe the machine and force Roger to use Linux instead. All he needs is Word. So I decide to download LibreOffice and see how their docx support is these days. Downloading 200MB takes a while on the effectively 2MBit connection. Still quicker than the now cancelled restore point. So I click through the installer, get to the progress bar, and wait. And wait. And wait.
+
+
+
Why the fuck isn’t the progress bar moving?
+
+
+
Instinctively, I open the task manager to see what the hold up is. Apparently nothing. No CPU is being used. No memory is consumed. It’s an SSD so disk speed is not an issue. Change to the services tab and same thing, nothing obvious. I try disabling the antivirus (Microsoft’s own so should be compatible right?). Good try chump, still no difference.
+
+
Second time in the task manager, I notice something though. A service which doesn’t really sound very official: safetynut. I find out where safetynut.exe lives and sure enough, it lives in something like:
+
+
+
C:\Program Files (x86)\Movie Toolbar\Safetynut
+
+
+
But I uninstalled that! Fine.. End process. To which Windows replies:
+
+
+
You don’t have permission to end this process
+
+
+
W T F
+
+
OK computer, I’m going to stop you right there. I am the administrator. I am your GOD. And as said deity, I command you to end that process!
+
+
+
God or no god, you still don’t have permission to do that
+
+
+
OK, fine, be that way. Delete C:\Program Files (x86)\Movie Toolbar\Safetynut.
+
+
+
Could not delete safetynut.dll as it is in use
+
+
+
Shaka, when the walls fell…
+
+
It’s an amazing “feature” in Windows that a program can lock a file and thus prevent you from deleting it. It’s also an amazing “feature” that the administrator can be refused the permission to do something. No recourse left but to reboot into safe mode.
+
+
To safe mode we go!
+
+
First, I go into the normal safe mode with a desktop. Still can’t delete the dll file though as it is “in use”. Time to open regedit and delete all references to safetynut from the registry. Search, delete. Rince, repeat…
+
+
Next reboot to safe mode with only a command line window. Navigate to the folder and delete the file and the folder, then reboot.
+
+
Success!
+
+
No more safetynut. Let’s try Windows Update again. Ooh, that’s a mighty fast restore point creation! And the update succeeds!
+
+
So apparently, safetynut was actively preventing Windows Update from proceeding. Roger promptly got a stern talking to about installing any software or clicking on ads/popups (I also installed adblock plus in Chrome). But it got me thinking about malware in general..
+
+
Most people are trained to install malware
+
+
In my view, none of this is the user’s fault. The fact is that Microsoft has trained everyone to install shitty software from untrusted sources. Let’s go back a few years, to the days of yore, in the time of Windows 98 and Windows 2000. If you reinstalled Windows back then, and I did a lot, then you very quickly got a routine for downloading the software you needed once Windows was installed.
+
+
First obvious things to install were the drivers for your network card, sound card and graphics card. You even possibly needed to install SATA-drivers during the actual install or the installer wouldn’t find your disk. If you did not have that on a floppy, you were screwed. But OK, you had your floppy, and you had your drivers on CD. Next you needed:
+
+
+
A browser, because Internet Explorer is still a gaping security hole
+
A firewall, because even up to XP, being exposed to the internet directly meant instant infection
+
Antivirus, anything that wasn’t Norton would do…
+
PDF-reader
+
zip/rar-extractor
+
+
+
I’d like to draw your attention to the last item. Something so mundane as a zip-extractor was not built in to Windows. XP was the first version (if I remember correctly) to include a built in zip-extractor. This specific flaw trained everyone to download Winzip or Winrar. Quite possibly, they would resort to getting a pirated serial key as well. The problem? Now users are trained to go to any website their 10-year old neighborhood tech support kid tells them to and click Download.
+
+
Here’s a screenshot of the pirate bay to illustrate (to clarify, do NOT download your software from torrent sites. It’s just an example of this behavior). The big download buttons will lead to ads, online poker or who knows. We can be quite sure that they will lead to endless evil. On the internet, never FUCKING EVER press a big styled button with the text “Download”. The link you want is the smaller green text: “get this torrent”.
+
+
+
+
Now, assuming you managed to avoid the big download buttons to download your program, you have your completely unverified .exe file or .msi file, you double click on it, and what do you get? More fucking bullshit. Here’s a screenshot of the Winzip (totally unnecessary program today) installer. Right after you agree to the Winzip Terms of Service, you get another license agreement.
+
+
+
+
How the screaming fuck are ordinary users supposed to understand that pressing Next will lead to untold horrors and pressing Decline is the way to install the software they want? They won’t of course. That’s the whole point!
+
+
I bet this is the source of 99% of all malware on Windows. And the problem is that this is a perfectly acceptable way of getting software. Macs have the same problem to some minor extent. They are also being trained to download strange files from various pages. It is NOT accepted on Linux. The reason you don’t need antivirus on Linux is not because the system is more secure. All software is brittle and insecure. The vital difference is in how Linux users get their software.
+
+
The way it should be
+
+
Here’s a screenshot of the package manager in Debian:
+
+
+
+
Now let’s say I need a c++ compiler and one was not installed already. I search for “c++ compiler” and there’s clang. To this day, I have no idea how I can get a compiler on Windows.
+
+
+
+
Installing 99% of all software is super easy and reliable on Linux. All of these packages have been checked by the people working on the distro. If any package were to install a toolbar or other malware, you can bet your ass that it would be removed from the official sources. And because this is how Linux users are trained to install their software, they will have some degree of suspicion against download links on unknown websites. Installing malware becomes notably harder than installing good software.
+
+
The coming app stores
+
+
Both OS X and Windows are trying to push their users to use their “app stores”. While I have many negative things to say about them, they should hopefully reduce the included malware problem and train users to only install garbage from trusted sources.
+]]>
+
+
+
+ https://cowboyprogrammer.org/2014/04/are-ipads-retarding-us/
+
+ Are iPads actually a step back?
+ 2014-04-26T00:00:00+00:00
+
+ Space Cowboy
+ jonas@cowboyprogrammer.org
+
+
+
+ Think what you will of the iPad, but it has been a huge success for Apple and people love it. It’s one of the few products that appealed (past-tense) to both the geeks and _hoi polloi_.
+
+
I remember watching the keynote where the iPhone was introduced and immediately I thought that’s the pad from Star-Trek TNG! I had to have it.
+Apple’s initial carrier exclusivity deals meant I had to wait for the iPhone 3g. Not only that, but because they partnered with a company I am sworned to destroy, I payed a guy in Italy to buy one unlocked and ship it to me for 7500SEK. Funny thing is that at the time I was a developer at Sony Ericsson, who did not think kindly of Apple entering their mobile domain. I got a lot of weird looks a work…
+
+
Then came the iPad. Again I’m thinking holy shit that’s awesome. At this point I had upgraded to an Android device (an HTC Legend) and had come to the conclusion that Android was far more interesting as a platform because of Apple’s restrictions on what apps can do. The customizability and capabilities on Android were far greater and as a developer, you appreciate that. However, there were no Android tablets. And there wouldn’t be for a long time.
+
+
I kept my cool though and managed to hold on to my money until the iPad 2 was released. I left early from work and lined up with other enthusiasts at the electronics store. At the time most people had no idea what they were going to do with it, me included, but I had to have it. I think my extended family clearly demonstrates how successful a product the iPad was. That same year I saw 3 iPads being gifted away (3 in a group of around 9 people!). By the next year, 3 more iPads were acquired. Everyone had to have one. It was one of those cases where you don’t get it until you see it for yourself.
+
+
From revolutionary to evolutionary
+
+
It is both a sign of how good the original product was and how little has changed that I never felt a reason to upgrade from the iPad 2.
+
+
+
The battery life was fantastic.
+
The screen size just right.
+
The resolution was good enough.
+
The speed was fine (until recently).
+
+
+
Hardware-wise, it was feature complete. The rest could be fixed in software. They never did though. The problem is iOS. Just as I abandoned the iPhone for Android, I now abandoned the iPad for a Nexus 7. There was so much potential being held back by the limitations of iOS.
+Stratechery explains some of my frustrations well. He means it as a defense in iOS’s favor though. But there is actually more to it than the limitations of iOS. Something inherent in the touch screen and the current mobile paradigm.
+
+
Limitations of the touch screen
+
+
I was playing R-Type 2 on my Nexus 10 and kept dying on the boss in the second level. And I realized that while I might get lucky and finish the level, I would never be able to play the game well due to the touch screen.
+
+
+
+
See, R-Type is a classic side-scrolling shoot-em-up. You pilot a spaceship and have to avoid enemy fire, hordes of enemies, and not crash into the roof or ceiling. It is a game based entirely on mastering the controls. You can see a good example of what I mean in this clip of a similar game called Gradius for the NES.
+
+
+
+
The problem I was having was that I kept crashing into the floor as I tried to manouever around the boss. Having played for and hour or two (and still being stuck on level 2!) I came to realize that it wasn’t I that sucked, it was the controls. I had reached the limit of what was possible (precision-wise) with a touch screen.
+
+
Noobs forever
+
+
And this is where the back-stepping begins. Growing up with NES, SNES, and a PC, I remember many older relatives noting the dexterity and precision in the thumbs of kids due to all the gaming. Video games required:
+
+
+
hand-eye-coordination
+
hand dexterity
+
concentration
+
+
+
To beat these games you needed mastery and focus. Not only was mastery required, it was the reward. The games suitable for touch screens can require neither. So tablet games will remain at a level no more advanced than snake or scrabble. (As a side note, what really can work is turn-based strategy games.)
+
+
No such thing as a touch typist
+
+
Just as serious gaming becomes impossible due to the touch interface, serious productivity suffers from the same limitations. It’s funny to see things like Microsoft Office being released for the iPad because it’s impossible to work with. Serious productivity requires the efficient inputting of language, be it English or Python. The touch keyboard is unable to let you do that. There is no such thing as a touch typist. On a tablet, everyone goes back to tapping with two fingers. There is nothing to master here (due to the lack of feedback) and so everyone will remain as noobs forever.
+
+
The dark age begins
+
+
Maybe you’re thinking to yourself:
+
+
+
so what if a touch screen isn’t ideal for everything, no input device is!
+
+
+
If you are, then I agree. Nothing can be great at everything. You use the right tool for the right job. The problem is the tremendous success of the tablet. This is where I think the geeks have a different view of where we are headed.
+
+
Geeks see the benefits of the touch screen. Its strengths, but also its weaknesses. They use it when it’s convenient. For more serious work, they move to their workstation, with keyboard and screen.
+
+
Non-geeks see the tablet as “the future”. They never liked their PC to begin with. It was just something they were forced to acquire to be able to pay their bills. They see the tablet as liberating. Geeks see the tablet as confining.
+
+
The success of the tablet amongst geeks and non-geeks combined means companies are scrambling to push everything into tablet interfaces. Apple is clearly moving towards iOS as OSX is evolving. Microsoft has already gone too far:
+
+
+
+
+
But it’s not just the tablet interface. It’s the whole mobile paradigm that is spreading. With it comes the app stores, where every app is pre-approved by the benevolent corporation that owns your soul apps and music. The corporation reserves the right to remove any app or in-app purchase it deems unworthy of your attention. Amazon did it, Apple does it all the time, and same for Microsoft.
+]]>
+
+
+
+ https://cowboyprogrammer.org/2014/04/advertising-thats-not-intrusive-orly/
+
+ Advertising, that's not intrusive. Orly?
+ 2014-04-07T00:00:00+00:00
+
+ Space Cowboy
+ jonas@cowboyprogrammer.org
+
+
+
+ When you have apps in Google Play (and I imagine, other App stores as well), the amount of spam you receive instantly goes up by a factor of 10. Google’s spam filters are pretty well trained but every now and again something gets through.
+
+
Advertising opportunity
+
+
Today’s piece of bullshyt (I really meant to spell it like that) reads as follows (my emphasis):
+
+
+
Our premium advertisers are currently looking to buy android traffic at a very high price in apps like Nononsense Notes.
+
+
We think you can generate up to $10 CPM with their full screen ads, which are very clean. Indeed, most of our advertisers are willing to pay, on average, between $1 and $3 per installation. You’re free to display these ads whenever you want in your app so that it’s not intrusive.
+
+
+
Ads are by definition intrusive. That’s how they nag you into buying their stupid stuff. And it doesn’t matter how clean your ads are. Displaying them fullscreen is beyond intrusive. It is down right offensive.
+
+
I uninstall anything that displays obnoxious ads, be they fullscreen or notifications, and promptly give the app a one star review. I sincerely hope others afford me the same “courtesy” for my apps.
+]]>
+
+
+
diff --git a/app/src/androidTest/resources/com/nononsenseapps/feeder/model/cowboyprogrammer_feed.json b/app/src/androidTest/resources/com/nononsenseapps/feeder/model/cowboyprogrammer_feed.json
new file mode 100644
index 0000000..27a120e
--- /dev/null
+++ b/app/src/androidTest/resources/com/nononsenseapps/feeder/model/cowboyprogrammer_feed.json
@@ -0,0 +1,103 @@
+{
+ "version": "https://jsonfeed.org/version/1",
+ "title": "Cowboy Programmer",
+ "home_page_url": "https://cowboyprogrammer.org/",
+ "author": {
+ "name": "Space Cowboy",
+ "avatar": "https://cowboyprogrammer.org/css/images/avatar.png"
+ },
+ "icon": "https://cowboyprogrammer.org/css/images/logo.png",
+
+ "items": [
+
+ {
+ "id": "https://cowboyprogrammer.org/2018/03/fixed-vs-variable-interest-rates/",
+ "url": "https://cowboyprogrammer.org/2018/03/fixed-vs-variable-interest-rates/",
+ "title": "A comparison between fixed and variable interest rates",
+ "content_html": "\u003cp\u003eThe data I am using is originally from \u003ca href=\"http://hypotek.swedbank.se/rantor/historiska-rantor/\"\u003eSwedBank\u003c/a\u003e and all data and\ncode is available at \u003ca href=\"https://gitlab.com/spacecowboy/swedish-interest-rates\"\u003eGitLab\u003c/a\u003e. \u003ca href=\"https://gitlab.com/spacecowboy/swedish-interest-rates/raw/master/swedish_interest_rates.csv\"\u003eThe data\u003c/a\u003e contains interest\nrates at 5 years fixed term, 2 years fixed term, and 3 months fixed\nterm (also called variable rate in Sweden) for those dates when any\nrate was changed. The first rates are from 1989-11-01 and the last are\nfrom 2018-02-12. Example of the data:\u003c/p\u003e\n\n\u003ctable border=\"1\" class=\"dataframe\"\u003e\n \u003cthead\u003e\n \u003ctr style=\"text-align: right;\"\u003e\n \u003cth\u003e\u003c/th\u003e\n \u003cth\u003e5y\u003c/th\u003e\n \u003cth\u003e2y\u003c/th\u003e\n \u003cth\u003e3m\u003c/th\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003eDate\u003c/th\u003e\n \u003cth\u003e\u003c/th\u003e\n \u003cth\u003e\u003c/th\u003e\n \u003cth\u003e\u003c/th\u003e\n \u003c/tr\u003e\n \u003c/thead\u003e\n \u003ctbody\u003e\n \u003ctr\u003e\n \u003cth\u003e1989-11-22\u003c/th\u003e\n \u003ctd\u003e13.50\u003c/td\u003e\n \u003ctd\u003e13.50\u003c/td\u003e\n \u003ctd\u003e12.75\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e1991-01-14\u003c/th\u003e\n \u003ctd\u003e14.00\u003c/td\u003e\n \u003ctd\u003e14.75\u003c/td\u003e\n \u003ctd\u003e15.25\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e1993-01-13\u003c/th\u003e\n \u003ctd\u003e12.75\u003c/td\u003e\n \u003ctd\u003e13.00\u003c/td\u003e\n \u003ctd\u003e13.75\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e1994-11-21\u003c/th\u003e\n \u003ctd\u003e11.75\u003c/td\u003e\n \u003ctd\u003e11.50\u003c/td\u003e\n \u003ctd\u003e9.75\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e1996-03-12\u003c/th\u003e\n \u003ctd\u003e9.85\u003c/td\u003e\n \u003ctd\u003e8.95\u003c/td\u003e\n \u003ctd\u003e9.10\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2005-09-09\u003c/th\u003e\n \u003ctd\u003e3.55\u003c/td\u003e\n \u003ctd\u003e2.97\u003c/td\u003e\n \u003ctd\u003e3.15\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2005-10-03\u003c/th\u003e\n \u003ctd\u003e3.69\u003c/td\u003e\n \u003ctd\u003e3.09\u003c/td\u003e\n \u003ctd\u003e3.15\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2007-12-21\u003c/th\u003e\n \u003ctd\u003e5.36\u003c/td\u003e\n \u003ctd\u003e5.25\u003c/td\u003e\n \u003ctd\u003e5.15\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2008-01-24\u003c/th\u003e\n \u003ctd\u003e5.13\u003c/td\u003e\n \u003ctd\u003e4.94\u003c/td\u003e\n \u003ctd\u003e5.15\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2009-03-20\u003c/th\u003e\n \u003ctd\u003e4.26\u003c/td\u003e\n \u003ctd\u003e2.83\u003c/td\u003e\n \u003ctd\u003e2.20\u003c/td\u003e\n \u003c/tr\u003e\n \u003c/tbody\u003e\n\u003c/table\u003e\n\n\u003cp\u003eTo make the calculations more convenient I assume that loans are only\nfixed the first day of the month. Example:\u003c/p\u003e\n\n\u003ctable border=\"1\" class=\"dataframe\"\u003e\n \u003cthead\u003e\n \u003ctr style=\"text-align: right;\"\u003e\n \u003cth\u003e\u003c/th\u003e\n \u003cth\u003e5y\u003c/th\u003e\n \u003cth\u003e2y\u003c/th\u003e\n \u003cth\u003e3m\u003c/th\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003eDate\u003c/th\u003e\n \u003cth\u003e\u003c/th\u003e\n \u003cth\u003e\u003c/th\u003e\n \u003cth\u003e\u003c/th\u003e\n \u003c/tr\u003e\n \u003c/thead\u003e\n \u003ctbody\u003e\n \u003ctr\u003e\n \u003cth\u003e1990-06-01\u003c/th\u003e\n \u003ctd\u003e14.50\u003c/td\u003e\n \u003ctd\u003e14.50\u003c/td\u003e\n \u003ctd\u003e13.95\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e1992-03-01\u003c/th\u003e\n \u003ctd\u003e12.50\u003c/td\u003e\n \u003ctd\u003e13.00\u003c/td\u003e\n \u003ctd\u003e14.75\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e1993-06-01\u003c/th\u003e\n \u003ctd\u003e10.75\u003c/td\u003e\n \u003ctd\u003e10.50\u003c/td\u003e\n \u003ctd\u003e11.50\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e1998-02-01\u003c/th\u003e\n \u003ctd\u003e6.70\u003c/td\u003e\n \u003ctd\u003e6.40\u003c/td\u003e\n \u003ctd\u003e5.80\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2001-09-01\u003c/th\u003e\n \u003ctd\u003e6.55\u003c/td\u003e\n \u003ctd\u003e5.95\u003c/td\u003e\n \u003ctd\u003e5.90\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2004-11-01\u003c/th\u003e\n \u003ctd\u003e4.85\u003c/td\u003e\n \u003ctd\u003e3.90\u003c/td\u003e\n \u003ctd\u003e3.65\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2009-05-01\u003c/th\u003e\n \u003ctd\u003e4.15\u003c/td\u003e\n \u003ctd\u003e2.73\u003c/td\u003e\n \u003ctd\u003e1.97\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2010-08-01\u003c/th\u003e\n \u003ctd\u003e3.99\u003c/td\u003e\n \u003ctd\u003e2.90\u003c/td\u003e\n \u003ctd\u003e2.17\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2011-05-01\u003c/th\u003e\n \u003ctd\u003e5.29\u003c/td\u003e\n \u003ctd\u003e4.39\u003c/td\u003e\n \u003ctd\u003e3.88\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2011-11-01\u003c/th\u003e\n \u003ctd\u003e4.59\u003c/td\u003e\n \u003ctd\u003e4.14\u003c/td\u003e\n \u003ctd\u003e4.35\u003c/td\u003e\n \u003c/tr\u003e\n \u003c/tbody\u003e\n\u003c/table\u003e\n\n\u003cp\u003eIf we graph the interest rates we get:\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2018/03/rates.en.png\" alt=\"Interest rates over time\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eYou can see a clear peak in the variable rate when the riksbank set\nthe repo rate at 500% (mortgages \u0026ldquo;only\u0026rdquo; reached 24%). You can also see\nthat during the early nineties the variable rate was higher than the\nfixed rates during relatively long periods. But to compare the actual\ncost over the fixed term we have to compare average rates.\u003c/p\u003e\n\n\u003cp\u003eFor example, let us compare the actual average rates from the first of\nJuly 1991 during 5 years for variable rate (11.96%) and 5 years fixed\nterm (12.25%). Even though with variable rate you\u0026rsquo;d have had a rate of\n24% during a quarter you\u0026rsquo;d still pay less in total over the 5 years.\u003c/p\u003e\n\n\u003cp\u003eIf the same calculation is made for every month you can see how much\nyou would have earned/lost depending on when you started your fixed\nterm. Since 5 years is not evenly divisible by 2 years, the 2 years\nfixed term refers to what the average rate would have been during the\nfirst 5 of the 6 years.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2018/03/5y_avg_rates.en.png\" alt=\"Average interest rate over 5 years\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eIt\u0026rsquo;s quite clear that variable rate has nearly always been the most\nprofitable alternative. At three seperate occasions it would have been\nmore profitable to pick a 5 year fixed term: at the of 1989, the\nbeginning of 1997, and in the middle of 2005. I won\u0026rsquo;t comment on the 2\nyears fixed term since it\u0026rsquo;s not a fair comparison to only look at 5 out of\n6 years.\u003c/p\u003e\n\n\u003cp\u003eIf we compare 2 years fixed term with variable rate:\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2018/03/2y_avg_rates.en.png\" alt=\"Average interest rate over 2 years\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eAlso here the most profitable choice is generally the variable rate\nhowever during times of rising interest rates getting a fixed 2 year\nterm has been the better choice on several occasions. An important\ndifference to the 5 years term is that you\u0026rsquo;re not locked in for long\nwhen the rates finally go down again (and you\u0026rsquo;re able to switch to\nvariable rate).\u003c/p\u003e\n\n\u003cp\u003eIf we compare all terms during 10 years:\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2018/03/10y_avg_rates.en.png\" alt=\"Average interest rate over 10 years\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eHere it is clear that the variable rate is the most profitable.\u003c/p\u003e\n\n\u003cp\u003eEven though it has been possible at certain occasions (29 years and\nonly 3 short occasions!) to get a fixed term for 5 years and pay less\noverall than with variable rate, I think it\u0026rsquo;s far too improbable that\none is able to do it at the right time. You\u0026rsquo;re almost guaranteed to be\npaying more in the end.\u003c/p\u003e\n\n\u003cp\u003eGetting a fixed term for 2 years is more probable to be profitable,\nbut even here it is more probable not to be.\u003c/p\u003e\n",
+ "date_published": "2018-03-05T23:00:00+02:00",
+ "image": "https://cowboyprogrammer.org/images/2018/03/5y_avg_rates.en.png"
+ }
+
+ , {
+ "id": "https://cowboyprogrammer.org/2016/10/reduce-colors-in-images/",
+ "url": "https://cowboyprogrammer.org/2016/10/reduce-colors-in-images/",
+ "title": "Reduce the size of images even further by reducing number of colors with Gimp",
+ "content_html": "\n\n\u003cp\u003eIn Gimp you go to \u003cem\u003eImage\u003c/em\u003e in the top menu bar and select \u003cem\u003eMode\u003c/em\u003e\nfollowed by \u003cem\u003eIndexed\u003c/em\u003e. Now you see a popup where you can select the\nnumber of colors for a generated optimum palette.\u003c/p\u003e\n\n\u003cp\u003eYou\u0026rsquo;ll have to experiment a little because it will depend on your\nimage.\u003c/p\u003e\n\n\u003cp\u003eI used this approach to shrink the size of the cover image in\n\u003ca href=\"/2016/08/zopfli_all_the_things/\"\u003ethe_zopfli post\u003c/a\u003e from a 37KB (JPG) to just 15KB\n(PNG, all PNG sizes listed include Zopfli compression btw).\u003c/p\u003e\n\n\u003ch2 id=\"straight-jpg-to-png-conversion-124kb\"\u003eStraight JPG to PNG conversion: 124KB\u003c/h2\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2017/10/zopfli_all_the_things.png\" alt=\"PNG version RGB colors\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eFirst off, I exported the JPG file as a PNG file. This PNG file had a\nwhopping 124KB! Clearly there was some bloat being stored.\u003c/p\u003e\n\n\u003ch2 id=\"256-colors-40kb\"\u003e256 colors: 40KB\u003c/h2\u003e\n\n\u003cp\u003eReducing from RGB to only 256 colors has no visible effect to my eyes.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2017/10/zopfli_all_the_things_256.png\" alt=\"256 colors\" /\u003e\u003c/p\u003e\n\n\u003ch2 id=\"128-colors-34kb\"\u003e128 colors: 34KB\u003c/h2\u003e\n\n\u003cp\u003eStill no difference.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2017/10/zopfli_all_the_things_128.png\" alt=\"128 colors\" /\u003e\u003c/p\u003e\n\n\u003ch2 id=\"64-colors-25kb\"\u003e64 colors: 25KB\u003c/h2\u003e\n\n\u003cp\u003eYou can start to see some artifacting in the shadow behind the text.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2017/10/zopfli_all_the_things_64.png\" alt=\"64 colors\" /\u003e\u003c/p\u003e\n\n\u003ch2 id=\"32-colors-15kb\"\u003e32 colors: 15KB\u003c/h2\u003e\n\n\u003cp\u003eIn my opinion this is the sweet spot. The shadow artifacting is barely\nnoticable but the size is significantly reduced.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2017/10/zopfli_all_the_things_32.png\" alt=\"32 colors\" /\u003e\u003c/p\u003e\n\n\u003ch2 id=\"16-colors-11kb\"\u003e16 colors: 11KB\u003c/h2\u003e\n\n\u003cp\u003eClear artifacting in the text shadow and the yellow (fire?) in the\nbackground has developed an outline.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2017/10/zopfli_all_the_things_16.png\" alt=\"16 colors\" /\u003e\u003c/p\u003e\n\n\u003ch2 id=\"8-colors-7-3kb\"\u003e8 colors: 7.3KB\u003c/h2\u003e\n\n\u003cp\u003eThe broom has shifted in color from a clear brown to almost grey. Text\nshadow is just a grey blob at this point. Even clearer outline\ndeveloped on the yellow background.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2017/10/zopfli_all_the_things_8.png\" alt=\"8 colors\" /\u003e\u003c/p\u003e\n\n\u003ch2 id=\"4-colors-4-3kb\"\u003e4 colors: 4.3KB\u003c/h2\u003e\n\n\u003cp\u003eInterestingly enough, I think 4 colors looks better than 8 colors. The outline in the background has disappeared because there\u0026rsquo;s not enough color spectrum to render it. The broom is now black and filled areas tend to get a white separator to the outlines.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2017/10/zopfli_all_the_things_4.png\" alt=\"4 colors\" /\u003e\u003c/p\u003e\n\n\u003ch2 id=\"2-colors-2-4kb\"\u003e2 colors: 2.4KB\u003c/h2\u003e\n\n\u003cp\u003eWell, at least the silhouette is well defined at this point I guess.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2017/10/zopfli_all_the_things_2.png\" alt=\"2 colors\" /\u003e\u003c/p\u003e\n",
+ "date_published": "2016-10-21T00:27:00+02:00",
+ "image": "https://cowboyprogrammer.org/images/2017/10/gimp_image_mode_index.png"
+ }
+
+ , {
+ "id": "https://cowboyprogrammer.org/2016/10/dont-start-service-on-install-of-debian-package/",
+ "url": "https://cowboyprogrammer.org/2016/10/dont-start-service-on-install-of-debian-package/",
+ "title": "Don't start service on installation of Debian package",
+ "content_html": "\u003cp\u003eA clear difference between Debian/Ubuntu and for example Red\nHat/Fedora is that packages which include system services will enable\nand start those services at install time in Debian/Ubuntu whereas they\nwill not start automatically in Red Hat/Fedora.\u003c/p\u003e\n\n\u003cp\u003eSometimes it would be very convenient if the service would \u003cem\u003enot\u003c/em\u003e start\nautomatically, for example if you need to configure the service before\nstarting it for the first time.\u003c/p\u003e\n\n\u003cp\u003eTo prevent the automatic start of system services at install time in\nDebian, just set the \u003ccode\u003eRUNLEVEL\u003c/code\u003e environment variable like so:\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003eRUNLEVEL=1 apt install -y PKG_NAME\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003cp\u003eThen you are free to configure your system before you start the\nservice for real:\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003esystemctl enable PKG_NAME\nsystemctl start PKG_NAME\n\u003c/code\u003e\u003c/pre\u003e\n",
+ "date_published": "2016-10-19T00:00:00+02:00",
+ "image": "https://cowboyprogrammer.org/images/Ardebian_logo_512_0.png"
+ }
+
+ , {
+ "id": "https://cowboyprogrammer.org/2016/09/reboot_machine_on_wrong_password/",
+ "url": "https://cowboyprogrammer.org/2016/09/reboot_machine_on_wrong_password/",
+ "title": "Rebooting on wrong password",
+ "content_html": "\n\n\u003cp\u003eHaving an encrypted hard drive is all well and good, but chances are\nthat if someone is gonna steal your laptop, it\u0026rsquo;s probably not going to\nbe turned off. Most likely, it will be stolen in a powered-on\nstate. And so your encrypted hard drive doesn\u0026rsquo;t increase your security\nat all since it\u0026rsquo;s currently unlocked.\u003c/p\u003e\n\n\u003cp\u003eIn my mind, it\u0026rsquo;s a slight improvement if the computer somehow can\nshutdown if someone is trying to gain access to it. That way, the hard\ndrive is no longer accessible and the number of possible attack\nvectors go down drastically. And so, if you type the wrong password 3\ntimes on my laptop, it shuts down.\u003c/p\u003e\n\n\u003cp\u003eThis is accomplished by using \u003ccode\u003ePAM\u003c/code\u003e, and its ability to invoke an\narbitrary script as part of the login flow via \u003ccode\u003epam_exec.so\u003c/code\u003e. The\nscript itself looks like this:\u003c/p\u003e\n\u003cdiv class=\"highlight\" style=\"background: #f0f0f0\"\u003e\u003cpre style=\"line-height: 125%\"\u003e\u003cspan\u003e\u003c/span\u003e\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e#!/bin/bash\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# Do not add -eu, you need to allow empty variables here!\u003c/span\u003e\n\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# To be used with PAM. Look in /etc/pam.d for the script that your\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# screensaver etc uses. Typically it references common-account and common-auth.\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e#\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# In common-auth, add this as the first line\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e#auth optional pam_exec.so debug /path/to/wrongpassword.sh\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e#\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# In common-account, add this as the first line\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e#account required pam_exec.so debug /path/to/wrongpassword.sh\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e#\u003c/span\u003e\n\n\u003cspan style=\"color: #bb60d5\"\u003eCOUNTFILE\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;/var/log/failed_login_count\u0026quot;\u003c/span\u003e\n\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# Make sure file exists\u003c/span\u003e\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003eif\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e[\u003c/span\u003e ! -f \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003eCOUNFILE\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e]\u003c/span\u003e;\u003cspan style=\"color: #007020; font-weight: bold\"\u003ethen\u003c/span\u003e\n touch \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003eCOUNTFILE\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\n chmod \u003cspan style=\"color: #40a070\"\u003e777\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003eCOUNTFILE\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003efi\u003c/span\u003e\n\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# Read value in it\u003c/span\u003e\n\u003cspan style=\"color: #bb60d5\"\u003eCOUNT\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #007020; font-weight: bold\"\u003e$(\u003c/span\u003ecat \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003eCOUNTFILE\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #007020; font-weight: bold\"\u003e)\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# Increment it\u003c/span\u003e\n\u003cspan style=\"color: #bb60d5\"\u003eCOUNT\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #007020; font-weight: bold\"\u003e$((\u003c/span\u003eCOUNT+1\u003cspan style=\"color: #007020; font-weight: bold\"\u003e))\u003c/span\u003e\n\u003cspan style=\"color: #007020\"\u003eecho\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003eCOUNT\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e \u0026gt; \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003eCOUNTFILE\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\n\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# if authentication\u003c/span\u003e\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003eif\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e[\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003ePAM_TYPE\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e==\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;auth\u0026quot;\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e]\u003c/span\u003e; \u003cspan style=\"color: #007020; font-weight: bold\"\u003ethen\u003c/span\u003e\n \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# The count will be at 4 after 3 wrong tries\u003c/span\u003e\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003eif\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e[\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003eCOUNT\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e -ge \u003cspan style=\"color: #40a070\"\u003e4\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e]\u003c/span\u003e; \u003cspan style=\"color: #007020; font-weight: bold\"\u003ethen\u003c/span\u003e\n \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# Shutdown in 1 min\u003c/span\u003e\n \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e#/usr/bin/shutdown --no-wall -h +1\u003c/span\u003e\n \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# This is a hack because the line above gives a segfault in logind\u003c/span\u003e\n \u003cspan style=\"color: #007020\"\u003eecho\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;0\u0026quot;\u003c/span\u003e \u0026gt; \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003eCOUNTFILE\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\n systemctl poweroff\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003efi\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# If authentication succeeded, and we are now in account phase\u003c/span\u003e\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003eelif\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e[\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003ePAM_TYPE\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e==\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;account\u0026quot;\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e]\u003c/span\u003e; \u003cspan style=\"color: #007020; font-weight: bold\"\u003ethen\u003c/span\u003e\n \u003cspan style=\"color: #007020\"\u003eecho\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;0\u0026quot;\u003c/span\u003e \u0026gt; \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003eCOUNTFILE\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\n \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# Cancel shutdown which was just issued\u003c/span\u003e\n shutdown -c\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003efi\u003c/span\u003e\n\n\u003cspan style=\"color: #007020\"\u003eexit\u003c/span\u003e \u003cspan style=\"color: #40a070\"\u003e0\u003c/span\u003e\n\u003c/pre\u003e\u003c/div\u003e\n\n\u003cp\u003eOn my Debian system, PAM ends up looking at \u003ccode\u003e/etc/pam.d/common-auth\u003c/code\u003e\nand \u003ccode\u003e/etc/pam.d/common-account\u003c/code\u003e. These are invoked in different parts\nof the authentication flow. In \u003ccode\u003ecommon-auth\u003c/code\u003e, add this as the first\nline:\u003c/p\u003e\n\u003cdiv class=\"highlight\" style=\"background: #f0f0f0\"\u003e\u003cpre style=\"line-height: 125%\"\u003e\u003cspan\u003e\u003c/span\u003eauth optional pam_exec.so debug /path/to/wrongpassword.sh\n\u003c/pre\u003e\u003c/div\u003e\n\n\u003cp\u003eAnd then in \u003ccode\u003ecommon-account\u003c/code\u003e, add this as the first line:\u003c/p\u003e\n\u003cdiv class=\"highlight\" style=\"background: #f0f0f0\"\u003e\u003cpre style=\"line-height: 125%\"\u003e\u003cspan\u003e\u003c/span\u003eaccount required pam_exec.so debug /path/to/wrongpassword.sh\n\u003c/pre\u003e\u003c/div\u003e\n\n\u003cp\u003eYou can try it immediately if it works. Lock your screen, and type the\nwrong password 4 times. If it works, your computer should shut down.\u003c/p\u003e\n\n\u003ch2 id=\"warning-do-not-enable-on-servers\"\u003eWARNING: DO NOT ENABLE ON SERVERS\u003c/h2\u003e\n\n\u003cp\u003eThis is \u003cstrong\u003eNOT\u003c/strong\u003e something you want to do on any machine. Most notably,\nit\u0026rsquo;s probably a huge mistake to copy this verbatim on a machine which\naccepts remote connections. In that case, you essentially enable\nanyone to DOS you by entering the wrong password via SSH or\nsimilarly. So don\u0026rsquo;t do this if you allow remote connections to your\nmachine (which shouldn\u0026rsquo;t be a thing on a laptop).\u003c/p\u003e\n",
+ "date_published": "2016-09-28T22:57:21+02:00"
+ }
+
+ , {
+ "id": "https://cowboyprogrammer.org/2016/08/zopfli_all_the_things/",
+ "url": "https://cowboyprogrammer.org/2016/08/zopfli_all_the_things/",
+ "title": "Compress all the images!",
+ "content_html": "\n\n\u003cp\u003e\u003cem\u003eUpdate 2016-11-22: Made the Makefile compatible with BSD sed (MacOS)\u003c/em\u003e\u003c/p\u003e\n\n\u003cp\u003eOne advantage that static sites, such as those built by \u003ca href=\"https://gohugo.io\"\u003eHugo\u003c/a\u003e,\nprovide is fast loading times. Because there is no processing to be\ndone, no server side rendering, no database lookups, loading times are\njust as fast as you can serve the files that make up the page. This\nmeans that bandwidth becomes the primary bottleneck, which\nincidentally is\n\u003ca href=\"https://webmasters.googleblog.com/2010/04/using-site-speed-in-web-search-ranking.html\"\u003eone of the factors used by Google to calculate your search ranking\u003c/a\u003e. See\nalso\n\u003ca href=\"https://developers.google.com/speed/pagespeed/insights\"\u003ePagespeed Insights\u003c/a\u003e.\u003c/p\u003e\n\n\u003ch2 id=\"compressing-images\"\u003eCompressing images\u003c/h2\u003e\n\n\u003cp\u003eBecause the largest pieces of a page typically consist of images, it\nstands to reason that if we can make the images smaller, we can make\nthe page load faster. Luckily there exists methods that can compress\nimages \u003cem\u003elosslessly\u003c/em\u003e. That means that the quality stays exactly the\nsame, the page only loads faster. That seemed like a no-brainer to me\nso I compressed all the images on the site using \u003ca href=\"http://advsys.net/ken/utils.htm\"\u003ePNGout\u003c/a\u003e as\n\u003ca href=\"https://blog.codinghorror.com/getting-the-most-out-of-png/\"\u003eadvised by Jeff Atwood\u003c/a\u003e. I mean, who doesn\u0026rsquo;t\nlike free bandwidth?\u003c/p\u003e\n\n\u003cp\u003eA new algorithm called \u003ca href=\"https://github.com/google/zopfli\"\u003eZopfli\u003c/a\u003e (open sourced by Google,\n\u003ca href=\"https://blog.codinghorror.com/zopfli-optimization-literally-free-bandwidth/\"\u003ealso mentioned by Jeff\u003c/a\u003e) claims even better\nresults than PNGout though. Results on this site\u0026rsquo;s images confirm\nthose claims. Running the tool on images \u003cem\u003ealready compressed by\nPNGout\u003c/em\u003e gives output such as this:\u003c/p\u003e\n\u003cdiv class=\"highlight\" style=\"background: #f0f0f0\"\u003e\u003cpre style=\"line-height: 125%\"\u003e\u003cspan\u003e\u003c/span\u003e./zopflipng --prefix=\u0026quot;zopfli_\u0026quot; static/images/2014/Dec/Screenshot-from-2014-12-29-13-28-29.png\nOptimizing static/images/2014/Dec/Screenshot-from-2014-12-29-13-28-29.png\nInput size: 89420 (87K)\nResult size: 90361 (88K). Percentage of original: 101.052%\nPreserving original PNG since it was smaller\n\n./zopflipng --prefix=\u0026quot;zopfli_\u0026quot; static/images/2014/Jun/Jenkins_install_git.png\nOptimizing static/images/2014/Jun/Jenkins_install_git.png\nInput size: 189406 (184K)\nResult size: 166362 (162K). Percentage of original: 87.834%\nResult is smaller\n\n./zopflipng --prefix=\u0026quot;zopfli_\u0026quot; static/images/2014/Jun/jenkins_batch.png\nOptimizing static/images/2014/Jun/jenkins_batch.png\nInput size: 21933 (21K)\nResult size: 16255 (15K). Percentage of original: 74.112%\nResult is smaller\n\n./zopflipng --prefix=\u0026quot;zopfli_\u0026quot; static/images/2014/Jun/jenkins_build_step.png\nOptimizing static/images/2014/Jun/jenkins_build_step.png\nInput size: 8184 (7K)\nResult size: 6809 (6K). Percentage of original: 83.199%\nResult is smaller\n\n./zopflipng --prefix=\u0026quot;zopfli_\u0026quot; static/images/2014/Jun/jenkins_config_git.png\nOptimizing static/images/2014/Jun/jenkins_config_git.png\nInput size: 57897 (56K)\nResult size: 47164 (46K). Percentage of original: 81.462%\nResult is smaller\n\u003c/pre\u003e\u003c/div\u003e\n\n\u003cp\u003eThe first result in the example output shows a case where Zopfli would\nactually have made the file bigger (because it was already compressed\nby PNGout, remember). This is nothing you have to worry about because\nit\u0026rsquo;s actually smart enough that it simply copies the original file in\nthat case.\u003c/p\u003e\n\n\u003cp\u003eComparing to both before any compression, and PNGout, yielded the\nfollowing results:\u003c/p\u003e\n\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003e\u003c/th\u003e\n\u003cth\u003eMean relative size\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\n\u003ctr\u003e\n\u003ctd\u003eBefore\u003c/td\u003e\n\u003ctd\u003e1.00\u003c/td\u003e\n\u003c/tr\u003e\n\n\u003ctr\u003e\n\u003ctd\u003ePNGout\u003c/td\u003e\n\u003ctd\u003e0.84\u003c/td\u003e\n\u003c/tr\u003e\n\n\u003ctr\u003e\n\u003ctd\u003eZopfliPNG\u003c/td\u003e\n\u003ctd\u003e0.77\u003c/td\u003e\n\u003c/tr\u003e\n\n\u003c/tbody\u003e\n\u003c/table\u003e\n\n\u003cp\u003e\u003ca href=\"https://en.wikipedia.org/wiki/Box_plot\"\u003eBox plot\u003c/a\u003e of results on all images:\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/zopfli_boxplot.png\" alt=\"Compression results\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eSource files: \u003ca href=\"/csv/before.csv\"\u003ebefore.csv\u003c/a\u003e,\n\u003ca href=\"/csv/pngout.csv\"\u003epngout.csv\u003c/a\u003e, \u003ca href=\"/csv/zopfli.csv\"\u003ezopfli.csv\u003c/a\u003e\u003c/p\u003e\n\n\u003cp\u003eAnd this is with the default arguments. It is possible squeeze yet a\ncouple of more bytes out of this if you\u0026rsquo;re willing to wait longer.\u003c/p\u003e\n\n\u003ch2 id=\"automate-it-with-make\"\u003eAutomate it with Make\u003c/h2\u003e\n\n\u003cp\u003eAnother joy of using a simple static site is that it is possible to\ncompose regular tools to do useful things. Tools like\n\u003ca href=\"https://www.gnu.org/software/make/\"\u003eMake\u003c/a\u003e. And we can use Make to build the site, as well as\ncompressing images which have not already been compressed. You could\ndo it manually for each new image that you add of course but be\nhonest, you \u003cem\u003eknow\u003c/em\u003e that you\u0026rsquo;re gonna forget to do it at some point. So\nlet\u0026rsquo;s automate it instead!\u003c/p\u003e\n\n\u003cp\u003eThis is the Makefile that I use to build this site with, note that\n\u003ccode\u003epublic\u003c/code\u003e depends on \u003ccode\u003e$(PNG_SENTINELS)\u003c/code\u003e, so I literally can\u0026rsquo;t forget to\ncompress any new images added:\u003c/p\u003e\n\u003cdiv class=\"highlight\" style=\"background: #f0f0f0\"\u003e\u003cpre style=\"line-height: 125%\"\u003e\u003cspan\u003e\u003c/span\u003e\u003cspan style=\"color: #06287e\"\u003e.PHONY\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:\u003c/span\u003e help build server server-with-drafts clean zopfli\n\n\u003cspan style=\"color: #bb60d5\"\u003ePNG_SENTINELS\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:=\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003e$(\u003c/span\u003eshell find . -path ./public -prune -o -name \u003cspan style=\"color: #4070a0\"\u003e\u0026#39;*.png\u0026#39;\u003c/span\u003e -print | sed \u003cspan style=\"color: #4070a0\"\u003e\u0026#39;s|\\(.\\+/\\)\\(.\\+.png\\)|\\1.\\2.zopfli|g\u0026#39;\u003c/span\u003e\u003cspan style=\"color: #007020; font-weight: bold\"\u003e)\u003c/span\u003e\n\n\u003cspan style=\"color: #06287e\"\u003ehelp\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:\u003c/span\u003e \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e## Print this help text\u003c/span\u003e\n\t@grep -E \u003cspan style=\"color: #4070a0\"\u003e\u0026#39;^[a-zA-Z_-]+:.*?## .*$$\u0026#39;\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003e$(\u003c/span\u003eMAKEFILE_LIST\u003cspan style=\"color: #007020; font-weight: bold\"\u003e)\u003c/span\u003e | awk \u003cspan style=\"color: #4070a0\"\u003e\u0026#39;BEGIN {FS = \u0026quot;:.*?## \u0026quot;}; {printf \u0026quot;\\033[36m%-30s\\033[0m %s\\n\u0026quot;, $$1, $$2}\u0026#39;\u003c/span\u003e\n\n\u003cspan style=\"color: #06287e\"\u003eserver\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:\u003c/span\u003e \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e## Run hugo server\u003c/span\u003e\n\thugo server\n\n\u003cspan style=\"color: #06287e\"\u003eserver-with-drafts\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:\u003c/span\u003e \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e## Run hugo server and include drafts\u003c/span\u003e\n\thugo server -D\n\n\u003cspan style=\"color: #06287e\"\u003ebuild\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:\u003c/span\u003e public \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e## Build site (will also compress images using zopfli)\u003c/span\u003e\n\n\u003cspan style=\"color: #06287e\"\u003ezopfli\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003e$(\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003ePNG_SENTINELS\u003c/span\u003e\u003cspan style=\"color: #007020; font-weight: bold\"\u003e)\u003c/span\u003e \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e## Compress new images using zopfli\u003c/span\u003e\n\n\u003cspan style=\"color: #06287e\"\u003eclean\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:\u003c/span\u003e \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e## Remove the built directory\u003c/span\u003e\n\t@rm -rf public\n\n\u003cspan style=\"color: #06287e\"\u003epublic\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003e$(\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003ePNG_SENTINELS\u003c/span\u003e\u003cspan style=\"color: #007020; font-weight: bold\"\u003e)\u003c/span\u003e\n\t@rm -rf public\n\thugo\n\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# Zopfli sentinel rule, assumes zopflipng binary is in the same folder\u003c/span\u003e\n\u003cspan style=\"color: #06287e\"\u003e.%.png.zopfli\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:\u003c/span\u003e %.png\n\t./zopflipng --prefix\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;zopfli_\u0026quot;\u003c/span\u003e $\u0026lt;\n\t@mv \u003cspan style=\"color: #007020; font-weight: bold\"\u003e$(\u003c/span\u003edir $\u0026lt;\u003cspan style=\"color: #007020; font-weight: bold\"\u003e)\u003c/span\u003ezopfli_\u003cspan style=\"color: #007020; font-weight: bold\"\u003e$(\u003c/span\u003enotdir $\u0026lt;\u003cspan style=\"color: #007020; font-weight: bold\"\u003e)\u003c/span\u003e $\u0026lt;\n\t@touch \u003cspan style=\"color: #bb60d5\"\u003e$@\u003c/span\u003e\n\u003c/pre\u003e\u003c/div\u003e\n\n\u003cp\u003eFor best performance, run make with parallel jobs (change 4 to your\nnumber CPUs): \u003ccode\u003emake -j4 zopfli\u003c/code\u003e.\u003c/p\u003e\n\n\u003cp\u003eTo know which files have already been compressed without actually\nrunning Zopfli on it again (which takes a while), sentinel files are\ncreated with this pattern: \u003ccode\u003e.\u0026lt;imgfilename\u0026gt;.zopfli\u003c/code\u003e. Thus, the next\ntime around, zopfli is only invoked for files which have \u003cem\u003enot\u003c/em\u003e already\nbeen compressed, making it a one-time operation. And when everything\nhas already been compressed, you\u0026rsquo;ll just get this:\u003c/p\u003e\n\u003cdiv class=\"highlight\" style=\"background: #f0f0f0\"\u003e\u003cpre style=\"line-height: 125%\"\u003e\u003cspan\u003e\u003c/span\u003emake: Nothing to be done for \u0026#39;zopfli\u0026#39;.\n\u003c/pre\u003e\u003c/div\u003e\n",
+ "date_published": "2016-08-26T13:17:40+02:00",
+ "image": "https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things_32.png"
+ }
+
+ , {
+ "id": "https://cowboyprogrammer.org/2016/07/migrating_from_ghost_to_hugo/",
+ "url": "https://cowboyprogrammer.org/2016/07/migrating_from_ghost_to_hugo/",
+ "title": "Migrating from Ghost to Hugo",
+ "content_html": "\n\n\u003cp\u003eSo I recently migrated this site from \u003ca href=\"https://ghost.org\"\u003eGhost\u003c/a\u003e to \u003ca href=\"https://gohugo.io\"\u003eHugo\u003c/a\u003e\nafter reading a nice article about the Hugo in\n\u003ca href=\"https://www.linuxvoice.com/download-linux-voice-issue-20/\"\u003eLinux Voice #20\u003c/a\u003e (funnily enough, the same issue also\nfeatures an article about Ghost). I originally made the switch to\nGhost from \u003ca href=\"https://jekyllrb.com/\"\u003eJekyll\u003c/a\u003e back in 2014 or so mainly because I could\nnot find a good theme to use. Ghost also seemed to have a lot of cool\nfeatures and it\u0026rsquo;s fun to try new things.\u003c/p\u003e\n\n\u003cp\u003eI think it\u0026rsquo;s safe to say that I am hardly a prolific blogger. I mainly\nwrite about stuff which I personally cannot find on the web which I\nthink should exist, because I will likely need it myself sometime in\nthe future. So it\u0026rsquo;s hardly a surprise that I am not in the target\naudience for Ghost.\u003c/p\u003e\n\n\u003ch2 id=\"things-about-ghost-which-annoy-me\"\u003eThings about Ghost which annoy me\u003c/h2\u003e\n\n\u003cul\u003e\n\u003cli\u003eIt\u0026rsquo;s written in NodeJS \u0026mdash; people who think JS is a good server\nlanguage also tend to think that it\u0026rsquo;s a good idea to depend on just\nabout any package, and download it in every single build. Which\nbecomes really \u003ca href=\"http://www.theregister.co.uk/2016/03/23/npm_left_pad_chaos/\"\u003efunny sometimes\u003c/a\u003e.\u003c/li\u003e\n\u003cli\u003ePoor selection of \u003ca href=\"http://marketplace.ghost.org/\"\u003ethemes\u003c/a\u003e \u0026mdash; this is subjective of\ncourse, but it seems to me that the free options don\u0026rsquo;t have much in\nterms of diversity. Heck, they even call it a \u003cem\u003emarketplace\u003c/em\u003e which\nrubs me the wrong way.\u003c/li\u003e\n\u003cli\u003eThemes end up being quite reliant on JS if you want necessary\nfeatures like syntax highlighting on code snippets \u0026mdash; I often\nbrowse with JS disabled and should be able to view my own site.\u003c/li\u003e\n\u003cli\u003eMarkdown parser treats newlines as significant \u0026mdash; meaning you can\u0026rsquo;t\nhave properly aligned paragraphs in your editor.\u003c/li\u003e\n\u003c/ul\u003e\n\n\u003cp\u003eThat last point irritates me deeply but it\u0026rsquo;s not as bad as the next point.\u003c/p\u003e\n\n\u003cul\u003e\n\u003cli\u003eYou can effectively lock an account by entering the wrong password 3\ntimes.\u003c/li\u003e\n\u003c/ul\u003e\n\n\u003cp\u003eThis requires some explanation. So Ghost, targeting teams of bloggers\nreally, naturally have an account system much like Wordpress. Now, as\nI was surveying the security status of other services I am running, I\nwas wondering how Ghost handled someone trying to brute force your\naccount and decided to simply try it out. Type the wrong password once\ntoo many, and this happens:\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/ghost_wrong_password.png\" alt=\"Ghost: typing the wrong password too many times locks your account\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eIt doesn\u0026rsquo;t lock it for a single IP address (I tried from several), it\nlocks the entire account. Effectively, someone can just set up a\nscript to try an account indefinitely simply with the intention to\nblock someone from logging in.\u003c/p\u003e\n\n\u003cp\u003eThe log doesn\u0026rsquo;t even show login attempts, so there is no way to\nimplement sensible blocking strategies using something like \u003ca href=\"http://www.fail2ban.org\"\u003efail2ban\u003c/a\u003e.\u003c/p\u003e\n\n\u003cp\u003eThe whole thing left a bad taste my mouth so it was a very suitable timing to read an article on \u003ca href=\"https://gohugo.io\"\u003eHugo\u003c/a\u003e.\u003c/p\u003e\n\n\u003ch2 id=\"things-about-hugo-which-excite-me\"\u003eThings about Hugo which excite me\u003c/h2\u003e\n\n\u003cul\u003e\n\u003cli\u003eMarkdown parser treats newlines correctly\u003c/li\u003e\n\u003cli\u003eIt\u0026rsquo;s a static site generator and not a service \u0026mdash; this meant 100MB\n(10%) of RAM became available on my server and there is no account\nto hack (or block).\u003c/li\u003e\n\u003cli\u003eSupports everything of Ghost (that I am aware of).\u003c/li\u003e\n\u003cli\u003eThe simplicity of Hugo makes it \u003ca href=\"https://npf.io/2014/08/making-it-a-series/\"\u003equite painless\u003c/a\u003e to\ndo useful things compared to\n\u003ca href=\"https://github.com/TryGhost/Ghost/issues/4818\"\u003eignored feature requests\u003c/a\u003e for the same in Ghost.\u003c/li\u003e\n\u003cli\u003eCan do server side syntax highlighting using Pygments.\u003c/li\u003e\n\u003cli\u003eSome really nice \u003ca href=\"http://themes.gohugo.io/\"\u003ethemes\u003c/a\u003e are available, and they are\nall free.\u003c/li\u003e\n\u003c/ul\u003e\n\n\u003ch2 id=\"migrating-all-data-from-ghost\"\u003eMigrating all data from Ghost\u003c/h2\u003e\n\n\u003cp\u003eMigrating from Ghost also turned about to be really painless. There\nwere several scripts around for exactly this but they all turned out\nto be written in \u003ca href=\"https://gist.github.com/vjeantet/d1f6cf824a2344dd6b4e\"\u003eodd languages\u003c/a\u003e, and did not actually\nmigrate all the metadata in Ghost. So I wrote my own in Python with\nthese \u003cem\u003ekiller features\u003c/em\u003e:\u003c/p\u003e\n\n\u003cul\u003e\n\u003cli\u003eMigrates tags.\u003c/li\u003e\n\u003cli\u003eMigrates dates.\u003c/li\u003e\n\u003cli\u003eMigrates drafts as drafts.\u003c/li\u003e\n\u003cli\u003eCreates aliases in your posts which makes sure that old permalinks\nwill still work!\u003c/li\u003e\n\u003cli\u003eMigrates cover pictures as banner images, just select a theme which\nsupport them.\u003c/li\u003e\n\u003cli\u003eRewrites all relative links so they all still work (this includes\nimages).\u003c/li\u003e\n\u003cli\u003eCode blocks with language definitions like \u003ccode\u003e```language-java\u003c/code\u003e\nare changed to \u003ccode\u003e```java\u003c/code\u003e.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"highlight\" style=\"background: #f0f0f0\"\u003e\u003cpre style=\"line-height: 125%\"\u003e\u003cspan\u003e\u003c/span\u003e\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e#!/usr/bin/env python3\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# -*- coding: utf-8 -*-\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003e\u0026#39;\u0026#39;\u0026#39;\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003eA simple program which migrates an exported Ghost blog to Hugo.\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003eIt assumes your blog is using the hugo-icarus theme, but should\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003ework for any theme. The script will migrate your posts, including\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003etags and banner images. Furthermore, it will make sure that\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003eall your old post urls will keep working by adding aliases to them.\u003c/span\u003e\n\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003eThe only thing you need to do yourself is copying the `images/`\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003edirectory in your ghost directory to `static/images/` in your hugo\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003edirectory. That way, all images will work. The script will rewrite\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003eall urls linking to `/content/images` to just `/images`.\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003e\u0026#39;\u0026#39;\u0026#39;\u003c/span\u003e\n\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003eimport\u003c/span\u003e \u003cspan style=\"color: #0e84b5; font-weight: bold\"\u003eargparse\u003c/span\u003e\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003eimport\u003c/span\u003e \u003cspan style=\"color: #0e84b5; font-weight: bold\"\u003ejson\u003c/span\u003e\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003efrom\u003c/span\u003e \u003cspan style=\"color: #0e84b5; font-weight: bold\"\u003edatetime\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003eimport\u003c/span\u003e date\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003efrom\u003c/span\u003e \u003cspan style=\"color: #0e84b5; font-weight: bold\"\u003eos\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003eimport\u003c/span\u003e path\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003efrom\u003c/span\u003e \u003cspan style=\"color: #0e84b5; font-weight: bold\"\u003ecollections\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003eimport\u003c/span\u003e defaultdict\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003eimport\u003c/span\u003e \u003cspan style=\"color: #0e84b5; font-weight: bold\"\u003ere\u003c/span\u003e\n\n_post \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026#39;\u0026#39;\u0026#39;\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003e+++\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003edate = \u0026quot;{date}\u0026quot;\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003edraft = {draft}\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003etitle = \u0026quot;\u0026quot;\u0026quot;{title}\u0026quot;\u0026quot;\u0026quot;\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003eslug = \u0026quot;{slug}\u0026quot;\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003etags = {tags}\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003ebanner = \u0026quot;{banner}\u0026quot;\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003ealiases = {aliases}\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003e+++\u003c/span\u003e\n\n\u003cspan style=\"color: #4070a0\"\u003e{markdown}\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003e\u0026#39;\u0026#39;\u0026#39;\u003c/span\u003e\n\n\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003edef\u003c/span\u003e \u003cspan style=\"color: #06287e\"\u003emigrate\u003c/span\u003e(filepath, hugodir):\n \u003cspan style=\"color: #4070a0; font-style: italic\"\u003e\u0026#39;\u0026#39;\u0026#39;\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003e Parse the Ghost json file and write post files\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003e \u0026#39;\u0026#39;\u0026#39;\u003c/span\u003e\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003ewith\u003c/span\u003e \u003cspan style=\"color: #007020\"\u003eopen\u003c/span\u003e(filepath, \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;r\u0026quot;\u003c/span\u003e) \u003cspan style=\"color: #007020; font-weight: bold\"\u003eas\u003c/span\u003e fp:\n ghost \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e json\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eload(fp)\n\n data \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e ghost[\u003cspan style=\"color: #4070a0\"\u003e\u0026#39;db\u0026#39;\u003c/span\u003e][\u003cspan style=\"color: #40a070\"\u003e0\u003c/span\u003e][\u003cspan style=\"color: #4070a0\"\u003e\u0026#39;data\u0026#39;\u003c/span\u003e]\n\n tags \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e {}\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003efor\u003c/span\u003e tag \u003cspan style=\"color: #007020; font-weight: bold\"\u003ein\u003c/span\u003e data[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;tags\u0026quot;\u003c/span\u003e]:\n tags[tag[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;id\u0026quot;\u003c/span\u003e]] \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e tag[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;name\u0026quot;\u003c/span\u003e]\n\n posttags \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e defaultdict(\u003cspan style=\"color: #007020\"\u003elist\u003c/span\u003e)\n\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003efor\u003c/span\u003e posttag \u003cspan style=\"color: #007020; font-weight: bold\"\u003ein\u003c/span\u003e data[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;posts_tags\u0026quot;\u003c/span\u003e]:\n posttags[posttag[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;post_id\u0026quot;\u003c/span\u003e]]\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eappend(tags[posttag[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;tag_id\u0026quot;\u003c/span\u003e]])\n\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003efor\u003c/span\u003e post \u003cspan style=\"color: #007020; font-weight: bold\"\u003ein\u003c/span\u003e data[\u003cspan style=\"color: #4070a0\"\u003e\u0026#39;posts\u0026#39;\u003c/span\u003e]:\n draft \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;true\u0026quot;\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003eif\u003c/span\u003e post[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;status\u0026quot;\u003c/span\u003e] \u003cspan style=\"color: #666666\"\u003e==\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;draft\u0026quot;\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003eelse\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;false\u0026quot;\u003c/span\u003e\n ts \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e \u003cspan style=\"color: #007020\"\u003eint\u003c/span\u003e(post[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;created_at\u0026quot;\u003c/span\u003e]) \u003cspan style=\"color: #666666\"\u003e/\u003c/span\u003e \u003cspan style=\"color: #40a070\"\u003e1000\u003c/span\u003e\n\n banner \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u0026quot;\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003eif\u003c/span\u003e post[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;image\u0026quot;\u003c/span\u003e] \u003cspan style=\"color: #007020; font-weight: bold\"\u003eis\u003c/span\u003e \u003cspan style=\"color: #007020\"\u003eNone\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003eelse\u003c/span\u003e post[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;image\u0026quot;\u003c/span\u003e]\n \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# /content/ should not be part of uri anymore\u003c/span\u003e\n banner \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e re\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003esub(\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;^.*/content[s]?/\u0026quot;\u003c/span\u003e, \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;/\u0026quot;\u003c/span\u003e, banner)\n\n target \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e path\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003ejoin(hugodir, \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;content/post\u0026quot;\u003c/span\u003e,\n \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;{}.md\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eformat(post[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;slug\u0026quot;\u003c/span\u003e]))\n\n aliases \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e [\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;/{}/\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eformat(post[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;slug\u0026quot;\u003c/span\u003e])]\n\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003eprint\u003c/span\u003e(\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;Migrating \u0026#39;{}\u0026#39; to {}\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eformat(post[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;title\u0026quot;\u003c/span\u003e],\n target))\n\n hugopost \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e _post\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eformat(markdown\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003epost[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;markdown\u0026quot;\u003c/span\u003e],\n title\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003epost[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;title\u0026quot;\u003c/span\u003e],\n draft\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003edraft,\n slug\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003epost[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;slug\u0026quot;\u003c/span\u003e],\n date\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003edate\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003efromtimestamp(ts)\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eisoformat(),\n tags\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003eposttags[post[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;id\u0026quot;\u003c/span\u003e]],\n banner\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003ebanner,\n aliases\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003ealiases)\n\n \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# this is no longer relevant\u003c/span\u003e\n hugopost \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e hugopost\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003ereplace(\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;```language-\u0026quot;\u003c/span\u003e, \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;```\u0026quot;\u003c/span\u003e)\n \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# /content/ should not be part of uri anymore\u003c/span\u003e\n hugopost \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e hugopost\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003ereplace(\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;/content/\u0026quot;\u003c/span\u003e, \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;/\u0026quot;\u003c/span\u003e)\n hugopost \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e re\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003esub(\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;^.*/content[s]?/\u0026quot;\u003c/span\u003e, \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;/\u0026quot;\u003c/span\u003e, hugopost)\n\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003ewith\u003c/span\u003e \u003cspan style=\"color: #007020\"\u003eopen\u003c/span\u003e(target, \u003cspan style=\"color: #4070a0\"\u003e\u0026#39;w\u0026#39;\u003c/span\u003e) \u003cspan style=\"color: #007020; font-weight: bold\"\u003eas\u003c/span\u003e fp:\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003eprint\u003c/span\u003e(hugopost, \u003cspan style=\"color: #007020\"\u003efile\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003efp)\n\n\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003edef\u003c/span\u003e \u003cspan style=\"color: #06287e\"\u003emain\u003c/span\u003e():\n parser \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e argparse\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eArgumentParser(\n description\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;Migrate an exported Ghost blog to Hugo\u0026quot;\u003c/span\u003e)\n req \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e parser\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eadd_argument_group(title\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;required arguments\u0026quot;\u003c/span\u003e)\n req\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eadd_argument(\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;-f\u0026quot;\u003c/span\u003e, \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;--file\u0026quot;\u003c/span\u003e, help\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;JSON file exported from Ghost\u0026quot;\u003c/span\u003e,\n required\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #007020\"\u003eTrue\u003c/span\u003e)\n req\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eadd_argument(\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;-d\u0026quot;\u003c/span\u003e, \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;--dir\u0026quot;\u003c/span\u003e, help\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;Directory (root) of Hugo site\u0026quot;\u003c/span\u003e,\n required\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #007020\"\u003eTrue\u003c/span\u003e)\n\n args \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e parser\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eparse_args()\n\n migrate(args\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003efile, args\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003edir)\n\n\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003eif\u003c/span\u003e \u003cspan style=\"color: #bb60d5\"\u003e__name__\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e==\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;__main__\u0026quot;\u003c/span\u003e:\n main()\n\u003c/pre\u003e\u003c/div\u003e\n\n\u003cp\u003eNext post, I might write about what changes I made to the theme, and\nsome nifty Nginx tricks you can use to stay compatible with old links.\u003c/p\u003e\n",
+ "date_published": "2016-07-25T23:55:38+02:00",
+ "image": "https://cowboyprogrammer.org/images/hugo-logo.png"
+ }
+
+ , {
+ "id": "https://cowboyprogrammer.org/2016/05/set-refresh-rate-of-screen-from-script/",
+ "url": "https://cowboyprogrammer.org/2016/05/set-refresh-rate-of-screen-from-script/",
+ "title": "Set refresh rate of screen from script",
+ "content_html": "\u003cp\u003eGetting a great new 100 Hz Ultra Wide monitor does not come without its share of tweaking. So it turns out that the refresh you set on your monitor in Nvidia settings (as explained in a \u003ca href=\"https://cowboyprogrammer.org/nvidia-gsync-on-linux/\"\u003eprevious post\u003c/a\u003e does not apply to all the display ports. They apparently count as different screens with different settings or something.\u003c/p\u003e\n\n\u003cp\u003eSo, here\u0026rsquo;s a handy script which you can add to your window manager\u0026rsquo;s autostart applications to set the refresh rate and resolution of your screen, regardless of which actual port you use:\u003c/p\u003e\n\u003cdiv class=\"highlight\" style=\"background: #f0f0f0\"\u003e\u003cpre style=\"line-height: 125%\"\u003e\u003cspan\u003e\u003c/span\u003e\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e#!/bin/bash -eu\u003c/span\u003e\n\u003cspan style=\"color: #bb60d5\"\u003eRES\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;3440x1440\u0026quot;\u003c/span\u003e\n\u003cspan style=\"color: #bb60d5\"\u003eRR\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;100\u0026quot;\u003c/span\u003e\n\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# Do for every output, so that it doesn\u0026#39;t matter where you plug in\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# your monitor.\u003c/span\u003e\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003efor\u003c/span\u003e output in \u003cspan style=\"color: #007020; font-weight: bold\"\u003e$(\u003c/span\u003exrandr | grep \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;DP-\u0026quot;\u003c/span\u003e | sed -e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;s/\\(DP-.\\).*/\\1/\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #007020; font-weight: bold\"\u003e)\u003c/span\u003e; \u003cspan style=\"color: #007020; font-weight: bold\"\u003edo\u003c/span\u003e\n \u003cspan style=\"color: #007020\"\u003eecho\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;Trying to set mode on \u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003e$output\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003eif\u003c/span\u003e xrandr --output \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003e$output\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e --mode \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003e$RES\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e -r \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003e$RR\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e; \u003cspan style=\"color: #007020; font-weight: bold\"\u003ethen\u003c/span\u003e\n \u003cspan style=\"color: #007020\"\u003eecho\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;Success: \u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003e$RES\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e \u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003e$RR\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e Hz set on \u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003e$output\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003efi\u003c/span\u003e\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003edone\u003c/span\u003e\n\u003c/pre\u003e\u003c/div\u003e\n\n\u003cp\u003eIt iterates over all the display ports on your graphics card, so it doesn\u0026rsquo;t matter where you plug your monitor in.\u003c/p\u003e\n\n\u003cp\u003eIn XFCE, you\u0026rsquo;d add this script to \u003cem\u003eApplication Autostart\u003c/em\u003e:\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2016/05/Session-and-Startup_033.png\" alt=\"XFCE Application Autostart\" /\u003e\u003c/p\u003e\n",
+ "date_published": "2016-05-18T00:00:00+00:00",
+ "image": "https://cowboyprogrammer.org/images/2016/05/Selection_034.png"
+ }
+
+ , {
+ "id": "https://cowboyprogrammer.org/2016/04/fixing-the-up-button-in-python-shell-history/",
+ "url": "https://cowboyprogrammer.org/2016/04/fixing-the-up-button-in-python-shell-history/",
+ "title": "Fixing the up button in Python shell history",
+ "content_html": "\u003cp\u003eIn case your python/ipython shell doesn\u0026rsquo;t have a working history, e.g. pressing \u0026#8593; only prints some nonsensical \u003ccode\u003e^[[A\u003c/code\u003e, then you are missing either the \u003ccode\u003ereadline\u003c/code\u003e or \u003ccode\u003encurses\u003c/code\u003e library.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2016/04/Selection_021.png\" alt=\"Python shell where up doesn't work\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eIpython is more descriptive that something is wrong, but if you\u0026rsquo;re in the habit of mostly using python as a quick calculator, you might never notice:\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2016/04/Selection_022.png\" alt=\"iPython shell where up doesn't work\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eIf you\u0026rsquo;re using \u003ca href=\"http://conda.pydata.org/miniconda.html\"\u003eMiniconda\u003c/a\u003e then just do:\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003econda install ncurses readline\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003cp\u003eAnd \u0026#8593; should work:\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2016/04/Selection_023.png\" alt=\"iPython with working up\" /\u003e\u003c/p\u003e\n",
+ "date_published": "2016-04-02T00:00:00+00:00",
+ "image": "https://cowboyprogrammer.org/images/2016/04/Selection_021-1.png"
+ }
+
+ , {
+ "id": "https://cowboyprogrammer.org/2016/03/nvidia-gsync-on-linux/",
+ "url": "https://cowboyprogrammer.org/2016/03/nvidia-gsync-on-linux/",
+ "title": "Nvidia G-Sync and Linux",
+ "content_html": "\n\n\u003cp\u003eAfter getting a fancy new monitor with G-Sync support, I was eager to try it out in my Linux gaming setup. While Nvidia fully supports G-Sync in their Linux drivers, it turns out that other components of the system can get in the way. As explained by a \u003ca href=\"https://devtalk.nvidia.com/default/topic/854184/gsync-is-not-working/?offset=1\"\u003epost on the Nvidia forums\u003c/a\u003e:\u003c/p\u003e\n\n\u003cblockquote\u003e\n\u003cp\u003eFor G-SYNC to work, the application has to be able to flip and the symptoms you\u0026rsquo;re describing here sound like it\u0026rsquo;s not able to flip in your configuration. There are a variety of reasons why flipping might not be working, but the most likely culprits here are either the compositor getting in the way, or the game not being completely full-screen. The full-screen requirement includes the game being completely unoccluded, so if your window manager is drawing something on top of the game, even just by one pixel, it will prevent flipping. Full-screen also means that it has to cover the entire X screen, which includes both monitors if you have them both enabled.\u003c/p\u003e\n\n\u003cp\u003eCan you please try a different window manager / desktop environment to see if the behavior changes?\u003c/p\u003e\n\u003c/blockquote\u003e\n\n\u003cp\u003eSince only a minority of PC-gamers are actually on Linux, and only a minority of those actually have G-Sync capable monitors, Googling for assistance was\u0026hellip; challenging. So, for any other Linux gamers out there, here is a short guide on how to enable G-Sync and verify that it works. Some of the steps are XFCE specific, as this is my window manager of choice on my gaming PC. If you are using a different window manager, you\u0026rsquo;ll have to look through your options to find the equivalent settings.\u003c/p\u003e\n\n\u003ch2 id=\"nvidia-settings\"\u003eNvidia settings\u003c/h2\u003e\n\n\u003cul\u003e\n\u003cli\u003eSync to VBlank: Optional\u003c/li\u003e\n\u003cli\u003eAllow Flipping: Required\u003c/li\u003e\n\u003cli\u003eAllow G-SYNC: Required\u003c/li\u003e\n\u003cli\u003eEnable G-SYNC Visual Indicator: Optional\u003c/li\u003e\n\u003c/ul\u003e\n\n\u003cp\u003eThe only two required settings are \u003cem\u003eflipping\u003c/em\u003e and \u003cem\u003eG-Sync\u003c/em\u003e, the others are optional. Enabling \u003cem\u003eSync to VBlank\u003c/em\u003e (VSync) in combination with G-Sync only prevents the GPU from generating an FPS beyond your monitor\u0026rsquo;s max refresh rate (which you can\u0026rsquo;t see anyway). It is turned off below the max refresh rate when G-Sync is enabled.\u003c/p\u003e\n\n\u003cp\u003eThe visual indicator is useful here to see that G-Sync is working. If all goes well, you should see a green \u0026ldquo;G-SYNC\u0026rdquo; text in the corner when running a game.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2016/03/NVIDIA-X-Server-Settings_007.png\" alt=\"Nvidia settings\" /\u003e\u003c/p\u003e\n\n\u003ch2 id=\"disable-compositor\"\u003eDisable compositor\u003c/h2\u003e\n\n\u003cp\u003eAs mentioned in the forum post, a compositor will prevent G-Sync from activating because essentially something is rendering above the game. The same reason prevents G-Sync from working in Window mode (unlike Windows, where G-Sync does not require fullscreen).\u003c/p\u003e\n\n\u003cp\u003eFor XFCE, go to \u003cem\u003eWindow Manager Tweaks\u003c/em\u003e under \u003cem\u003eSettings\u003c/em\u003e\n\u003cimg src=\"/images/2016/03/Selection_004.png\" alt=\"XFCE Settings\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eThen under the \u003cem\u003eCompositor\u003c/em\u003e tab, make sure the compositor is disabled\n\u003cimg src=\"/images/2016/03/Selection_005.png\" alt=\"Window Manager Tweaks\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eIn addition, depending on your setup, make sure you don\u0026rsquo;t have things like \u003ca href=\"https://wiki.archlinux.org/index.php/Compton\"\u003eCompton\u003c/a\u003e or \u003ca href=\"https://wiki.archlinux.org/index.php/Compiz\"\u003eCompiz\u003c/a\u003e enabled.\u003c/p\u003e\n\n\u003ch2 id=\"start-a-game-in-fullscreen\"\u003eStart a game in fullscreen\u003c/h2\u003e\n\n\u003cp\u003eAs mentioned, you must run the game in fullscreen mode. G-Sync does not work with window mode in Linux.\u003c/p\u003e\n\n\u003cp\u003eI did notice that there are games which do not enable G-Sync. One example is \u0026ldquo;Cities: Skylines\u0026rdquo;. So make sure to try several games if you don\u0026rsquo;t see the G-Sync logo.\u003c/p\u003e\n\n\u003cp\u003eA good candidate here is Dota 2 since it is free to play. Dota 2 running in \u0026ldquo;Desktop-Friendly Fullscreen\u0026rdquo; does enable G-Sync. As does Portal 2 and XCOM 2.\u003c/p\u003e\n",
+ "date_published": "2016-03-05T00:00:00+00:00",
+ "image": "https://cowboyprogrammer.org/images/2016/03/NVIDIA-X-Server-Settings_007-1.png"
+ }
+
+ , {
+ "id": "https://cowboyprogrammer.org/2014/12/encrypt-a-btrfs-raid5-array-in-place/",
+ "url": "https://cowboyprogrammer.org/2014/12/encrypt-a-btrfs-raid5-array-in-place/",
+ "title": "Encrypt a BTRFS RAID5-array in-place",
+ "content_html": "\n\n\u003cp\u003eWhen I decided I needed more disk space for media and virtual machine (VM) images, I decided to throw some more money at the problem and get three 3TB hard drives and run \u003ca href=\"https://btrfs.wiki.kernel.org/index.php/Main_Page\"\u003eBTRFS\u003c/a\u003e in \u003ca href=\"http://en.wikipedia.org/wiki/RAID#Standard_levels\"\u003eRAID5\u003c/a\u003e. It\u0026rsquo;s still somewhat experimental, but has proven very solid for me.\u003c/p\u003e\n\n\u003cp\u003eRAID5 means that one drive can completely fail, but all the data is still intact. All one has to do is insert a new drive and the drive will be reconstructed. While RAID5 protects against a complete drive failure, it does nothing to prevent a single bit to be flipped to due cosmic rays or electricity spikes.\u003c/p\u003e\n\n\u003cp\u003eBTRFS is a new filesystem for Linux which does what ZFS does for BSD. The two important features which it offers over previous systems is: copy-on-write (COW), and bitrot protection. See, when running RAID with BTRFS, if a single bit is flipped, BTRFS will detect it when you try to read the file and correct it (if running in RAID so there\u0026rsquo;s redundancy). COW means you can take snapshots of the entire drive instantly without using extra space. Space will only be required when stuff change and diverge from your snapshots.\u003c/p\u003e\n\n\u003cp\u003eSee \u003ca href=\"http://arstechnica.com/information-technology/2014/01/bitrot-and-atomic-cows-inside-next-gen-filesystems/\"\u003eArstechnica\u003c/a\u003e for why \u003cem\u003eBTRFS\u003c/em\u003e is da shit for your next drive or system.\u003c/p\u003e\n\n\u003cp\u003eWhat I did not do at the time was encrypt the drives. \u003ca href=\"http://www.linuxvoice.com/\"\u003eLinux Voice #11\u003c/a\u003e had a very nice article on encryption so I thought I\u0026rsquo;d set it up. And because I\u0026rsquo;m using RAID5, it is actually possible for me to encrypt my drives using \u003ca href=\"https://wiki.archlinux.org/index.php/Dm-crypt/Device_encryption\"\u003edm-crypt/LUKS\u003c/a\u003e in-place, while the whole shebang is mounted, readable and usable :)\u003c/p\u003e\n\n\u003cp\u003eSome initial mistakes meant I had to actually reboot the system, so I thought I\u0026rsquo;d write down how to do it correctly. So to summarize, the goal is to convert three disks to three encrypted disks. BTRFS will be moved from using the drives directly, to using the LUKS-mapped.\u003c/p\u003e\n\n\u003ch3 id=\"unmount-the-raid-system-time-1-second\"\u003eUnmount the raid system (time 1 second)\u003c/h3\u003e\n\n\u003cp\u003eSadly, we need to unmount the volume to be able to \u0026ldquo;remove\u0026rdquo; the drive. This needs to be done so the system can understand that the drive has \u0026ldquo;vanished\u0026rdquo;. It will only stay unmounted for about a minute though.\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003esudo umount /path/to/vol\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003cp\u003eThis is assuming you have configured your \u003cstrong\u003efstab\u003c/strong\u003e with all the details. For example, with something like this (ALWAYS USE UUID!!)\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003e# BTRFS Systems\nUUID=\u0026quot;ac21dd50-e6ee-4a9e-abcd-459cba0e6913\u0026quot; /mnt/btrfs btrfs defaults 0 0\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003cp\u003eNote that no modification of the \u003cstrong\u003efstab\u003c/strong\u003e will be necessary if you have used UUID.\u003c/p\u003e\n\n\u003ch3 id=\"encrypt-one-of-the-drives-time-10-seconds\"\u003eEncrypt one of the drives (time 10 seconds)\u003c/h3\u003e\n\n\u003cp\u003ePick one of the drives to encrypt. Here it\u0026rsquo;s \u003ccode\u003e/dev/sdc\u003c/code\u003e:\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003esudo cryptsetup luksFormat -v /dev/sdc\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003ch3 id=\"open-the-encrypted-drive-time-30-seconds\"\u003eOpen the encrypted drive (time 30 seconds)\u003c/h3\u003e\n\n\u003cp\u003eTo use it, we have to open the drive. You can pick any name you want:\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003esudo cryptsetup luksOpen /dev/sdc DRIVENAME\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003cp\u003eTo make this happen on boot, find the new \u003cem\u003eUUID\u003c/em\u003e of \u003ccode\u003e/dev/sdc\u003c/code\u003e with \u003ccode\u003eblkid\u003c/code\u003e:\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003esudo blkid\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2014/Dec/Screenshot-from-2014-12-29-13-28-29.png\" alt=\"Output of blkid\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eSo for me, the drive has a the following \u003cem\u003eUUID:\u003c/em\u003e \u003ccode\u003ef5d3974c-529e-4574-bbfa-7f3e6db05c65\u003c/code\u003e. Add the following line to \u003ccode\u003e/etc/crypttab\u003c/code\u003e with your desired drive name and your \u003cem\u003eUUID\u003c/em\u003e (without any quotes):\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003eDRIVENAME UUID=your-uuid-without-quotes none luks\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003cp\u003eNow the system will ask for your password on boot.\u003c/p\u003e\n\n\u003ch3 id=\"add-the-encrypted-drive-to-the-raid-time-20-seconds\"\u003eAdd the encrypted drive to the raid (time 20 seconds)\u003c/h3\u003e\n\n\u003cp\u003eFirst we have to remount the raid system. This will fail because there is a missing drive, unless we add the option \u003cem\u003edegraded\u003c/em\u003e.\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003esudo mount -o degraded /path/to/vol\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003cp\u003eThere will be some complaints about missing drives and such, which is exactly what we expect. Now, just add the new drive:\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003esudo btrfs device add /dev/mapper/DRIVENAME /path/to/vol\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003ch3 id=\"remove-the-missing-drive-time-14-hours\"\u003eRemove the missing drive (time 14 hours)\u003c/h3\u003e\n\n\u003cp\u003eThe final step is to remove the old drive. We can use the special name \u003cem\u003emissing\u003c/em\u003e to remove it:\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003esudo btrfs device delete missing /path/to/vol\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003cp\u003eThis can take a really long time, and by long I mean ~15 hours if you have a terrabyte of data. But, you can still use the drive during this process so just be patient.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2014/Dec/Screenshot-from-2014-12-29-12-48-45.png\" alt=\"Balance took 14 hours\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eFor me it took 14 hours 34 minutes. The reason for the delay is because the \u003cem\u003edelete\u003c/em\u003e command will force the system to rebuild the missing drive on your new encrypted volume.\u003c/p\u003e\n\n\u003ch3 id=\"next-drive-rinse-and-repeat\"\u003eNext drive, rinse and repeat\u003c/h3\u003e\n\n\u003cp\u003eJust unmount the raid, encrypt the drive, add it back and delete the missing. Repeat for all drives in your array. Once the last drive is done, unmount the array and remount it without the \u003ccode\u003e-o degraded\u003c/code\u003e option. Now you have an encrypted RAID array.\u003c/p\u003e\n",
+ "date_published": "2014-12-28T00:00:00+00:00"
+ }
+
+ ]
+
+}
diff --git a/app/src/androidTest/resources/com/nononsenseapps/feeder/model/opml/Flym_auto_backup.opml b/app/src/androidTest/resources/com/nononsenseapps/feeder/model/opml/Flym_auto_backup.opml
new file mode 100644
index 0000000..b88894e
--- /dev/null
+++ b/app/src/androidTest/resources/com/nononsenseapps/feeder/model/opml/Flym_auto_backup.opml
@@ -0,0 +1,28 @@
+
+
+
+Flym export
+1498468298144
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/androidTest/resources/com/nononsenseapps/feeder/model/opml/antennapod-feeds.opml b/app/src/androidTest/resources/com/nononsenseapps/feeder/model/opml/antennapod-feeds.opml
new file mode 100644
index 0000000..e3e58f7
--- /dev/null
+++ b/app/src/androidTest/resources/com/nononsenseapps/feeder/model/opml/antennapod-feeds.opml
@@ -0,0 +1,17 @@
+
+
+
+ AntennaPod Subscriptions
+ 04 Jul 17 00:58:04 +0200
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/androidTest/resources/com/nononsenseapps/feeder/model/opml/rssguard_1.opml b/app/src/androidTest/resources/com/nononsenseapps/feeder/model/opml/rssguard_1.opml
new file mode 100644
index 0000000..15ec3ba
--- /dev/null
+++ b/app/src/androidTest/resources/com/nononsenseapps/feeder/model/opml/rssguard_1.opml
@@ -0,0 +1,49 @@
+
+
+
+ RSS Guard
+ Thu, 27 Jul 2017 18:51:54 GMT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/androidTest/resources/com/nononsenseapps/feeder/model/opml/rssguard_2.opml b/app/src/androidTest/resources/com/nononsenseapps/feeder/model/opml/rssguard_2.opml
new file mode 100644
index 0000000..f4384de
--- /dev/null
+++ b/app/src/androidTest/resources/com/nononsenseapps/feeder/model/opml/rssguard_2.opml
@@ -0,0 +1,49 @@
+
+
+
+ RSS Guard
+ Thu, 27 Jul 2017 18:51:54 GMT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/androidTest/resources/com/nononsenseapps/feeder/model/rss_nixos.xml b/app/src/androidTest/resources/com/nononsenseapps/feeder/model/rss_nixos.xml
new file mode 100644
index 0000000..bc18c50
--- /dev/null
+++ b/app/src/androidTest/resources/com/nononsenseapps/feeder/model/rss_nixos.xml
@@ -0,0 +1,898 @@
+
+NixOS Newshttps://nixos.orgNews for NixOS, the purely functional Linux distribution.NixOShttps://nixos.org/logo/nixos-logo-only-hires.pnghttps://nixos.org/
+ NixOS 18.09 released
+https://nixos.org/news.html
+
+
+
+ NixOS 18.09 “Jellyfish†has been released, the tenth stable release branch.
+ See the release notes
+ for details. You can get NixOS 18.09 ISOs and VirtualBox appliances
+ from the download page.
+ For information on how to upgrade from older release branches
+ to 18.09, check out the
+ manual section on upgrading.
+Sat Oct 06 2018 00:00:00 GMT
+ Fastly supports NixOS
+https://nixos.org/news.html
+ We are happy to announce that we have moved our binary cache to Fastly. Fastly
+ is a big supporter of open source projects and now NixOS is one of them! Fastly provides us with CDN capability,
+ which previously was running on AWS CloudFront. Big thanks go to Fastly, in particular Tom Denniston and Elaine
+ Greenberg, our friends at Infor and Packet.net
+ and Graham Christensen for making this possible.
+Thu Oct 04 2018 00:00:00 GMT
+ Nix 2.1 released
+https://nixos.org/news.html
+ Nix 2.1
+ has been released. See the release
+ notes for a list of changes and new features.
+Sun Sep 02 2018 00:00:00 GMT
+ NixOS Discourse forum
+https://nixos.org/news.html
+ The nix-devel mailing list is now replaced by our discourse forum instance which is also usable by email:
+ discourse.nixos.org.
+Tue Aug 14 2018 00:00:00 GMT
+ NixCon 2018
+https://nixos.org/news.html
+ We're happy to announce that NixCon 2018, the
+ third Nix Conference, will take place October 25-27 2018 in London
+ For more information, see the
+ NixCon 2018 website.
+ And please consider
+ submitting a talk!
+Mon May 21 2018 00:00:00 GMT
+ NixOS 18.03 released
+https://nixos.org/news.html
+
+
+
+ NixOS 18.03 “Impala†has been released, the ninth stable release branch.
+ See the release notes
+ for details. You can get NixOS 18.03 ISOs and VirtualBox appliances
+ from the download page.
+ For information on how to upgrade from older release branches
+ to 18.03, check out the
+ manual section on upgrading.
+Wed Apr 04 2018 00:00:00 GMT
+ Nix 2.0 released
+https://nixos.org/news.html
+ Nix 2.0
+ has been released. See the release
+ notes for a list of changes and new features.
+Thu Feb 22 2018 00:00:00 GMT
+ NixOS 17.09 released
+https://nixos.org/news.html
+ NixOS 17.09 “Hummingbird†has been released, the eigth stable release
+ branch. See the release notes
+ for details. You can get NixOS 17.09 ISOs and VirtualBox
+ appliances from the download
+ page. For information on how to upgrade from older release
+ branches to 17.09, check out the manual section on
+ upgrading.
+Mon Oct 02 2017 00:00:00 GMT
+ Nix-dev mailing list moved
+https://nixos.org/news.html
+ The nix-dev mailing list has moved to
+ nix-devel
+ on Google Groups.
+Wed Jul 12 2017 00:00:00 GMT
+ NixCon 2017
+https://nixos.org/news.html
+ We're happy to announce that NixCon 2017, the
+ second Nix Conference, will take place October 28–31 2017 in Munich
+ For more information, see the
+ NixCon 2017 website.
+ And please consider
+ submitting a talk!
+Sun Jun 18 2017 00:00:00 GMT
+ NixOS 17.03 released
+https://nixos.org/news.html
+ NixOS 17.03 “Gorilla†has been released, the seventh stable release
+ branch. See the release notes
+ for details. You can get NixOS 17.03 ISOs and VirtualBox
+ appliances from the download
+ page. For information on how to upgrade from older release
+ branches to 17.03, check out the manual section on
+ upgrading.
+Fri Mar 31 2017 00:00:00 GMT
+ NixOS 16.09 released
+https://nixos.org/news.html
+ NixOS 16.09 “Flounder†has been released, the sixth stable release
+ branch. See the release notes
+ for details. You can get NixOS 16.09 ISOs and VirtualBox
+ appliances from the download
+ page. For information on how to upgrade from older release
+ branches to 16.09, check out the manual section on
+ upgrading.
+Mon Oct 03 2016 00:00:00 GMT
+ NixOps 1.4 released
+https://nixos.org/news.html
+ NixOps
+ 1.4 has been released. This release contains contains many
+ nice new features. See the manual
+ for details.
+Wed Jul 20 2016 00:00:00 GMT
+ NixOS 16.03 released
+https://nixos.org/news.html
+ NixOS 16.03 “Emu†has been released, the fifth stable release
+ branch. See the release notes
+ for details. You can get NixOS 16.03 ISOs and VirtualBox
+ appliances from the download
+ page. For information on how to upgrade from older release
+ branches to 16.03, check out the manual section on
+ upgrading.
+Sun May 01 2016 00:00:00 GMT
+ Nix 1.11 released
+https://nixos.org/news.html
+ Nix 1.11
+ has been released. See the release
+ notes for a list of changes and new features.
+Fri Feb 19 2016 00:00:00 GMT
+ NixOS 15.09 released
+https://nixos.org/news.html
+ NixOS 15.09 “Dingo†has been released, the fourth stable release
+ branch. See the release notes
+ for details. You can get NixOS 15.09 ISOs and VirtualBox
+ appliances from the download
+ page. For information on how to upgrade from older release
+ branches to 15.09, check out the manual section on
+ upgrading.
+Fri Oct 30 2015 00:00:00 GMT
+ Nix 1.10 released
+https://nixos.org/news.html
+ Nix 1.10
+ has been released. See the release
+ notes for a list of changes and new features.
+Sat Oct 03 2015 00:00:00 GMT
+ NixCon 2015
+https://nixos.org/news.html
+
+ We're happy to announce that NixCon 2015, the
+ first Nix Conference, will take place on November
+ 14—15th 2015 in Berlin. For more information, see the
+ NixCon website. And please
+ consider submitting a
+ talk!
+Thu Sep 03 2015 00:00:00 GMT
+ NixOS Foundation
+https://nixos.org/news.html
+ The NixOS Foundation
+ was started to improve our ability to maintain and extend the infrastructure
+ used by the Nix related projects. If you would like to support us, please go
+ here and donate some money!
+Sun Aug 09 2015 00:00:00 GMT
+ Nix 1.9 released
+https://nixos.org/news.html
+ Nix 1.9
+ has been released. See the release
+ notes for a list of changes and new features.
+Sun Jul 12 2015 00:00:00 GMT
+ NixOS 14.12 released
+https://nixos.org/news.html
+ NixOS 14.12 “Caterpillar†has been released, the third stable
+ release branch. It brings Linux 3.14, systemd 217, Glibc 2.20,
+ KDE 4.14.1, and much more. See the release notes
+ for details. You can get NixOS 14.12 ISOs and VirtualBox
+ appliances from the download
+ page. For information on how to upgrade from older release
+ branches to 14.12, check out the manual section on
+ upgrading.
+Fri Jan 30 2015 00:00:00 GMT
+ Nix 1.8 released
+https://nixos.org/news.html
+ Nix 1.8
+ has been released. See the release
+ notes for a list of changes and new features.
+Wed Jan 14 2015 00:00:00 GMT
+ NixOS sprint in Ljubljana
+https://nixos.org/news.html
+ We’re having a NixOS sprint at the Kiberpipa hackerspace
+ in Ljubljana, Slovenia, on August
+ 23—27. Joining is free! For more information and to
+ register, please go to the sprint
+ page.
+Sat Aug 30 2014 00:00:00 GMT
+ NixOS 14.04 released
+https://nixos.org/news.html
+ NixOS 14.04 “Baboon†has been released, the second stable
+ release branch. It brings Linux 3.12, systemd 212, GCC 4.8,
+ Glibc 2.19, KDE 4.12, light-weight NixOS containers, and much
+ more. See the release
+ notes for details. You can get NixOS 14.04 ISOs and
+ VirtualBox appliances from the download page. For information on
+ how to upgrade a 13.10 system to 14.04, check out the manual
+ section on upgrading.
+Fri May 30 2014 00:00:00 GMT
+ NixOps 1.2 released
+https://nixos.org/news.html
+ NixOps
+ 1.2 has been released. This release contains contains many nice new features. See the manual
+ for details.
+Fri May 30 2014 00:00:00 GMT
+ Nix 1.7 released
+https://nixos.org/news.html
+ Nix 1.7
+ has been released. See the release
+ notes for a list of new features.
+Sun May 11 2014 00:00:00 GMT
+ Heartbleed vulnerability in OpenSSL
+https://nixos.org/news.html
+ A serious security
+ vulnerability has been discovered in OpenSSL. All stable
+ NixOS releases prior to version
+ 13.10.35708.15a465c are vulnerable. (You can
+ see your current version by running nixos-version.) To
+ upgrade to the latest NixOS version, run nixos-rebuild
+ switch --upgrade. You can verify whether you are safe by
+ running
+
+
+
+ If this shows any OpenSSL version prior to 1.0.1g, you may be
+ vulnerable.
+Fri May 09 2014 00:00:00 GMT
+ FOSDEM talks
+https://nixos.org/news.html
+ Domen Kožar gave a
+ talk at FOSDEM about NixOS (video).
+ Also, Ludovic Courtès gave a talk on
+ Guix, the Nix- and Guile-based package manager.
+Sun Mar 02 2014 00:00:00 GMT
+ Stdenv updates branch merged into master
+https://nixos.org/news.html
+ The stdenv-updates branch has
+ been merged into the master branch of Nixpkgs. The main
+ change are that brings is that Nixpkgs/NixOS are now based on
+ GCC 4.8 and Glibc 2.18, in addition to many smaller updates.
+Fri Feb 21 2014 00:00:00 GMT
+ NixOS 13.10 released
+https://nixos.org/news.html
+ We have released NixOS 13.10, the first stable branch of NixOS.
+ Its goal is to provide a safe branch for production environments
+ that need bug fixes and security updates, but not the
+ potentially destabilising changes that sometimes occur on the
+ unstable branch. You can get NixOS 13.10 ISOs and VirtualBox
+ appliances from the download
+ page. See the announcement
+ for more information. For information on how to switch an
+ existing NixOS machine from the unstable channel to 13.10, check
+ out the manual
+ section on upgrading.
+Sun Dec 01 2013 00:00:00 GMT
+ Nix 1.6.1 released
+https://nixos.org/news.html
+ Nix
+ 1.6.1 has been released. This is primarily a bug fix
+ release but has some minor new features. See the release
+ notes for details.
+Thu Nov 28 2013 00:00:00 GMT
+ NixOS sources merged into Nixpkgs
+https://nixos.org/news.html
+ The NixOS Git tree has been merged into the Nixpkgs tree in
+ order to simplify development. The sources now live in the nixos
+ subdirectory of the Nixpkgs repository on GitHub. See the
+ announcement
+ for more information.
+Sun Nov 10 2013 00:00:00 GMT
+ NixOps 1.1.1 released
+https://nixos.org/news.html
+ NixOps
+ 1.1.1 has been released. This release consists mostly of minor bugfixes. See the manual
+ for details.
+Sat Nov 02 2013 00:00:00 GMT
+ Nix 1.6 released
+https://nixos.org/news.html
+ Nix 1.6
+ has been released. See the release
+ notes for details.
+Thu Oct 10 2013 00:00:00 GMT
+ NixOps 1.1 released
+https://nixos.org/news.html
+ NixOps
+ 1.1 has been released. This release brings a backend for Hetzner,
+ a German data center provider, support for EC2 spot instances and some
+ minor bugfixes. See the manual
+ for details.
+Wed Oct 09 2013 00:00:00 GMT
+ NixOS sprint in Slovenia
+https://nixos.org/news.html
+ A sprint focused on NixOS and Kotti will be held 22-26
+ July 2013 in Lokve, Slovenia. It is organised by Termitnjak and sponsored
+ by LogicBlox.
+Thu Aug 15 2013 00:00:00 GMT
+ NixOps 1.0.1 released
+https://nixos.org/news.html
+ NixOps
+ 1.0.1 has been released, a minor bug fix release. See the manual
+ for details.
+Sun Aug 11 2013 00:00:00 GMT
+ NixOS presentation at EuroPython
+https://nixos.org/news.html
+ Domen Kožar gave a presentation at EuroPython
+ 2013: “NixOS
+ Operating System: Declarative Configuration Distributionâ€.
+Mon Aug 05 2013 00:00:00 GMT
+ NixOps 1.0 released
+https://nixos.org/news.html
+ NixOps
+ 1.0 has been released, the inaugural release of the NixOS
+ cloud deployment tool. See the announcement
+ and the manual
+ for details.
+Thu Jul 25 2013 00:00:00 GMT
+ Nix 1.5.3 released
+https://nixos.org/news.html
+ Nix 1.5.3
+ has been released. This is primarily a bug fix release. See the release
+ notes for details.
+Wed Jul 17 2013 00:00:00 GMT
+ PhD thesis: A Reference Architecture for Distributed Software Deployment
+https://nixos.org/news.html
+ Today Sander van
+ der Burg successfully defended his PhD thesis entitled A
+ Reference Architecture for Distributed Software
+ Deployment! It describes (among other things) Disnix, a system for
+ deployment of service-oriented architectures.
+Wed Jul 03 2013 00:00:00 GMT
+ Nix 1.5.2 released
+https://nixos.org/news.html
+ Nix 1.5.2
+ has been released. This is a bug fix release.
+Thu Jun 13 2013 00:00:00 GMT
+ Nix 1.5.1 released
+https://nixos.org/news.html
+ Nix 1.5.1
+ has been released. It fixes a regression introduced in Nix 1.4. See the release
+ notes for details.
+Thu Mar 28 2013 00:00:00 GMT
+ Nix 1.4 released
+https://nixos.org/news.html
+ Nix 1.4
+ has been released. This is primarily a bug fix release that
+ addresses a security problem in multi-user mode. See the release
+ notes for details. For installation information, see the manual.
+Tue Mar 26 2013 00:00:00 GMT
+ NixOS switched to systemd
+https://nixos.org/news.html
+ NixOS has switched from Upstart to systemd!
+ Systemd brings many advantages such as better dependency
+ management, socket-based activation of services, per-service
+ logging, cgroup-based process management, and much more. (Read
+ the announcement.)
+Thu Feb 21 2013 00:00:00 GMT
+ Nix 1.3 released
+https://nixos.org/news.html
+ Nix 1.3
+ has been released. This is primarily a bug fix release. See
+ the release
+ notes for details. For installation information, see the manual.
+Tue Feb 05 2013 00:00:00 GMT
+ Nix 1.2 released
+https://nixos.org/news.html
+ Nix 1.2
+ has been released. See the release
+ notes for details. For installation information, see the manual.
+Sun Jan 06 2013 00:00:00 GMT
+ Nix 1.1 released
+https://nixos.org/news.html
+ Nix 1.1
+ has been released. See the release
+ notes for details. For installation information, see the manual.
+Sat Aug 18 2012 00:00:00 GMT
+ Binary Nix tarballs available
+https://nixos.org/news.html
+ Our continuous build system, Hydra, now produces binary
+ tarball distributions of Nix for Mac OS X (Darwin), FreeBSD
+ and Linux. The tarballs contain all dependencies of Nix, making
+ it a lot easier to install Nix on those platforms. To install,
+ download a binary tarball, unpack it in the root directory, then
+ run nix-finish-install. See the manual
+ for more information.
+Sun Jun 24 2012 00:00:00 GMT
+ Nix 1.0 released
+https://nixos.org/news.html
+ After almost two years of development, Nix 1.0
+ has been released. See the release
+ notes for an overview of the most important improvements.
+ For installation information, see the manual.
+Mon Jun 11 2012 00:00:00 GMTPatchELF 0.6 releasedhttps://nixos.org/news.html
+ PatchELF
+ 0.6 has been released. Apart from some bug fixes, it adds
+ support for executables produced by the Gold linker. See the README
+ for details.
+Wed Dec 07 2011 00:00:00 GMTHydra talk at Inriahttps://nixos.org/news.html
+
+
+
+ Ludovic Courtès gave a talk on Hydra at Inria (which has
+ its own Hydra instance for building Inria software) entitled “Hydra:
+ continuous integration for demanding peopleâ€.
+Sat Dec 03 2011 00:00:00 GMTMoving to GitHubhttps://nixos.org/news.html
+ The NixOS project is (slowly) migrating from Subversion to Git!
+ The master repositories will be hosted in the NixOS organization on GitHub. For the moment, just a
+ few subprojects have been migrated, such as Hydra and Charon. Thanks to
+ Tianyi Cui for donating the NixOS GitHub organization.
+Mon Nov 28 2011 00:00:00 GMT
+ Nix-dev mailing list moved
+https://nixos.org/news.html
+ The nix-dev mailing list has moved. The address is now
+ nix-dev@lists.science.uu.nl (web
+ interface).
+Fri Oct 14 2011 00:00:00 GMT
+ FOSDEM talk about NixOS
+https://nixos.org/news.html
+
+ Sander van der
+ Burg gave a talk about NixOS at the CrossDistro
+ track of FOSDEM (video, slides).
+Sat Mar 05 2011 00:00:00 GMT
+ ISSRE paper on NixOS-based system testing
+https://nixos.org/news.html
+ The paper “Automating System
+ Tests Using Declarative Virtual Machines†(by Sander van der
+ Burg and Eelco Dolstra) has been accepted for presentation at
+ the 21st IEEE International
+ Symposium on Software Reliability Engineering (ISSRE 2010).
+ It describes how system tests with complex requirements on the
+ environment (such as remote machines, network topologies, system
+ services or root privileges) can be written succinctly using declarative
+ specifications of the machines needed by the test
+ environment. From these specifications we can automatically
+ instantiate (networks of) virtual machines. This is what we use
+ for automated
+ regression testing of NixOS itself. A draft
+ of the paper is available.
+Sat Sep 18 2010 00:00:00 GMT
+ Xfce in NixOS
+https://nixos.org/news.html
+
+ NixOS now supports Xfce, a
+ modern, light-weight desktop environment. It can be enabled by
+ setting the NixOS configuration value
+ services.xserver.desktopManager.xfce.enable to
+ true. (Screenshot)
+Sat Sep 18 2010 00:00:00 GMT
+ Nix 0.16 released
+https://nixos.org/news.html
+ Nix
+ 0.16 has been released, featuring a much faster evaluator
+ and support for configurable parallelism inside builders. See
+ the release
+ notes for details. For installation information, see the manual.
+Fri Sep 17 2010 00:00:00 GMT
+ NixOS talk at LSM
+https://nixos.org/news.html
+ Ludovic Courtès gave a talk about Nix and NixOS at the Libre Software Meeting
+ in Bordeaux, entitled “NixOS:
+ The Only Functional GNU/Linux Distribution†(slides).
+Mon Aug 09 2010 00:00:00 GMT
+ Nix 0.15 released
+https://nixos.org/news.html
+ Nix
+ 0.15 has been released. This is a bug fix release. See the
+ release
+ notes for details. For installation information, see the manual.
+Sat Apr 17 2010 00:00:00 GMT
+ Nix 0.14 released
+https://nixos.org/news.html
+ Nix
+ 0.14 has been released. This is primarily a bug fix
+ release. See the release
+ notes for details. For installation information, see the manual.
+Thu Mar 04 2010 00:00:00 GMT
+ Nix logo
+https://nixos.org/news.html
+
+ Long overdue, the Nix project finally has a logo!
+ The logo was originally created by Simon Frankau for the Haskell
+ logo competition, who kindly gave us permission to use it
+ for the Nix project. (The snowflake motif is even more
+ appropriate for Nix, because nix is Latin for
+ snow.) Any further modifications are entirely our
+ fault.
+Fri Dec 25 2009 00:00:00 GMT
+ Nix 0.13 released
+https://nixos.org/news.html
+ Nix
+ 0.13 has been released. This is mostly a bug fix release,
+ although it also adds some new language features. See the release
+ notes for details. For installation information, see the manual.
+Sat Dec 05 2009 00:00:00 GMT
+ LWN.net article on NixOS
+https://nixos.org/news.html
+ LWN.net has an article about NixOS
+ written by Koen Vervloesem.
+Sun Jul 26 2009 00:00:00 GMT
+ Nixpkgs 0.12 released
+https://nixos.org/news.html
+ Nixpkgs
+ 0.12 has been released. See the release
+ notes for details. Meanwhile, the Nixpkgs trunk has been
+ updated
+ to GCC 4.3.3, Glibc 2.9 and X.org 7.4.
+Sun May 24 2009 00:00:00 GMT
+ OpenOffice.org 3 in Nixpkgs
+https://nixos.org/news.html
+
+
+ LluÃs Batlle has updated OpenOffice.org in Nixpkgs to 3.0.1
+ (screenshot).
+Thu May 21 2009 00:00:00 GMT
+ KDE 4.2 in Nixpkgs/NixOS
+https://nixos.org/news.html
+
+
+ We now have a fairly complete set of KDE 4.2 packages in Nixpkgs
+ and NixOS. Previously we had KDE 3.5, but it was rather
+ incomplete: just kdelibs and kdebase.
+ Now we have all that desktop
+ goodness, such as kdemultimedia,
+ kdenetwork and kdegames. You can
+ enable KDE 4 in NixOS by setting the
+ services.xserver.sessionType option to
+ kde4. Thanks go to Yury G. Kudryashov, Andrew
+ Morsillo and Sander van der Burg for doing the hard work on
+ adding KDE 4 to Nixpkgs. (Screenshot 1,
+ screenshot
+ 2.)
+Thu May 07 2009 00:00:00 GMT
+ Hydra
+https://nixos.org/news.html
+
+
+
+ Nix
+ and NixOS
+ releases are now built in Hydra, the new Nix-based
+ continuous build system. Hydra replaces our old Nix-based
+ build farm, which will be phased out soon. There are
+ several advantages over the old build farm: the build tasks for
+ a project are scheduled and published separately, so that for
+ instance a (fast) tarball build doesn’t have to wait for a
+ (slow) Cygwin build; build results are stored in a database,
+ which will enable all sorts of interesting queries; better error
+ reporting; a better web interface; and much more. We have
+ written a draft
+ paper about Hydra. There are some instructions
+ available about how to set up your own Hydra server.
+Thu Feb 05 2009 00:00:00 GMT
+ Linux.com article about Nix
+https://nixos.org/news.html
+ There is an article on Linux.com about Nix: “Nix fixes dependency
+ hell on all Linux distributionsâ€.
+Thu Jan 22 2009 00:00:00 GMT
+ Nix 0.12 released
+https://nixos.org/news.html
+ Nix
+ 0.12 has been released. The most important change is that
+ Nix no longer needs Berkeley DB to store metadata, but there are
+ many other improvements. See the release
+ notes for details.
+Sun Dec 21 2008 00:00:00 GMT
+ DisNix paper accepted at HotSWUp
+https://nixos.org/news.html
+
+ The paper “Atomic Upgrading of Distributed Systems†(by Sander
+ van der Burg, Eelco Dolstra and Merijn de Jonge) has been
+ accepted for presentation at the First ACM Workshop on Hot
+ Topics in Software Upgrades (HotSWUp). A draft
+ of the paper is available. It describes Sander’s master’s
+ thesis research on DisNix, an extension to Nix that allows
+ deployment and upgrading of distributed systems from a single
+ declarative description. We will continue this research in
+ the Jacquard PDS
+ project, which has now started. (We still have an opening
+ for a PhD student or a postdoc; please contact us if you’re
+ interested.)
+
+Thu Oct 09 2008 00:00:00 GMT
+ NixOS paper accepted at ICFP!
+https://nixos.org/news.html
+
+ The paper “NixOS: A Purely Functional Linux Distribution†(by
+ Eelco Dolstra and Andres Löh) has been accepted
+ for presentation at the 2008
+ International Conference on Functional Programming (ICFP).
+ It describes NixOS in much greater detail than last year’s
+ HotOS paper, and argues why the purely functional style and
+ features such as laziness are important for system
+ configuration management. It also provides some measurements
+ on the actual purity of Nix build actions. A draft
+ of the paper is available.
+
+Wed Jul 16 2008 00:00:00 GMT
+ Website back up
+https://nixos.org/news.html
+
+ The Nix website was down for a few days due to cooling
+ problems in the server room causing the machine to overheat.
+ These should be resolved now. Apologies for the
+ inconvenience.
+
+Fri Jun 06 2008 00:00:00 GMT
+ Website / SVN repositories moved
+https://nixos.org/news.html
+
+Mon May 05 2008 00:00:00 GMT
+ Jacquard grant proposal accepted!
+https://nixos.org/news.html
+
+
+ The Jacquard program of
+ NWO and EZ has granted funding for the Nix-related project “Pull
+ Deployment of Services†(PDS), which is about improving the
+ deployment of software and services in complex heterogenous
+ environments. The grant consists of 368 K€ for a PhD student (4
+ years) and a postdoc (3 years). If you’re interested in these
+ positions, please have a look at this page,
+ and don’t hesitate to contact Eelco
+ Visser or Eelco Dolstra.
+
+
+Fri Mar 14 2008 00:00:00 GMT
+ New NixOS ISOs
+https://nixos.org/news.html
+
+
+
+
+ New NixOS installation CD images for i686 and
+ x86_64 are available,
+ which is a good thing as the previous ones were already a few
+ months old. The new images are Nix 0.11-based, contain Memtest86+ as a
+ convenience, should support more SATA drives, and show online
+ help (the NixOS
+ manual) on virtual console 7.
+
+
+Wed Feb 06 2008 00:00:00 GMT
+ Nix 0.11 released
+https://nixos.org/news.html
+ Nix
+ 0.11 has been released. This is a major new release
+ representing over a year of development. The most important
+ improvement is secure multi-user support. It also features many
+ usability enhancements and language extensions, many of them
+ prompted by NixOS, the purely functional Linux distribution
+ based on Nix. See the release
+ notes for details.
+Thu Jan 31 2008 00:00:00 GMT
+ Nixpkgs 0.11 released
+https://nixos.org/news.html
+ Nixpkgs
+ 0.11 has been released. See the release
+ notes for details.
+Fri Oct 12 2007 00:00:00 GMT
+ OpenOffice in Nixpkgs
+https://nixos.org/news.html
+
+
+
+
+ OpenOffice is now in
+ Nixpkgs (screenshot of
+ OpenOffice 2.2.1 running under NixOS, and another
+ screenshot). Despite being a rather gigantic package (it
+ takes two hours to compile on an Intel Core 2 6700), OpenOffice
+ had only two “impurities†(references to paths outside of the
+ Nix store) in its build
+ process that had to be resolved — a reference to
+ /bin/bash and one to /usr/lib/libjpeg.so.
+
+
Armijn Hemel, Wouter den
+ Breejen and Eelco Dolstra contributed to the Nix expression for
+ OpenOffice.
+
+
+
+ Wine now runs on NixOS!
+ Finally we can run all those legacy
+ applications... Thanks to Michael Raskin for adding Wine
+ and a NPTL-enabled Glibc (which Wine seems to need). This is a
+ nice application of purely functional package composition, by
+ the way: Wine didn’t work with the standard Glibc in Nixpkgs, so
+ we just pass
+ it another Glibc at build time.
+
+
In other news, Nix 0.11
+ and Nixpkgs 0.11 will be released soon.
+ There is now a mailing
+ list (nix-commits@cs.uu.nl) that you can
+ subscribe to if you want to receive automatic commit
+ notifications from the Nix Subversion repository.
+
+Fri Sep 14 2007 00:00:00 GMT
+ HotOS paper on NixOS
+https://nixos.org/news.html
+
+
+
+
+ We now have KDE running on
+ NixOS (obligatory
+ screenshot). Just kdebase for now (Martin
+ Bravenboer already added kdelibs a long time ago so
+ that we could run the wonderful KCachegrind),
+ but it contains all the important stuff (Konqueror, KDesktop,
+ Kicker, Konsole, Control Center, etc.).
+
+
In related news, we can
+ safely say that, rumours to the contrary notwithstanding, NixOS
+ is not an April
+ Fools’ Joke.
+
+Wed May 02 2007 00:00:00 GMT
+ NixOS progress report
+https://nixos.org/news.html
+
+
+ NixOS is now almost usable as a desktop OS ;-). We
+ have an X server, a bunch of Gnome packages, basic wireless
+ support, and of course all the applications in Nixpkgs that we
+ had all along running on other Linux distributions. Here are a
+ few screenshots:
+
+Thu Apr 05 2007 00:00:00 GMT
+ NixOS manual
+https://nixos.org/news.html
+ There is now some basic
+ documentation for NixOS.
+Mon Mar 19 2007 00:00:00 GMT
+ NixOS for x86_64
+https://nixos.org/news.html
+ NixOS now works on x86_64 machines. A 64-bit ISO is available.
+Fri Feb 23 2007 00:00:00 GMT
+ New build farm hardware at TUD
+https://nixos.org/news.html
+
To quote Eelco Visser: new
+ hardware for buildfarm at Delft University of Technology has
+ arrived.
+
+
Here’s what we have: 5 Intel Core 2 Duo DualCore machines
+ with 1GB RAM, 2 Mac minis with 1,83-GHz Intel Core
+ Duo-processor, another Core 2 Duo a UPS to deal with spikes in
+ power supply, a console with integrated monitor and keyboard
+ switches, a rack with room for a couple more machines.
+
+
Here’s what we’re going to do with the goodies. The five
+ Intel machines and the two MacMinis (also Intel) are going to
+ be used to crank at building hundreds of software
+ packages. Using virtualisation we should be able to run builds
+ on multiple operating system distributions. Read
+ more…
+Fri Feb 23 2007 00:00:00 GMT
+ Nixpkgs 0.10 released
+https://nixos.org/news.html
+ Nixpkgs
+ 0.10 has been released. See the release
+ notes for details.
+Sun Nov 12 2006 00:00:00 GMT
+ Nix 0.10.1 released
+https://nixos.org/news.html
+ Nix
+ 0.10.1 has been released. It fixes two obscure bugs that
+ shouldn’t affect most users.
+Sat Nov 11 2006 00:00:00 GMT
+ Nix 0.10 released
+https://nixos.org/news.html
+ Nix
+ 0.10 has been released. This release has many
+ improvements and bug fixes; see the release
+ notes for details.
+Mon Nov 06 2006 00:00:00 GMT
+ Nixpkgs 0.9 released
+https://nixos.org/news.html
+ Nixpkgs
+ 0.9 has been released.
+Fri Mar 03 2006 00:00:00 GMT
+ PhD thesis defended
+https://nixos.org/news.html
+ Eelco Dolstra
+ defended his PhD
+ thesis on the purely functional deployment model.
+Sat Feb 18 2006 00:00:00 GMT
+ Nix 0.9.2 released
+https://nixos.org/news.html
+ Nix
+ 0.9.2 has been released released. This is a bug fix
+ release that addresses some problems on Mac OS X.
+Fri Oct 21 2005 00:00:00 GMT
+ Nix 0.9 released
+https://nixos.org/news.html
+ Nix 0.9
+ has been released. This is a new major release that provides
+ quite a few performance improvements and bug fixes, as well as a
+ number of new features. Read the release
+ notes for details.
+Sun Oct 16 2005 00:00:00 GMT
+ Secure sharing paper accepted for ASE 2005
+https://nixos.org/news.html
+ The paper “Secure Sharing Between Untrusted Users in a
+ Transparent Source/Binary Deployment Model†has been accepted at
+ ASE 2005. This
+ paper describes how a Nix store can be securely shared by
+ multiple users who may not trust each other; i.e., how do we
+ prevent one user from installing a Trojan horse that is
+ subsequently executed by some other user?
+Sun Aug 28 2005 00:00:00 GMT
+ Service deployment paper accepted for SCM-12
+https://nixos.org/news.html
+ The paper “Service Configuration Management†(accepted at the
+ 12th
+ International Workshop on Software Configuration
+ Management) describes how we can rather easily deploy
+ “services†(e.g., complete webserver configurations such as our
+ Subversion server) through
+ Nix by treating the non-component parts (such as configuration
+ files, control scripts and static data) as components that are
+ built by Nix expressions. The result is that all advantages
+ that Nix offers to software deployment also extend to service
+ deployment, such as the ability to easily have multiple
+ configuration side by side, to roll back configurations, and to
+ identify the precise dependencies of a configuration.
+Mon Aug 22 2005 00:00:00 GMT
+ Patching paper accepted for CBSE 2005
+https://nixos.org/news.html
+ The paper “Efficient Upgrading in a Purely Functional Component
+ Deployment Model†has been accepted at CBSE 2005.
+ It describes how we can deploy updates to Nix packages
+ efficiently, even if “fundamental†packages like Glibc are
+ updated (which cause a rebuild of all dependent packages), by
+ deploying binary patches between components in the Nix store.
+ Includes techniques such as patch chaining and computing deltas
+ between archive files.
+Thu Mar 17 2005 00:00:00 GMT
+ Paper “Imposing a Memory Management Discipline on Software
+ Deployment†accepted for presentation at ICSE 2004!
+https://nixos.org/news.html
+ The first Nix paper.
+Fri Jan 16 2004 00:00:00 GMT
diff --git a/app/src/androidTest/resources/com/nononsenseapps/feeder/ui/cowboy_feed.json b/app/src/androidTest/resources/com/nononsenseapps/feeder/ui/cowboy_feed.json
new file mode 100644
index 0000000..2e3ccf6
--- /dev/null
+++ b/app/src/androidTest/resources/com/nononsenseapps/feeder/ui/cowboy_feed.json
@@ -0,0 +1,104 @@
+{
+ "version": "https://jsonfeed.org/version/1",
+ "title": "Cowboy Programmer",
+ "home_page_url": "https://cowboyprogrammer.org/",
+ "feed_url": "https://cowboyprogrammer.org/feed.json",
+ "author": {
+ "name": "Space Cowboy",
+ "avatar": "https://cowboyprogrammer.org/css/images/avatar.png"
+ },
+ "icon": "https://cowboyprogrammer.org/css/images/logo.png",
+
+ "items": [
+
+ {
+ "id": "https://cowboyprogrammer.org/2018/03/fixed-vs-variable-interest-rates/",
+ "url": "https://cowboyprogrammer.org/2018/03/fixed-vs-variable-interest-rates/",
+ "title": "A comparison between fixed and variable interest rates",
+ "content_html": "\u003cp\u003eThe data I am using is originally from \u003ca href=\"http://hypotek.swedbank.se/rantor/historiska-rantor/\"\u003eSwedBank\u003c/a\u003e and all data and\ncode is available at \u003ca href=\"https://gitlab.com/spacecowboy/swedish-interest-rates\"\u003eGitLab\u003c/a\u003e. \u003ca href=\"https://gitlab.com/spacecowboy/swedish-interest-rates/raw/master/swedish_interest_rates.csv\"\u003eThe data\u003c/a\u003e contains interest\nrates at 5 years fixed term, 2 years fixed term, and 3 months fixed\nterm (also called variable rate in Sweden) for those dates when any\nrate was changed. The first rates are from 1989-11-01 and the last are\nfrom 2018-02-12. Example of the data:\u003c/p\u003e\n\n\u003ctable border=\"1\" class=\"dataframe\"\u003e\n \u003cthead\u003e\n \u003ctr style=\"text-align: right;\"\u003e\n \u003cth\u003e\u003c/th\u003e\n \u003cth\u003e5y\u003c/th\u003e\n \u003cth\u003e2y\u003c/th\u003e\n \u003cth\u003e3m\u003c/th\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003eDate\u003c/th\u003e\n \u003cth\u003e\u003c/th\u003e\n \u003cth\u003e\u003c/th\u003e\n \u003cth\u003e\u003c/th\u003e\n \u003c/tr\u003e\n \u003c/thead\u003e\n \u003ctbody\u003e\n \u003ctr\u003e\n \u003cth\u003e1989-11-22\u003c/th\u003e\n \u003ctd\u003e13.50\u003c/td\u003e\n \u003ctd\u003e13.50\u003c/td\u003e\n \u003ctd\u003e12.75\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e1991-01-14\u003c/th\u003e\n \u003ctd\u003e14.00\u003c/td\u003e\n \u003ctd\u003e14.75\u003c/td\u003e\n \u003ctd\u003e15.25\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e1993-01-13\u003c/th\u003e\n \u003ctd\u003e12.75\u003c/td\u003e\n \u003ctd\u003e13.00\u003c/td\u003e\n \u003ctd\u003e13.75\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e1994-11-21\u003c/th\u003e\n \u003ctd\u003e11.75\u003c/td\u003e\n \u003ctd\u003e11.50\u003c/td\u003e\n \u003ctd\u003e9.75\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e1996-03-12\u003c/th\u003e\n \u003ctd\u003e9.85\u003c/td\u003e\n \u003ctd\u003e8.95\u003c/td\u003e\n \u003ctd\u003e9.10\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2005-09-09\u003c/th\u003e\n \u003ctd\u003e3.55\u003c/td\u003e\n \u003ctd\u003e2.97\u003c/td\u003e\n \u003ctd\u003e3.15\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2005-10-03\u003c/th\u003e\n \u003ctd\u003e3.69\u003c/td\u003e\n \u003ctd\u003e3.09\u003c/td\u003e\n \u003ctd\u003e3.15\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2007-12-21\u003c/th\u003e\n \u003ctd\u003e5.36\u003c/td\u003e\n \u003ctd\u003e5.25\u003c/td\u003e\n \u003ctd\u003e5.15\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2008-01-24\u003c/th\u003e\n \u003ctd\u003e5.13\u003c/td\u003e\n \u003ctd\u003e4.94\u003c/td\u003e\n \u003ctd\u003e5.15\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2009-03-20\u003c/th\u003e\n \u003ctd\u003e4.26\u003c/td\u003e\n \u003ctd\u003e2.83\u003c/td\u003e\n \u003ctd\u003e2.20\u003c/td\u003e\n \u003c/tr\u003e\n \u003c/tbody\u003e\n\u003c/table\u003e\n\n\u003cp\u003eTo make the calculations more convenient I assume that loans are only\nfixed the first day of the month. Example:\u003c/p\u003e\n\n\u003ctable border=\"1\" class=\"dataframe\"\u003e\n \u003cthead\u003e\n \u003ctr style=\"text-align: right;\"\u003e\n \u003cth\u003e\u003c/th\u003e\n \u003cth\u003e5y\u003c/th\u003e\n \u003cth\u003e2y\u003c/th\u003e\n \u003cth\u003e3m\u003c/th\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003eDate\u003c/th\u003e\n \u003cth\u003e\u003c/th\u003e\n \u003cth\u003e\u003c/th\u003e\n \u003cth\u003e\u003c/th\u003e\n \u003c/tr\u003e\n \u003c/thead\u003e\n \u003ctbody\u003e\n \u003ctr\u003e\n \u003cth\u003e1990-06-01\u003c/th\u003e\n \u003ctd\u003e14.50\u003c/td\u003e\n \u003ctd\u003e14.50\u003c/td\u003e\n \u003ctd\u003e13.95\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e1992-03-01\u003c/th\u003e\n \u003ctd\u003e12.50\u003c/td\u003e\n \u003ctd\u003e13.00\u003c/td\u003e\n \u003ctd\u003e14.75\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e1993-06-01\u003c/th\u003e\n \u003ctd\u003e10.75\u003c/td\u003e\n \u003ctd\u003e10.50\u003c/td\u003e\n \u003ctd\u003e11.50\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e1998-02-01\u003c/th\u003e\n \u003ctd\u003e6.70\u003c/td\u003e\n \u003ctd\u003e6.40\u003c/td\u003e\n \u003ctd\u003e5.80\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2001-09-01\u003c/th\u003e\n \u003ctd\u003e6.55\u003c/td\u003e\n \u003ctd\u003e5.95\u003c/td\u003e\n \u003ctd\u003e5.90\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2004-11-01\u003c/th\u003e\n \u003ctd\u003e4.85\u003c/td\u003e\n \u003ctd\u003e3.90\u003c/td\u003e\n \u003ctd\u003e3.65\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2009-05-01\u003c/th\u003e\n \u003ctd\u003e4.15\u003c/td\u003e\n \u003ctd\u003e2.73\u003c/td\u003e\n \u003ctd\u003e1.97\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2010-08-01\u003c/th\u003e\n \u003ctd\u003e3.99\u003c/td\u003e\n \u003ctd\u003e2.90\u003c/td\u003e\n \u003ctd\u003e2.17\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2011-05-01\u003c/th\u003e\n \u003ctd\u003e5.29\u003c/td\u003e\n \u003ctd\u003e4.39\u003c/td\u003e\n \u003ctd\u003e3.88\u003c/td\u003e\n \u003c/tr\u003e\n \u003ctr\u003e\n \u003cth\u003e2011-11-01\u003c/th\u003e\n \u003ctd\u003e4.59\u003c/td\u003e\n \u003ctd\u003e4.14\u003c/td\u003e\n \u003ctd\u003e4.35\u003c/td\u003e\n \u003c/tr\u003e\n \u003c/tbody\u003e\n\u003c/table\u003e\n\n\u003cp\u003eIf we graph the interest rates we get:\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2018/03/rates.en.png\" alt=\"Interest rates over time\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eYou can see a clear peak in the variable rate when the riksbank set\nthe repo rate at 500% (mortgages \u0026ldquo;only\u0026rdquo; reached 24%). You can also see\nthat during the early nineties the variable rate was higher than the\nfixed rates during relatively long periods. But to compare the actual\ncost over the fixed term we have to compare average rates.\u003c/p\u003e\n\n\u003cp\u003eFor example, let us compare the actual average rates from the first of\nJuly 1991 during 5 years for variable rate (11.96%) and 5 years fixed\nterm (12.25%). Even though with variable rate you\u0026rsquo;d have had a rate of\n24% during a quarter you\u0026rsquo;d still pay less in total over the 5 years.\u003c/p\u003e\n\n\u003cp\u003eIf the same calculation is made for every month you can see how much\nyou would have earned/lost depending on when you started your fixed\nterm. Since 5 years is not evenly divisible by 2 years, the 2 years\nfixed term refers to what the average rate would have been during the\nfirst 5 of the 6 years.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2018/03/5y_avg_rates.en.png\" alt=\"Average interest rate over 5 years\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eIt\u0026rsquo;s quite clear that variable rate has nearly always been the most\nprofitable alternative. At three seperate occasions it would have been\nmore profitable to pick a 5 year fixed term: at the of 1989, the\nbeginning of 1997, and in the middle of 2005. I won\u0026rsquo;t comment on the 2\nyears fixed term since it\u0026rsquo;s not a fair comparison to only look at 5 out of\n6 years.\u003c/p\u003e\n\n\u003cp\u003eIf we compare 2 years fixed term with variable rate:\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2018/03/2y_avg_rates.en.png\" alt=\"Average interest rate over 2 years\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eAlso here the most profitable choice is generally the variable rate\nhowever during times of rising interest rates getting a fixed 2 year\nterm has been the better choice on several occasions. An important\ndifference to the 5 years term is that you\u0026rsquo;re not locked in for long\nwhen the rates finally go down again (and you\u0026rsquo;re able to switch to\nvariable rate).\u003c/p\u003e\n\n\u003cp\u003eIf we compare all terms during 10 years:\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2018/03/10y_avg_rates.en.png\" alt=\"Average interest rate over 10 years\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eHere it is clear that the variable rate is the most profitable.\u003c/p\u003e\n\n\u003cp\u003eEven though it has been possible at certain occasions (29 years and\nonly 3 short occasions!) to get a fixed term for 5 years and pay less\noverall than with variable rate, I think it\u0026rsquo;s far too improbable that\none is able to do it at the right time. You\u0026rsquo;re almost guaranteed to be\npaying more in the end.\u003c/p\u003e\n\n\u003cp\u003eGetting a fixed term for 2 years is more probable to be profitable,\nbut even here it is more probable not to be.\u003c/p\u003e\n",
+ "date_published": "2018-03-05T23:00:00+02:00",
+ "image": "https://cowboyprogrammer.org/images/2018/03/5y_avg_rates.en.png"
+ }
+
+ , {
+ "id": "https://cowboyprogrammer.org/2016/10/reduce-colors-in-images/",
+ "url": "https://cowboyprogrammer.org/2016/10/reduce-colors-in-images/",
+ "title": "Reduce the size of images even further by reducing number of colors with Gimp",
+ "content_html": "\n\n\u003cp\u003eIn Gimp you go to \u003cem\u003eImage\u003c/em\u003e in the top menu bar and select \u003cem\u003eMode\u003c/em\u003e\nfollowed by \u003cem\u003eIndexed\u003c/em\u003e. Now you see a popup where you can select the\nnumber of colors for a generated optimum palette.\u003c/p\u003e\n\n\u003cp\u003eYou\u0026rsquo;ll have to experiment a little because it will depend on your\nimage.\u003c/p\u003e\n\n\u003cp\u003eI used this approach to shrink the size of the cover image in\n\u003ca href=\"/2016/08/zopfli_all_the_things/\"\u003ethe_zopfli post\u003c/a\u003e from a 37KB (JPG) to just 15KB\n(PNG, all PNG sizes listed include Zopfli compression btw).\u003c/p\u003e\n\n\u003ch2 id=\"straight-jpg-to-png-conversion-124kb\"\u003eStraight JPG to PNG conversion: 124KB\u003c/h2\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2017/10/zopfli_all_the_things.png\" alt=\"PNG version RGB colors\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eFirst off, I exported the JPG file as a PNG file. This PNG file had a\nwhopping 124KB! Clearly there was some bloat being stored.\u003c/p\u003e\n\n\u003ch2 id=\"256-colors-40kb\"\u003e256 colors: 40KB\u003c/h2\u003e\n\n\u003cp\u003eReducing from RGB to only 256 colors has no visible effect to my eyes.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2017/10/zopfli_all_the_things_256.png\" alt=\"256 colors\" /\u003e\u003c/p\u003e\n\n\u003ch2 id=\"128-colors-34kb\"\u003e128 colors: 34KB\u003c/h2\u003e\n\n\u003cp\u003eStill no difference.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2017/10/zopfli_all_the_things_128.png\" alt=\"128 colors\" /\u003e\u003c/p\u003e\n\n\u003ch2 id=\"64-colors-25kb\"\u003e64 colors: 25KB\u003c/h2\u003e\n\n\u003cp\u003eYou can start to see some artifacting in the shadow behind the text.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2017/10/zopfli_all_the_things_64.png\" alt=\"64 colors\" /\u003e\u003c/p\u003e\n\n\u003ch2 id=\"32-colors-15kb\"\u003e32 colors: 15KB\u003c/h2\u003e\n\n\u003cp\u003eIn my opinion this is the sweet spot. The shadow artifacting is barely\nnoticable but the size is significantly reduced.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2017/10/zopfli_all_the_things_32.png\" alt=\"32 colors\" /\u003e\u003c/p\u003e\n\n\u003ch2 id=\"16-colors-11kb\"\u003e16 colors: 11KB\u003c/h2\u003e\n\n\u003cp\u003eClear artifacting in the text shadow and the yellow (fire?) in the\nbackground has developed an outline.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2017/10/zopfli_all_the_things_16.png\" alt=\"16 colors\" /\u003e\u003c/p\u003e\n\n\u003ch2 id=\"8-colors-7-3kb\"\u003e8 colors: 7.3KB\u003c/h2\u003e\n\n\u003cp\u003eThe broom has shifted in color from a clear brown to almost grey. Text\nshadow is just a grey blob at this point. Even clearer outline\ndeveloped on the yellow background.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2017/10/zopfli_all_the_things_8.png\" alt=\"8 colors\" /\u003e\u003c/p\u003e\n\n\u003ch2 id=\"4-colors-4-3kb\"\u003e4 colors: 4.3KB\u003c/h2\u003e\n\n\u003cp\u003eInterestingly enough, I think 4 colors looks better than 8 colors. The outline in the background has disappeared because there\u0026rsquo;s not enough color spectrum to render it. The broom is now black and filled areas tend to get a white separator to the outlines.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2017/10/zopfli_all_the_things_4.png\" alt=\"4 colors\" /\u003e\u003c/p\u003e\n\n\u003ch2 id=\"2-colors-2-4kb\"\u003e2 colors: 2.4KB\u003c/h2\u003e\n\n\u003cp\u003eWell, at least the silhouette is well defined at this point I guess.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2017/10/zopfli_all_the_things_2.png\" alt=\"2 colors\" /\u003e\u003c/p\u003e\n",
+ "date_published": "2016-10-21T00:27:00+02:00",
+ "image": "https://cowboyprogrammer.org/images/2017/10/gimp_image_mode_index.png"
+ }
+
+ , {
+ "id": "https://cowboyprogrammer.org/2016/10/dont-start-service-on-install-of-debian-package/",
+ "url": "https://cowboyprogrammer.org/2016/10/dont-start-service-on-install-of-debian-package/",
+ "title": "Don't start service on installation of Debian package",
+ "content_html": "\u003cp\u003eA clear difference between Debian/Ubuntu and for example Red\nHat/Fedora is that packages which include system services will enable\nand start those services at install time in Debian/Ubuntu whereas they\nwill not start automatically in Red Hat/Fedora.\u003c/p\u003e\n\n\u003cp\u003eSometimes it would be very convenient if the service would \u003cem\u003enot\u003c/em\u003e start\nautomatically, for example if you need to configure the service before\nstarting it for the first time.\u003c/p\u003e\n\n\u003cp\u003eTo prevent the automatic start of system services at install time in\nDebian, just set the \u003ccode\u003eRUNLEVEL\u003c/code\u003e environment variable like so:\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003eRUNLEVEL=1 apt install -y PKG_NAME\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003cp\u003eThen you are free to configure your system before you start the\nservice for real:\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003esystemctl enable PKG_NAME\nsystemctl start PKG_NAME\n\u003c/code\u003e\u003c/pre\u003e\n",
+ "date_published": "2016-10-19T00:00:00+02:00",
+ "image": "https://cowboyprogrammer.org/images/Ardebian_logo_512_0.png"
+ }
+
+ , {
+ "id": "https://cowboyprogrammer.org/2016/09/reboot_machine_on_wrong_password/",
+ "url": "https://cowboyprogrammer.org/2016/09/reboot_machine_on_wrong_password/",
+ "title": "Rebooting on wrong password",
+ "content_html": "\n\n\u003cp\u003eHaving an encrypted hard drive is all well and good, but chances are\nthat if someone is gonna steal your laptop, it\u0026rsquo;s probably not going to\nbe turned off. Most likely, it will be stolen in a powered-on\nstate. And so your encrypted hard drive doesn\u0026rsquo;t increase your security\nat all since it\u0026rsquo;s currently unlocked.\u003c/p\u003e\n\n\u003cp\u003eIn my mind, it\u0026rsquo;s a slight improvement if the computer somehow can\nshutdown if someone is trying to gain access to it. That way, the hard\ndrive is no longer accessible and the number of possible attack\nvectors go down drastically. And so, if you type the wrong password 3\ntimes on my laptop, it shuts down.\u003c/p\u003e\n\n\u003cp\u003eThis is accomplished by using \u003ccode\u003ePAM\u003c/code\u003e, and its ability to invoke an\narbitrary script as part of the login flow via \u003ccode\u003epam_exec.so\u003c/code\u003e. The\nscript itself looks like this:\u003c/p\u003e\n\u003cdiv class=\"highlight\" style=\"background: #f0f0f0\"\u003e\u003cpre style=\"line-height: 125%\"\u003e\u003cspan\u003e\u003c/span\u003e\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e#!/bin/bash\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# Do not add -eu, you need to allow empty variables here!\u003c/span\u003e\n\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# To be used with PAM. Look in /etc/pam.d for the script that your\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# screensaver etc uses. Typically it references common-account and common-auth.\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e#\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# In common-auth, add this as the first line\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e#auth optional pam_exec.so debug /path/to/wrongpassword.sh\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e#\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# In common-account, add this as the first line\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e#account required pam_exec.so debug /path/to/wrongpassword.sh\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e#\u003c/span\u003e\n\n\u003cspan style=\"color: #bb60d5\"\u003eCOUNTFILE\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;/var/log/failed_login_count\u0026quot;\u003c/span\u003e\n\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# Make sure file exists\u003c/span\u003e\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003eif\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e[\u003c/span\u003e ! -f \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003eCOUNFILE\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e]\u003c/span\u003e;\u003cspan style=\"color: #007020; font-weight: bold\"\u003ethen\u003c/span\u003e\n touch \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003eCOUNTFILE\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\n chmod \u003cspan style=\"color: #40a070\"\u003e777\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003eCOUNTFILE\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003efi\u003c/span\u003e\n\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# Read value in it\u003c/span\u003e\n\u003cspan style=\"color: #bb60d5\"\u003eCOUNT\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #007020; font-weight: bold\"\u003e$(\u003c/span\u003ecat \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003eCOUNTFILE\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #007020; font-weight: bold\"\u003e)\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# Increment it\u003c/span\u003e\n\u003cspan style=\"color: #bb60d5\"\u003eCOUNT\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #007020; font-weight: bold\"\u003e$((\u003c/span\u003eCOUNT+1\u003cspan style=\"color: #007020; font-weight: bold\"\u003e))\u003c/span\u003e\n\u003cspan style=\"color: #007020\"\u003eecho\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003eCOUNT\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e \u0026gt; \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003eCOUNTFILE\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\n\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# if authentication\u003c/span\u003e\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003eif\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e[\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003ePAM_TYPE\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e==\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;auth\u0026quot;\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e]\u003c/span\u003e; \u003cspan style=\"color: #007020; font-weight: bold\"\u003ethen\u003c/span\u003e\n \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# The count will be at 4 after 3 wrong tries\u003c/span\u003e\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003eif\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e[\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003eCOUNT\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e -ge \u003cspan style=\"color: #40a070\"\u003e4\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e]\u003c/span\u003e; \u003cspan style=\"color: #007020; font-weight: bold\"\u003ethen\u003c/span\u003e\n \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# Shutdown in 1 min\u003c/span\u003e\n \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e#/usr/bin/shutdown --no-wall -h +1\u003c/span\u003e\n \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# This is a hack because the line above gives a segfault in logind\u003c/span\u003e\n \u003cspan style=\"color: #007020\"\u003eecho\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;0\u0026quot;\u003c/span\u003e \u0026gt; \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003eCOUNTFILE\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\n systemctl poweroff\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003efi\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# If authentication succeeded, and we are now in account phase\u003c/span\u003e\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003eelif\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e[\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003ePAM_TYPE\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e==\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;account\u0026quot;\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e]\u003c/span\u003e; \u003cspan style=\"color: #007020; font-weight: bold\"\u003ethen\u003c/span\u003e\n \u003cspan style=\"color: #007020\"\u003eecho\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;0\u0026quot;\u003c/span\u003e \u0026gt; \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e${\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003eCOUNTFILE\u003c/span\u003e\u003cspan style=\"color: #70a0d0; font-style: italic\"\u003e}\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\n \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# Cancel shutdown which was just issued\u003c/span\u003e\n shutdown -c\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003efi\u003c/span\u003e\n\n\u003cspan style=\"color: #007020\"\u003eexit\u003c/span\u003e \u003cspan style=\"color: #40a070\"\u003e0\u003c/span\u003e\n\u003c/pre\u003e\u003c/div\u003e\n\n\u003cp\u003eOn my Debian system, PAM ends up looking at \u003ccode\u003e/etc/pam.d/common-auth\u003c/code\u003e\nand \u003ccode\u003e/etc/pam.d/common-account\u003c/code\u003e. These are invoked in different parts\nof the authentication flow. In \u003ccode\u003ecommon-auth\u003c/code\u003e, add this as the first\nline:\u003c/p\u003e\n\u003cdiv class=\"highlight\" style=\"background: #f0f0f0\"\u003e\u003cpre style=\"line-height: 125%\"\u003e\u003cspan\u003e\u003c/span\u003eauth optional pam_exec.so debug /path/to/wrongpassword.sh\n\u003c/pre\u003e\u003c/div\u003e\n\n\u003cp\u003eAnd then in \u003ccode\u003ecommon-account\u003c/code\u003e, add this as the first line:\u003c/p\u003e\n\u003cdiv class=\"highlight\" style=\"background: #f0f0f0\"\u003e\u003cpre style=\"line-height: 125%\"\u003e\u003cspan\u003e\u003c/span\u003eaccount required pam_exec.so debug /path/to/wrongpassword.sh\n\u003c/pre\u003e\u003c/div\u003e\n\n\u003cp\u003eYou can try it immediately if it works. Lock your screen, and type the\nwrong password 4 times. If it works, your computer should shut down.\u003c/p\u003e\n\n\u003ch2 id=\"warning-do-not-enable-on-servers\"\u003eWARNING: DO NOT ENABLE ON SERVERS\u003c/h2\u003e\n\n\u003cp\u003eThis is \u003cstrong\u003eNOT\u003c/strong\u003e something you want to do on any machine. Most notably,\nit\u0026rsquo;s probably a huge mistake to copy this verbatim on a machine which\naccepts remote connections. In that case, you essentially enable\nanyone to DOS you by entering the wrong password via SSH or\nsimilarly. So don\u0026rsquo;t do this if you allow remote connections to your\nmachine (which shouldn\u0026rsquo;t be a thing on a laptop).\u003c/p\u003e\n",
+ "date_published": "2016-09-28T22:57:21+02:00"
+ }
+
+ , {
+ "id": "https://cowboyprogrammer.org/2016/08/zopfli_all_the_things/",
+ "url": "https://cowboyprogrammer.org/2016/08/zopfli_all_the_things/",
+ "title": "Compress all the images!",
+ "content_html": "\n\n\u003cp\u003e\u003cem\u003eUpdate 2016-11-22: Made the Makefile compatible with BSD sed (MacOS)\u003c/em\u003e\u003c/p\u003e\n\n\u003cp\u003eOne advantage that static sites, such as those built by \u003ca href=\"https://gohugo.io\"\u003eHugo\u003c/a\u003e,\nprovide is fast loading times. Because there is no processing to be\ndone, no server side rendering, no database lookups, loading times are\njust as fast as you can serve the files that make up the page. This\nmeans that bandwidth becomes the primary bottleneck, which\nincidentally is\n\u003ca href=\"https://webmasters.googleblog.com/2010/04/using-site-speed-in-web-search-ranking.html\"\u003eone of the factors used by Google to calculate your search ranking\u003c/a\u003e. See\nalso\n\u003ca href=\"https://developers.google.com/speed/pagespeed/insights\"\u003ePagespeed Insights\u003c/a\u003e.\u003c/p\u003e\n\n\u003ch2 id=\"compressing-images\"\u003eCompressing images\u003c/h2\u003e\n\n\u003cp\u003eBecause the largest pieces of a page typically consist of images, it\nstands to reason that if we can make the images smaller, we can make\nthe page load faster. Luckily there exists methods that can compress\nimages \u003cem\u003elosslessly\u003c/em\u003e. That means that the quality stays exactly the\nsame, the page only loads faster. That seemed like a no-brainer to me\nso I compressed all the images on the site using \u003ca href=\"http://advsys.net/ken/utils.htm\"\u003ePNGout\u003c/a\u003e as\n\u003ca href=\"https://blog.codinghorror.com/getting-the-most-out-of-png/\"\u003eadvised by Jeff Atwood\u003c/a\u003e. I mean, who doesn\u0026rsquo;t\nlike free bandwidth?\u003c/p\u003e\n\n\u003cp\u003eA new algorithm called \u003ca href=\"https://github.com/google/zopfli\"\u003eZopfli\u003c/a\u003e (open sourced by Google,\n\u003ca href=\"https://blog.codinghorror.com/zopfli-optimization-literally-free-bandwidth/\"\u003ealso mentioned by Jeff\u003c/a\u003e) claims even better\nresults than PNGout though. Results on this site\u0026rsquo;s images confirm\nthose claims. Running the tool on images \u003cem\u003ealready compressed by\nPNGout\u003c/em\u003e gives output such as this:\u003c/p\u003e\n\u003cdiv class=\"highlight\" style=\"background: #f0f0f0\"\u003e\u003cpre style=\"line-height: 125%\"\u003e\u003cspan\u003e\u003c/span\u003e./zopflipng --prefix=\u0026quot;zopfli_\u0026quot; static/images/2014/Dec/Screenshot-from-2014-12-29-13-28-29.png\nOptimizing static/images/2014/Dec/Screenshot-from-2014-12-29-13-28-29.png\nInput size: 89420 (87K)\nResult size: 90361 (88K). Percentage of original: 101.052%\nPreserving original PNG since it was smaller\n\n./zopflipng --prefix=\u0026quot;zopfli_\u0026quot; static/images/2014/Jun/Jenkins_install_git.png\nOptimizing static/images/2014/Jun/Jenkins_install_git.png\nInput size: 189406 (184K)\nResult size: 166362 (162K). Percentage of original: 87.834%\nResult is smaller\n\n./zopflipng --prefix=\u0026quot;zopfli_\u0026quot; static/images/2014/Jun/jenkins_batch.png\nOptimizing static/images/2014/Jun/jenkins_batch.png\nInput size: 21933 (21K)\nResult size: 16255 (15K). Percentage of original: 74.112%\nResult is smaller\n\n./zopflipng --prefix=\u0026quot;zopfli_\u0026quot; static/images/2014/Jun/jenkins_build_step.png\nOptimizing static/images/2014/Jun/jenkins_build_step.png\nInput size: 8184 (7K)\nResult size: 6809 (6K). Percentage of original: 83.199%\nResult is smaller\n\n./zopflipng --prefix=\u0026quot;zopfli_\u0026quot; static/images/2014/Jun/jenkins_config_git.png\nOptimizing static/images/2014/Jun/jenkins_config_git.png\nInput size: 57897 (56K)\nResult size: 47164 (46K). Percentage of original: 81.462%\nResult is smaller\n\u003c/pre\u003e\u003c/div\u003e\n\n\u003cp\u003eThe first result in the example output shows a case where Zopfli would\nactually have made the file bigger (because it was already compressed\nby PNGout, remember). This is nothing you have to worry about because\nit\u0026rsquo;s actually smart enough that it simply copies the original file in\nthat case.\u003c/p\u003e\n\n\u003cp\u003eComparing to both before any compression, and PNGout, yielded the\nfollowing results:\u003c/p\u003e\n\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003e\u003c/th\u003e\n\u003cth\u003eMean relative size\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\n\u003ctr\u003e\n\u003ctd\u003eBefore\u003c/td\u003e\n\u003ctd\u003e1.00\u003c/td\u003e\n\u003c/tr\u003e\n\n\u003ctr\u003e\n\u003ctd\u003ePNGout\u003c/td\u003e\n\u003ctd\u003e0.84\u003c/td\u003e\n\u003c/tr\u003e\n\n\u003ctr\u003e\n\u003ctd\u003eZopfliPNG\u003c/td\u003e\n\u003ctd\u003e0.77\u003c/td\u003e\n\u003c/tr\u003e\n\n\u003c/tbody\u003e\n\u003c/table\u003e\n\n\u003cp\u003e\u003ca href=\"https://en.wikipedia.org/wiki/Box_plot\"\u003eBox plot\u003c/a\u003e of results on all images:\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/zopfli_boxplot.png\" alt=\"Compression results\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eSource files: \u003ca href=\"/csv/before.csv\"\u003ebefore.csv\u003c/a\u003e,\n\u003ca href=\"/csv/pngout.csv\"\u003epngout.csv\u003c/a\u003e, \u003ca href=\"/csv/zopfli.csv\"\u003ezopfli.csv\u003c/a\u003e\u003c/p\u003e\n\n\u003cp\u003eAnd this is with the default arguments. It is possible squeeze yet a\ncouple of more bytes out of this if you\u0026rsquo;re willing to wait longer.\u003c/p\u003e\n\n\u003ch2 id=\"automate-it-with-make\"\u003eAutomate it with Make\u003c/h2\u003e\n\n\u003cp\u003eAnother joy of using a simple static site is that it is possible to\ncompose regular tools to do useful things. Tools like\n\u003ca href=\"https://www.gnu.org/software/make/\"\u003eMake\u003c/a\u003e. And we can use Make to build the site, as well as\ncompressing images which have not already been compressed. You could\ndo it manually for each new image that you add of course but be\nhonest, you \u003cem\u003eknow\u003c/em\u003e that you\u0026rsquo;re gonna forget to do it at some point. So\nlet\u0026rsquo;s automate it instead!\u003c/p\u003e\n\n\u003cp\u003eThis is the Makefile that I use to build this site with, note that\n\u003ccode\u003epublic\u003c/code\u003e depends on \u003ccode\u003e$(PNG_SENTINELS)\u003c/code\u003e, so I literally can\u0026rsquo;t forget to\ncompress any new images added:\u003c/p\u003e\n\u003cdiv class=\"highlight\" style=\"background: #f0f0f0\"\u003e\u003cpre style=\"line-height: 125%\"\u003e\u003cspan\u003e\u003c/span\u003e\u003cspan style=\"color: #06287e\"\u003e.PHONY\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:\u003c/span\u003e help build server server-with-drafts clean zopfli\n\n\u003cspan style=\"color: #bb60d5\"\u003ePNG_SENTINELS\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:=\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003e$(\u003c/span\u003eshell find . -path ./public -prune -o -name \u003cspan style=\"color: #4070a0\"\u003e\u0026#39;*.png\u0026#39;\u003c/span\u003e -print | sed \u003cspan style=\"color: #4070a0\"\u003e\u0026#39;s|\\(.\\+/\\)\\(.\\+.png\\)|\\1.\\2.zopfli|g\u0026#39;\u003c/span\u003e\u003cspan style=\"color: #007020; font-weight: bold\"\u003e)\u003c/span\u003e\n\n\u003cspan style=\"color: #06287e\"\u003ehelp\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:\u003c/span\u003e \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e## Print this help text\u003c/span\u003e\n\t@grep -E \u003cspan style=\"color: #4070a0\"\u003e\u0026#39;^[a-zA-Z_-]+:.*?## .*$$\u0026#39;\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003e$(\u003c/span\u003eMAKEFILE_LIST\u003cspan style=\"color: #007020; font-weight: bold\"\u003e)\u003c/span\u003e | awk \u003cspan style=\"color: #4070a0\"\u003e\u0026#39;BEGIN {FS = \u0026quot;:.*?## \u0026quot;}; {printf \u0026quot;\\033[36m%-30s\\033[0m %s\\n\u0026quot;, $$1, $$2}\u0026#39;\u003c/span\u003e\n\n\u003cspan style=\"color: #06287e\"\u003eserver\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:\u003c/span\u003e \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e## Run hugo server\u003c/span\u003e\n\thugo server\n\n\u003cspan style=\"color: #06287e\"\u003eserver-with-drafts\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:\u003c/span\u003e \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e## Run hugo server and include drafts\u003c/span\u003e\n\thugo server -D\n\n\u003cspan style=\"color: #06287e\"\u003ebuild\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:\u003c/span\u003e public \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e## Build site (will also compress images using zopfli)\u003c/span\u003e\n\n\u003cspan style=\"color: #06287e\"\u003ezopfli\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003e$(\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003ePNG_SENTINELS\u003c/span\u003e\u003cspan style=\"color: #007020; font-weight: bold\"\u003e)\u003c/span\u003e \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e## Compress new images using zopfli\u003c/span\u003e\n\n\u003cspan style=\"color: #06287e\"\u003eclean\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:\u003c/span\u003e \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e## Remove the built directory\u003c/span\u003e\n\t@rm -rf public\n\n\u003cspan style=\"color: #06287e\"\u003epublic\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003e$(\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003ePNG_SENTINELS\u003c/span\u003e\u003cspan style=\"color: #007020; font-weight: bold\"\u003e)\u003c/span\u003e\n\t@rm -rf public\n\thugo\n\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# Zopfli sentinel rule, assumes zopflipng binary is in the same folder\u003c/span\u003e\n\u003cspan style=\"color: #06287e\"\u003e.%.png.zopfli\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e:\u003c/span\u003e %.png\n\t./zopflipng --prefix\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;zopfli_\u0026quot;\u003c/span\u003e $\u0026lt;\n\t@mv \u003cspan style=\"color: #007020; font-weight: bold\"\u003e$(\u003c/span\u003edir $\u0026lt;\u003cspan style=\"color: #007020; font-weight: bold\"\u003e)\u003c/span\u003ezopfli_\u003cspan style=\"color: #007020; font-weight: bold\"\u003e$(\u003c/span\u003enotdir $\u0026lt;\u003cspan style=\"color: #007020; font-weight: bold\"\u003e)\u003c/span\u003e $\u0026lt;\n\t@touch \u003cspan style=\"color: #bb60d5\"\u003e$@\u003c/span\u003e\n\u003c/pre\u003e\u003c/div\u003e\n\n\u003cp\u003eFor best performance, run make with parallel jobs (change 4 to your\nnumber CPUs): \u003ccode\u003emake -j4 zopfli\u003c/code\u003e.\u003c/p\u003e\n\n\u003cp\u003eTo know which files have already been compressed without actually\nrunning Zopfli on it again (which takes a while), sentinel files are\ncreated with this pattern: \u003ccode\u003e.\u0026lt;imgfilename\u0026gt;.zopfli\u003c/code\u003e. Thus, the next\ntime around, zopfli is only invoked for files which have \u003cem\u003enot\u003c/em\u003e already\nbeen compressed, making it a one-time operation. And when everything\nhas already been compressed, you\u0026rsquo;ll just get this:\u003c/p\u003e\n\u003cdiv class=\"highlight\" style=\"background: #f0f0f0\"\u003e\u003cpre style=\"line-height: 125%\"\u003e\u003cspan\u003e\u003c/span\u003emake: Nothing to be done for \u0026#39;zopfli\u0026#39;.\n\u003c/pre\u003e\u003c/div\u003e\n",
+ "date_published": "2016-08-26T13:17:40+02:00",
+ "image": "https://cowboyprogrammer.org/images/2017/10/zopfli_all_the_things_32.png"
+ }
+
+ , {
+ "id": "https://cowboyprogrammer.org/2016/07/migrating_from_ghost_to_hugo/",
+ "url": "https://cowboyprogrammer.org/2016/07/migrating_from_ghost_to_hugo/",
+ "title": "Migrating from Ghost to Hugo",
+ "content_html": "\n\n\u003cp\u003eSo I recently migrated this site from \u003ca href=\"https://ghost.org\"\u003eGhost\u003c/a\u003e to \u003ca href=\"https://gohugo.io\"\u003eHugo\u003c/a\u003e\nafter reading a nice article about the Hugo in\n\u003ca href=\"https://www.linuxvoice.com/download-linux-voice-issue-20/\"\u003eLinux Voice #20\u003c/a\u003e (funnily enough, the same issue also\nfeatures an article about Ghost). I originally made the switch to\nGhost from \u003ca href=\"https://jekyllrb.com/\"\u003eJekyll\u003c/a\u003e back in 2014 or so mainly because I could\nnot find a good theme to use. Ghost also seemed to have a lot of cool\nfeatures and it\u0026rsquo;s fun to try new things.\u003c/p\u003e\n\n\u003cp\u003eI think it\u0026rsquo;s safe to say that I am hardly a prolific blogger. I mainly\nwrite about stuff which I personally cannot find on the web which I\nthink should exist, because I will likely need it myself sometime in\nthe future. So it\u0026rsquo;s hardly a surprise that I am not in the target\naudience for Ghost.\u003c/p\u003e\n\n\u003ch2 id=\"things-about-ghost-which-annoy-me\"\u003eThings about Ghost which annoy me\u003c/h2\u003e\n\n\u003cul\u003e\n\u003cli\u003eIt\u0026rsquo;s written in NodeJS \u0026mdash; people who think JS is a good server\nlanguage also tend to think that it\u0026rsquo;s a good idea to depend on just\nabout any package, and download it in every single build. Which\nbecomes really \u003ca href=\"http://www.theregister.co.uk/2016/03/23/npm_left_pad_chaos/\"\u003efunny sometimes\u003c/a\u003e.\u003c/li\u003e\n\u003cli\u003ePoor selection of \u003ca href=\"http://marketplace.ghost.org/\"\u003ethemes\u003c/a\u003e \u0026mdash; this is subjective of\ncourse, but it seems to me that the free options don\u0026rsquo;t have much in\nterms of diversity. Heck, they even call it a \u003cem\u003emarketplace\u003c/em\u003e which\nrubs me the wrong way.\u003c/li\u003e\n\u003cli\u003eThemes end up being quite reliant on JS if you want necessary\nfeatures like syntax highlighting on code snippets \u0026mdash; I often\nbrowse with JS disabled and should be able to view my own site.\u003c/li\u003e\n\u003cli\u003eMarkdown parser treats newlines as significant \u0026mdash; meaning you can\u0026rsquo;t\nhave properly aligned paragraphs in your editor.\u003c/li\u003e\n\u003c/ul\u003e\n\n\u003cp\u003eThat last point irritates me deeply but it\u0026rsquo;s not as bad as the next point.\u003c/p\u003e\n\n\u003cul\u003e\n\u003cli\u003eYou can effectively lock an account by entering the wrong password 3\ntimes.\u003c/li\u003e\n\u003c/ul\u003e\n\n\u003cp\u003eThis requires some explanation. So Ghost, targeting teams of bloggers\nreally, naturally have an account system much like Wordpress. Now, as\nI was surveying the security status of other services I am running, I\nwas wondering how Ghost handled someone trying to brute force your\naccount and decided to simply try it out. Type the wrong password once\ntoo many, and this happens:\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/ghost_wrong_password.png\" alt=\"Ghost: typing the wrong password too many times locks your account\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eIt doesn\u0026rsquo;t lock it for a single IP address (I tried from several), it\nlocks the entire account. Effectively, someone can just set up a\nscript to try an account indefinitely simply with the intention to\nblock someone from logging in.\u003c/p\u003e\n\n\u003cp\u003eThe log doesn\u0026rsquo;t even show login attempts, so there is no way to\nimplement sensible blocking strategies using something like \u003ca href=\"http://www.fail2ban.org\"\u003efail2ban\u003c/a\u003e.\u003c/p\u003e\n\n\u003cp\u003eThe whole thing left a bad taste my mouth so it was a very suitable timing to read an article on \u003ca href=\"https://gohugo.io\"\u003eHugo\u003c/a\u003e.\u003c/p\u003e\n\n\u003ch2 id=\"things-about-hugo-which-excite-me\"\u003eThings about Hugo which excite me\u003c/h2\u003e\n\n\u003cul\u003e\n\u003cli\u003eMarkdown parser treats newlines correctly\u003c/li\u003e\n\u003cli\u003eIt\u0026rsquo;s a static site generator and not a service \u0026mdash; this meant 100MB\n(10%) of RAM became available on my server and there is no account\nto hack (or block).\u003c/li\u003e\n\u003cli\u003eSupports everything of Ghost (that I am aware of).\u003c/li\u003e\n\u003cli\u003eThe simplicity of Hugo makes it \u003ca href=\"https://npf.io/2014/08/making-it-a-series/\"\u003equite painless\u003c/a\u003e to\ndo useful things compared to\n\u003ca href=\"https://github.com/TryGhost/Ghost/issues/4818\"\u003eignored feature requests\u003c/a\u003e for the same in Ghost.\u003c/li\u003e\n\u003cli\u003eCan do server side syntax highlighting using Pygments.\u003c/li\u003e\n\u003cli\u003eSome really nice \u003ca href=\"http://themes.gohugo.io/\"\u003ethemes\u003c/a\u003e are available, and they are\nall free.\u003c/li\u003e\n\u003c/ul\u003e\n\n\u003ch2 id=\"migrating-all-data-from-ghost\"\u003eMigrating all data from Ghost\u003c/h2\u003e\n\n\u003cp\u003eMigrating from Ghost also turned about to be really painless. There\nwere several scripts around for exactly this but they all turned out\nto be written in \u003ca href=\"https://gist.github.com/vjeantet/d1f6cf824a2344dd6b4e\"\u003eodd languages\u003c/a\u003e, and did not actually\nmigrate all the metadata in Ghost. So I wrote my own in Python with\nthese \u003cem\u003ekiller features\u003c/em\u003e:\u003c/p\u003e\n\n\u003cul\u003e\n\u003cli\u003eMigrates tags.\u003c/li\u003e\n\u003cli\u003eMigrates dates.\u003c/li\u003e\n\u003cli\u003eMigrates drafts as drafts.\u003c/li\u003e\n\u003cli\u003eCreates aliases in your posts which makes sure that old permalinks\nwill still work!\u003c/li\u003e\n\u003cli\u003eMigrates cover pictures as banner images, just select a theme which\nsupport them.\u003c/li\u003e\n\u003cli\u003eRewrites all relative links so they all still work (this includes\nimages).\u003c/li\u003e\n\u003cli\u003eCode blocks with language definitions like \u003ccode\u003e```language-java\u003c/code\u003e\nare changed to \u003ccode\u003e```java\u003c/code\u003e.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"highlight\" style=\"background: #f0f0f0\"\u003e\u003cpre style=\"line-height: 125%\"\u003e\u003cspan\u003e\u003c/span\u003e\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e#!/usr/bin/env python3\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# -*- coding: utf-8 -*-\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003e\u0026#39;\u0026#39;\u0026#39;\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003eA simple program which migrates an exported Ghost blog to Hugo.\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003eIt assumes your blog is using the hugo-icarus theme, but should\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003ework for any theme. The script will migrate your posts, including\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003etags and banner images. Furthermore, it will make sure that\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003eall your old post urls will keep working by adding aliases to them.\u003c/span\u003e\n\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003eThe only thing you need to do yourself is copying the `images/`\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003edirectory in your ghost directory to `static/images/` in your hugo\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003edirectory. That way, all images will work. The script will rewrite\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003eall urls linking to `/content/images` to just `/images`.\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003e\u0026#39;\u0026#39;\u0026#39;\u003c/span\u003e\n\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003eimport\u003c/span\u003e \u003cspan style=\"color: #0e84b5; font-weight: bold\"\u003eargparse\u003c/span\u003e\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003eimport\u003c/span\u003e \u003cspan style=\"color: #0e84b5; font-weight: bold\"\u003ejson\u003c/span\u003e\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003efrom\u003c/span\u003e \u003cspan style=\"color: #0e84b5; font-weight: bold\"\u003edatetime\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003eimport\u003c/span\u003e date\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003efrom\u003c/span\u003e \u003cspan style=\"color: #0e84b5; font-weight: bold\"\u003eos\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003eimport\u003c/span\u003e path\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003efrom\u003c/span\u003e \u003cspan style=\"color: #0e84b5; font-weight: bold\"\u003ecollections\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003eimport\u003c/span\u003e defaultdict\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003eimport\u003c/span\u003e \u003cspan style=\"color: #0e84b5; font-weight: bold\"\u003ere\u003c/span\u003e\n\n_post \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026#39;\u0026#39;\u0026#39;\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003e+++\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003edate = \u0026quot;{date}\u0026quot;\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003edraft = {draft}\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003etitle = \u0026quot;\u0026quot;\u0026quot;{title}\u0026quot;\u0026quot;\u0026quot;\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003eslug = \u0026quot;{slug}\u0026quot;\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003etags = {tags}\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003ebanner = \u0026quot;{banner}\u0026quot;\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003ealiases = {aliases}\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003e+++\u003c/span\u003e\n\n\u003cspan style=\"color: #4070a0\"\u003e{markdown}\u003c/span\u003e\n\u003cspan style=\"color: #4070a0\"\u003e\u0026#39;\u0026#39;\u0026#39;\u003c/span\u003e\n\n\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003edef\u003c/span\u003e \u003cspan style=\"color: #06287e\"\u003emigrate\u003c/span\u003e(filepath, hugodir):\n \u003cspan style=\"color: #4070a0; font-style: italic\"\u003e\u0026#39;\u0026#39;\u0026#39;\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003e Parse the Ghost json file and write post files\u003c/span\u003e\n\u003cspan style=\"color: #4070a0; font-style: italic\"\u003e \u0026#39;\u0026#39;\u0026#39;\u003c/span\u003e\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003ewith\u003c/span\u003e \u003cspan style=\"color: #007020\"\u003eopen\u003c/span\u003e(filepath, \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;r\u0026quot;\u003c/span\u003e) \u003cspan style=\"color: #007020; font-weight: bold\"\u003eas\u003c/span\u003e fp:\n ghost \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e json\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eload(fp)\n\n data \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e ghost[\u003cspan style=\"color: #4070a0\"\u003e\u0026#39;db\u0026#39;\u003c/span\u003e][\u003cspan style=\"color: #40a070\"\u003e0\u003c/span\u003e][\u003cspan style=\"color: #4070a0\"\u003e\u0026#39;data\u0026#39;\u003c/span\u003e]\n\n tags \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e {}\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003efor\u003c/span\u003e tag \u003cspan style=\"color: #007020; font-weight: bold\"\u003ein\u003c/span\u003e data[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;tags\u0026quot;\u003c/span\u003e]:\n tags[tag[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;id\u0026quot;\u003c/span\u003e]] \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e tag[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;name\u0026quot;\u003c/span\u003e]\n\n posttags \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e defaultdict(\u003cspan style=\"color: #007020\"\u003elist\u003c/span\u003e)\n\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003efor\u003c/span\u003e posttag \u003cspan style=\"color: #007020; font-weight: bold\"\u003ein\u003c/span\u003e data[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;posts_tags\u0026quot;\u003c/span\u003e]:\n posttags[posttag[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;post_id\u0026quot;\u003c/span\u003e]]\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eappend(tags[posttag[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;tag_id\u0026quot;\u003c/span\u003e]])\n\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003efor\u003c/span\u003e post \u003cspan style=\"color: #007020; font-weight: bold\"\u003ein\u003c/span\u003e data[\u003cspan style=\"color: #4070a0\"\u003e\u0026#39;posts\u0026#39;\u003c/span\u003e]:\n draft \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;true\u0026quot;\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003eif\u003c/span\u003e post[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;status\u0026quot;\u003c/span\u003e] \u003cspan style=\"color: #666666\"\u003e==\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;draft\u0026quot;\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003eelse\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;false\u0026quot;\u003c/span\u003e\n ts \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e \u003cspan style=\"color: #007020\"\u003eint\u003c/span\u003e(post[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;created_at\u0026quot;\u003c/span\u003e]) \u003cspan style=\"color: #666666\"\u003e/\u003c/span\u003e \u003cspan style=\"color: #40a070\"\u003e1000\u003c/span\u003e\n\n banner \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u0026quot;\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003eif\u003c/span\u003e post[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;image\u0026quot;\u003c/span\u003e] \u003cspan style=\"color: #007020; font-weight: bold\"\u003eis\u003c/span\u003e \u003cspan style=\"color: #007020\"\u003eNone\u003c/span\u003e \u003cspan style=\"color: #007020; font-weight: bold\"\u003eelse\u003c/span\u003e post[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;image\u0026quot;\u003c/span\u003e]\n \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# /content/ should not be part of uri anymore\u003c/span\u003e\n banner \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e re\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003esub(\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;^.*/content[s]?/\u0026quot;\u003c/span\u003e, \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;/\u0026quot;\u003c/span\u003e, banner)\n\n target \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e path\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003ejoin(hugodir, \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;content/post\u0026quot;\u003c/span\u003e,\n \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;{}.md\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eformat(post[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;slug\u0026quot;\u003c/span\u003e]))\n\n aliases \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e [\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;/{}/\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eformat(post[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;slug\u0026quot;\u003c/span\u003e])]\n\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003eprint\u003c/span\u003e(\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;Migrating \u0026#39;{}\u0026#39; to {}\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eformat(post[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;title\u0026quot;\u003c/span\u003e],\n target))\n\n hugopost \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e _post\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eformat(markdown\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003epost[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;markdown\u0026quot;\u003c/span\u003e],\n title\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003epost[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;title\u0026quot;\u003c/span\u003e],\n draft\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003edraft,\n slug\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003epost[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;slug\u0026quot;\u003c/span\u003e],\n date\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003edate\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003efromtimestamp(ts)\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eisoformat(),\n tags\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003eposttags[post[\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;id\u0026quot;\u003c/span\u003e]],\n banner\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003ebanner,\n aliases\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003ealiases)\n\n \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# this is no longer relevant\u003c/span\u003e\n hugopost \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e hugopost\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003ereplace(\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;```language-\u0026quot;\u003c/span\u003e, \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;```\u0026quot;\u003c/span\u003e)\n \u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# /content/ should not be part of uri anymore\u003c/span\u003e\n hugopost \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e hugopost\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003ereplace(\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;/content/\u0026quot;\u003c/span\u003e, \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;/\u0026quot;\u003c/span\u003e)\n hugopost \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e re\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003esub(\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;^.*/content[s]?/\u0026quot;\u003c/span\u003e, \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;/\u0026quot;\u003c/span\u003e, hugopost)\n\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003ewith\u003c/span\u003e \u003cspan style=\"color: #007020\"\u003eopen\u003c/span\u003e(target, \u003cspan style=\"color: #4070a0\"\u003e\u0026#39;w\u0026#39;\u003c/span\u003e) \u003cspan style=\"color: #007020; font-weight: bold\"\u003eas\u003c/span\u003e fp:\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003eprint\u003c/span\u003e(hugopost, \u003cspan style=\"color: #007020\"\u003efile\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003efp)\n\n\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003edef\u003c/span\u003e \u003cspan style=\"color: #06287e\"\u003emain\u003c/span\u003e():\n parser \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e argparse\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eArgumentParser(\n description\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;Migrate an exported Ghost blog to Hugo\u0026quot;\u003c/span\u003e)\n req \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e parser\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eadd_argument_group(title\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;required arguments\u0026quot;\u003c/span\u003e)\n req\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eadd_argument(\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;-f\u0026quot;\u003c/span\u003e, \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;--file\u0026quot;\u003c/span\u003e, help\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;JSON file exported from Ghost\u0026quot;\u003c/span\u003e,\n required\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #007020\"\u003eTrue\u003c/span\u003e)\n req\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eadd_argument(\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;-d\u0026quot;\u003c/span\u003e, \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;--dir\u0026quot;\u003c/span\u003e, help\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;Directory (root) of Hugo site\u0026quot;\u003c/span\u003e,\n required\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #007020\"\u003eTrue\u003c/span\u003e)\n\n args \u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e parser\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003eparse_args()\n\n migrate(args\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003efile, args\u003cspan style=\"color: #666666\"\u003e.\u003c/span\u003edir)\n\n\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003eif\u003c/span\u003e \u003cspan style=\"color: #bb60d5\"\u003e__name__\u003c/span\u003e \u003cspan style=\"color: #666666\"\u003e==\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;__main__\u0026quot;\u003c/span\u003e:\n main()\n\u003c/pre\u003e\u003c/div\u003e\n\n\u003cp\u003eNext post, I might write about what changes I made to the theme, and\nsome nifty Nginx tricks you can use to stay compatible with old links.\u003c/p\u003e\n",
+ "date_published": "2016-07-25T23:55:38+02:00",
+ "image": "https://cowboyprogrammer.org/images/hugo-logo.png"
+ }
+
+ , {
+ "id": "https://cowboyprogrammer.org/2016/05/set-refresh-rate-of-screen-from-script/",
+ "url": "https://cowboyprogrammer.org/2016/05/set-refresh-rate-of-screen-from-script/",
+ "title": "Set refresh rate of screen from script",
+ "content_html": "\u003cp\u003eGetting a great new 100 Hz Ultra Wide monitor does not come without its share of tweaking. So it turns out that the refresh you set on your monitor in Nvidia settings (as explained in a \u003ca href=\"https://cowboyprogrammer.org/nvidia-gsync-on-linux/\"\u003eprevious post\u003c/a\u003e does not apply to all the display ports. They apparently count as different screens with different settings or something.\u003c/p\u003e\n\n\u003cp\u003eSo, here\u0026rsquo;s a handy script which you can add to your window manager\u0026rsquo;s autostart applications to set the refresh rate and resolution of your screen, regardless of which actual port you use:\u003c/p\u003e\n\u003cdiv class=\"highlight\" style=\"background: #f0f0f0\"\u003e\u003cpre style=\"line-height: 125%\"\u003e\u003cspan\u003e\u003c/span\u003e\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e#!/bin/bash -eu\u003c/span\u003e\n\u003cspan style=\"color: #bb60d5\"\u003eRES\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;3440x1440\u0026quot;\u003c/span\u003e\n\u003cspan style=\"color: #bb60d5\"\u003eRR\u003c/span\u003e\u003cspan style=\"color: #666666\"\u003e=\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;100\u0026quot;\u003c/span\u003e\n\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# Do for every output, so that it doesn\u0026#39;t matter where you plug in\u003c/span\u003e\n\u003cspan style=\"color: #60a0b0; font-style: italic\"\u003e# your monitor.\u003c/span\u003e\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003efor\u003c/span\u003e output in \u003cspan style=\"color: #007020; font-weight: bold\"\u003e$(\u003c/span\u003exrandr | grep \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;DP-\u0026quot;\u003c/span\u003e | sed -e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;s/\\(DP-.\\).*/\\1/\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #007020; font-weight: bold\"\u003e)\u003c/span\u003e; \u003cspan style=\"color: #007020; font-weight: bold\"\u003edo\u003c/span\u003e\n \u003cspan style=\"color: #007020\"\u003eecho\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;Trying to set mode on \u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003e$output\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003eif\u003c/span\u003e xrandr --output \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003e$output\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e --mode \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003e$RES\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e -r \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003e$RR\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e; \u003cspan style=\"color: #007020; font-weight: bold\"\u003ethen\u003c/span\u003e\n \u003cspan style=\"color: #007020\"\u003eecho\u003c/span\u003e \u003cspan style=\"color: #4070a0\"\u003e\u0026quot;Success: \u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003e$RES\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e \u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003e$RR\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e Hz set on \u003c/span\u003e\u003cspan style=\"color: #bb60d5\"\u003e$output\u003c/span\u003e\u003cspan style=\"color: #4070a0\"\u003e\u0026quot;\u003c/span\u003e\n \u003cspan style=\"color: #007020; font-weight: bold\"\u003efi\u003c/span\u003e\n\u003cspan style=\"color: #007020; font-weight: bold\"\u003edone\u003c/span\u003e\n\u003c/pre\u003e\u003c/div\u003e\n\n\u003cp\u003eIt iterates over all the display ports on your graphics card, so it doesn\u0026rsquo;t matter where you plug your monitor in.\u003c/p\u003e\n\n\u003cp\u003eIn XFCE, you\u0026rsquo;d add this script to \u003cem\u003eApplication Autostart\u003c/em\u003e:\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2016/05/Session-and-Startup_033.png\" alt=\"XFCE Application Autostart\" /\u003e\u003c/p\u003e\n",
+ "date_published": "2016-05-18T00:00:00+00:00",
+ "image": "https://cowboyprogrammer.org/images/2016/05/Selection_034.png"
+ }
+
+ , {
+ "id": "https://cowboyprogrammer.org/2016/04/fixing-the-up-button-in-python-shell-history/",
+ "url": "https://cowboyprogrammer.org/2016/04/fixing-the-up-button-in-python-shell-history/",
+ "title": "Fixing the up button in Python shell history",
+ "content_html": "\u003cp\u003eIn case your python/ipython shell doesn\u0026rsquo;t have a working history, e.g. pressing \u0026#8593; only prints some nonsensical \u003ccode\u003e^[[A\u003c/code\u003e, then you are missing either the \u003ccode\u003ereadline\u003c/code\u003e or \u003ccode\u003encurses\u003c/code\u003e library.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2016/04/Selection_021.png\" alt=\"Python shell where up doesn't work\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eIpython is more descriptive that something is wrong, but if you\u0026rsquo;re in the habit of mostly using python as a quick calculator, you might never notice:\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2016/04/Selection_022.png\" alt=\"iPython shell where up doesn't work\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eIf you\u0026rsquo;re using \u003ca href=\"http://conda.pydata.org/miniconda.html\"\u003eMiniconda\u003c/a\u003e then just do:\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003econda install ncurses readline\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003cp\u003eAnd \u0026#8593; should work:\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2016/04/Selection_023.png\" alt=\"iPython with working up\" /\u003e\u003c/p\u003e\n",
+ "date_published": "2016-04-02T00:00:00+00:00",
+ "image": "https://cowboyprogrammer.org/images/2016/04/Selection_021-1.png"
+ }
+
+ , {
+ "id": "https://cowboyprogrammer.org/2016/03/nvidia-gsync-on-linux/",
+ "url": "https://cowboyprogrammer.org/2016/03/nvidia-gsync-on-linux/",
+ "title": "Nvidia G-Sync and Linux",
+ "content_html": "\n\n\u003cp\u003eAfter getting a fancy new monitor with G-Sync support, I was eager to try it out in my Linux gaming setup. While Nvidia fully supports G-Sync in their Linux drivers, it turns out that other components of the system can get in the way. As explained by a \u003ca href=\"https://devtalk.nvidia.com/default/topic/854184/gsync-is-not-working/?offset=1\"\u003epost on the Nvidia forums\u003c/a\u003e:\u003c/p\u003e\n\n\u003cblockquote\u003e\n\u003cp\u003eFor G-SYNC to work, the application has to be able to flip and the symptoms you\u0026rsquo;re describing here sound like it\u0026rsquo;s not able to flip in your configuration. There are a variety of reasons why flipping might not be working, but the most likely culprits here are either the compositor getting in the way, or the game not being completely full-screen. The full-screen requirement includes the game being completely unoccluded, so if your window manager is drawing something on top of the game, even just by one pixel, it will prevent flipping. Full-screen also means that it has to cover the entire X screen, which includes both monitors if you have them both enabled.\u003c/p\u003e\n\n\u003cp\u003eCan you please try a different window manager / desktop environment to see if the behavior changes?\u003c/p\u003e\n\u003c/blockquote\u003e\n\n\u003cp\u003eSince only a minority of PC-gamers are actually on Linux, and only a minority of those actually have G-Sync capable monitors, Googling for assistance was\u0026hellip; challenging. So, for any other Linux gamers out there, here is a short guide on how to enable G-Sync and verify that it works. Some of the steps are XFCE specific, as this is my window manager of choice on my gaming PC. If you are using a different window manager, you\u0026rsquo;ll have to look through your options to find the equivalent settings.\u003c/p\u003e\n\n\u003ch2 id=\"nvidia-settings\"\u003eNvidia settings\u003c/h2\u003e\n\n\u003cul\u003e\n\u003cli\u003eSync to VBlank: Optional\u003c/li\u003e\n\u003cli\u003eAllow Flipping: Required\u003c/li\u003e\n\u003cli\u003eAllow G-SYNC: Required\u003c/li\u003e\n\u003cli\u003eEnable G-SYNC Visual Indicator: Optional\u003c/li\u003e\n\u003c/ul\u003e\n\n\u003cp\u003eThe only two required settings are \u003cem\u003eflipping\u003c/em\u003e and \u003cem\u003eG-Sync\u003c/em\u003e, the others are optional. Enabling \u003cem\u003eSync to VBlank\u003c/em\u003e (VSync) in combination with G-Sync only prevents the GPU from generating an FPS beyond your monitor\u0026rsquo;s max refresh rate (which you can\u0026rsquo;t see anyway). It is turned off below the max refresh rate when G-Sync is enabled.\u003c/p\u003e\n\n\u003cp\u003eThe visual indicator is useful here to see that G-Sync is working. If all goes well, you should see a green \u0026ldquo;G-SYNC\u0026rdquo; text in the corner when running a game.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2016/03/NVIDIA-X-Server-Settings_007.png\" alt=\"Nvidia settings\" /\u003e\u003c/p\u003e\n\n\u003ch2 id=\"disable-compositor\"\u003eDisable compositor\u003c/h2\u003e\n\n\u003cp\u003eAs mentioned in the forum post, a compositor will prevent G-Sync from activating because essentially something is rendering above the game. The same reason prevents G-Sync from working in Window mode (unlike Windows, where G-Sync does not require fullscreen).\u003c/p\u003e\n\n\u003cp\u003eFor XFCE, go to \u003cem\u003eWindow Manager Tweaks\u003c/em\u003e under \u003cem\u003eSettings\u003c/em\u003e\n\u003cimg src=\"/images/2016/03/Selection_004.png\" alt=\"XFCE Settings\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eThen under the \u003cem\u003eCompositor\u003c/em\u003e tab, make sure the compositor is disabled\n\u003cimg src=\"/images/2016/03/Selection_005.png\" alt=\"Window Manager Tweaks\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eIn addition, depending on your setup, make sure you don\u0026rsquo;t have things like \u003ca href=\"https://wiki.archlinux.org/index.php/Compton\"\u003eCompton\u003c/a\u003e or \u003ca href=\"https://wiki.archlinux.org/index.php/Compiz\"\u003eCompiz\u003c/a\u003e enabled.\u003c/p\u003e\n\n\u003ch2 id=\"start-a-game-in-fullscreen\"\u003eStart a game in fullscreen\u003c/h2\u003e\n\n\u003cp\u003eAs mentioned, you must run the game in fullscreen mode. G-Sync does not work with window mode in Linux.\u003c/p\u003e\n\n\u003cp\u003eI did notice that there are games which do not enable G-Sync. One example is \u0026ldquo;Cities: Skylines\u0026rdquo;. So make sure to try several games if you don\u0026rsquo;t see the G-Sync logo.\u003c/p\u003e\n\n\u003cp\u003eA good candidate here is Dota 2 since it is free to play. Dota 2 running in \u0026ldquo;Desktop-Friendly Fullscreen\u0026rdquo; does enable G-Sync. As does Portal 2 and XCOM 2.\u003c/p\u003e\n",
+ "date_published": "2016-03-05T00:00:00+00:00",
+ "image": "https://cowboyprogrammer.org/images/2016/03/NVIDIA-X-Server-Settings_007-1.png"
+ }
+
+ , {
+ "id": "https://cowboyprogrammer.org/2014/12/encrypt-a-btrfs-raid5-array-in-place/",
+ "url": "https://cowboyprogrammer.org/2014/12/encrypt-a-btrfs-raid5-array-in-place/",
+ "title": "Encrypt a BTRFS RAID5-array in-place",
+ "content_html": "\n\n\u003cp\u003eWhen I decided I needed more disk space for media and virtual machine (VM) images, I decided to throw some more money at the problem and get three 3TB hard drives and run \u003ca href=\"https://btrfs.wiki.kernel.org/index.php/Main_Page\"\u003eBTRFS\u003c/a\u003e in \u003ca href=\"http://en.wikipedia.org/wiki/RAID#Standard_levels\"\u003eRAID5\u003c/a\u003e. It\u0026rsquo;s still somewhat experimental, but has proven very solid for me.\u003c/p\u003e\n\n\u003cp\u003eRAID5 means that one drive can completely fail, but all the data is still intact. All one has to do is insert a new drive and the drive will be reconstructed. While RAID5 protects against a complete drive failure, it does nothing to prevent a single bit to be flipped to due cosmic rays or electricity spikes.\u003c/p\u003e\n\n\u003cp\u003eBTRFS is a new filesystem for Linux which does what ZFS does for BSD. The two important features which it offers over previous systems is: copy-on-write (COW), and bitrot protection. See, when running RAID with BTRFS, if a single bit is flipped, BTRFS will detect it when you try to read the file and correct it (if running in RAID so there\u0026rsquo;s redundancy). COW means you can take snapshots of the entire drive instantly without using extra space. Space will only be required when stuff change and diverge from your snapshots.\u003c/p\u003e\n\n\u003cp\u003eSee \u003ca href=\"http://arstechnica.com/information-technology/2014/01/bitrot-and-atomic-cows-inside-next-gen-filesystems/\"\u003eArstechnica\u003c/a\u003e for why \u003cem\u003eBTRFS\u003c/em\u003e is da shit for your next drive or system.\u003c/p\u003e\n\n\u003cp\u003eWhat I did not do at the time was encrypt the drives. \u003ca href=\"http://www.linuxvoice.com/\"\u003eLinux Voice #11\u003c/a\u003e had a very nice article on encryption so I thought I\u0026rsquo;d set it up. And because I\u0026rsquo;m using RAID5, it is actually possible for me to encrypt my drives using \u003ca href=\"https://wiki.archlinux.org/index.php/Dm-crypt/Device_encryption\"\u003edm-crypt/LUKS\u003c/a\u003e in-place, while the whole shebang is mounted, readable and usable :)\u003c/p\u003e\n\n\u003cp\u003eSome initial mistakes meant I had to actually reboot the system, so I thought I\u0026rsquo;d write down how to do it correctly. So to summarize, the goal is to convert three disks to three encrypted disks. BTRFS will be moved from using the drives directly, to using the LUKS-mapped.\u003c/p\u003e\n\n\u003ch3 id=\"unmount-the-raid-system-time-1-second\"\u003eUnmount the raid system (time 1 second)\u003c/h3\u003e\n\n\u003cp\u003eSadly, we need to unmount the volume to be able to \u0026ldquo;remove\u0026rdquo; the drive. This needs to be done so the system can understand that the drive has \u0026ldquo;vanished\u0026rdquo;. It will only stay unmounted for about a minute though.\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003esudo umount /path/to/vol\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003cp\u003eThis is assuming you have configured your \u003cstrong\u003efstab\u003c/strong\u003e with all the details. For example, with something like this (ALWAYS USE UUID!!)\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003e# BTRFS Systems\nUUID=\u0026quot;ac21dd50-e6ee-4a9e-abcd-459cba0e6913\u0026quot; /mnt/btrfs btrfs defaults 0 0\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003cp\u003eNote that no modification of the \u003cstrong\u003efstab\u003c/strong\u003e will be necessary if you have used UUID.\u003c/p\u003e\n\n\u003ch3 id=\"encrypt-one-of-the-drives-time-10-seconds\"\u003eEncrypt one of the drives (time 10 seconds)\u003c/h3\u003e\n\n\u003cp\u003ePick one of the drives to encrypt. Here it\u0026rsquo;s \u003ccode\u003e/dev/sdc\u003c/code\u003e:\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003esudo cryptsetup luksFormat -v /dev/sdc\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003ch3 id=\"open-the-encrypted-drive-time-30-seconds\"\u003eOpen the encrypted drive (time 30 seconds)\u003c/h3\u003e\n\n\u003cp\u003eTo use it, we have to open the drive. You can pick any name you want:\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003esudo cryptsetup luksOpen /dev/sdc DRIVENAME\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003cp\u003eTo make this happen on boot, find the new \u003cem\u003eUUID\u003c/em\u003e of \u003ccode\u003e/dev/sdc\u003c/code\u003e with \u003ccode\u003eblkid\u003c/code\u003e:\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003esudo blkid\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2014/Dec/Screenshot-from-2014-12-29-13-28-29.png\" alt=\"Output of blkid\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eSo for me, the drive has a the following \u003cem\u003eUUID:\u003c/em\u003e \u003ccode\u003ef5d3974c-529e-4574-bbfa-7f3e6db05c65\u003c/code\u003e. Add the following line to \u003ccode\u003e/etc/crypttab\u003c/code\u003e with your desired drive name and your \u003cem\u003eUUID\u003c/em\u003e (without any quotes):\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003eDRIVENAME UUID=your-uuid-without-quotes none luks\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003cp\u003eNow the system will ask for your password on boot.\u003c/p\u003e\n\n\u003ch3 id=\"add-the-encrypted-drive-to-the-raid-time-20-seconds\"\u003eAdd the encrypted drive to the raid (time 20 seconds)\u003c/h3\u003e\n\n\u003cp\u003eFirst we have to remount the raid system. This will fail because there is a missing drive, unless we add the option \u003cem\u003edegraded\u003c/em\u003e.\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003esudo mount -o degraded /path/to/vol\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003cp\u003eThere will be some complaints about missing drives and such, which is exactly what we expect. Now, just add the new drive:\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003esudo btrfs device add /dev/mapper/DRIVENAME /path/to/vol\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003ch3 id=\"remove-the-missing-drive-time-14-hours\"\u003eRemove the missing drive (time 14 hours)\u003c/h3\u003e\n\n\u003cp\u003eThe final step is to remove the old drive. We can use the special name \u003cem\u003emissing\u003c/em\u003e to remove it:\u003c/p\u003e\n\n\u003cpre\u003e\u003ccode\u003esudo btrfs device delete missing /path/to/vol\n\u003c/code\u003e\u003c/pre\u003e\n\n\u003cp\u003eThis can take a really long time, and by long I mean ~15 hours if you have a terrabyte of data. But, you can still use the drive during this process so just be patient.\u003c/p\u003e\n\n\u003cp\u003e\u003cimg src=\"/images/2014/Dec/Screenshot-from-2014-12-29-12-48-45.png\" alt=\"Balance took 14 hours\" /\u003e\u003c/p\u003e\n\n\u003cp\u003eFor me it took 14 hours 34 minutes. The reason for the delay is because the \u003cem\u003edelete\u003c/em\u003e command will force the system to rebuild the missing drive on your new encrypted volume.\u003c/p\u003e\n\n\u003ch3 id=\"next-drive-rinse-and-repeat\"\u003eNext drive, rinse and repeat\u003c/h3\u003e\n\n\u003cp\u003eJust unmount the raid, encrypt the drive, add it back and delete the missing. Repeat for all drives in your array. Once the last drive is done, unmount the array and remount it without the \u003ccode\u003e-o degraded\u003c/code\u003e option. Now you have an encrypted RAID array.\u003c/p\u003e\n",
+ "date_published": "2014-12-28T00:00:00+00:00"
+ }
+
+ ]
+
+}
diff --git a/app/src/debug/res/values/constants.xml b/app/src/debug/res/values/constants.xml
new file mode 100644
index 0000000..0b79c23
--- /dev/null
+++ b/app/src/debug/res/values/constants.xml
@@ -0,0 +1,4 @@
+
+
+ FeederD
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..58aed73
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/nononsenseapps/feeder/ApplicationCoroutineScope.kt b/app/src/main/java/com/nononsenseapps/feeder/ApplicationCoroutineScope.kt
new file mode 100644
index 0000000..4df8069
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/ApplicationCoroutineScope.kt
@@ -0,0 +1,9 @@
+package com.nononsenseapps.feeder
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+
+class ApplicationCoroutineScope : CoroutineScope {
+ override val coroutineContext = Dispatchers.Default + SupervisorJob()
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/FeederApplication.kt b/app/src/main/java/com/nononsenseapps/feeder/FeederApplication.kt
new file mode 100644
index 0000000..ea7cf60
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/FeederApplication.kt
@@ -0,0 +1,147 @@
+package com.nononsenseapps.feeder
+
+import android.app.Application
+import android.content.ContentResolver
+import android.content.SharedPreferences
+import android.os.Build.VERSION.SDK_INT
+import android.widget.Toast
+import androidx.core.app.NotificationManagerCompat
+import androidx.multidex.MultiDexApplication
+import androidx.preference.PreferenceManager
+import androidx.work.WorkManager
+import coil.ImageLoader
+import coil.decode.GifDecoder
+import coil.decode.ImageDecoderDecoder
+import coil.decode.SvgDecoder
+import com.jakewharton.threetenabp.AndroidThreeTen
+import com.nononsenseapps.feeder.db.room.AppDatabase
+import com.nononsenseapps.feeder.db.room.FeedDao
+import com.nononsenseapps.feeder.db.room.FeedItemDao
+import com.nononsenseapps.feeder.di.networkModule
+import com.nononsenseapps.feeder.di.stateModule
+import com.nononsenseapps.feeder.di.viewModelModule
+import com.nononsenseapps.feeder.model.UserAgentInterceptor
+import com.nononsenseapps.feeder.util.AsyncImageLoader
+import com.nononsenseapps.feeder.util.Prefs
+import com.nononsenseapps.feeder.util.ToastMaker
+import com.nononsenseapps.jsonfeed.cachingHttpClient
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.withContext
+import okhttp3.Cache
+import okhttp3.CacheControl
+import okhttp3.OkHttpClient
+import org.conscrypt.Conscrypt
+import org.kodein.di.Kodein
+import org.kodein.di.KodeinAware
+import org.kodein.di.generic.bind
+import org.kodein.di.generic.instance
+import org.kodein.di.generic.singleton
+import java.io.File
+import java.security.Security
+import java.util.concurrent.TimeUnit
+
+@FlowPreview
+@ExperimentalCoroutinesApi
+@Suppress("unused")
+class FeederApplication : MultiDexApplication(), KodeinAware {
+ private val applicationCoroutineScope = ApplicationCoroutineScope()
+
+ override val kodein by Kodein.lazy {
+ // import(androidXModule(this@FeederApplication))
+
+ bind() with singleton { this@FeederApplication }
+ bind() with singleton { AppDatabase.getInstance(this@FeederApplication) }
+ bind() with singleton { instance().feedDao() }
+ bind() with singleton { instance().feedItemDao() }
+
+ import(viewModelModule)
+
+ bind() with singleton { WorkManager.getInstance(this@FeederApplication) }
+ bind() with singleton { contentResolver }
+ bind() with singleton {
+ object : ToastMaker {
+ override suspend fun makeToast(text: String) = withContext(Dispatchers.Main) {
+ Toast.makeText(this@FeederApplication, text, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ bind() with singleton { NotificationManagerCompat.from(this@FeederApplication) }
+ bind() with singleton { PreferenceManager.getDefaultSharedPreferences(this@FeederApplication) }
+ bind() with singleton { Prefs(kodein) }
+
+ bind() with singleton {
+ cachingHttpClient(
+ cacheDirectory = (externalCacheDir ?: filesDir).resolve("http")
+ ).newBuilder()
+ .addNetworkInterceptor(UserAgentInterceptor)
+ .build()
+ }
+ bind() with singleton {
+ val prefs = instance()
+ val okHttpClient = instance()
+ .newBuilder()
+ // Use separate image cache or images will quickly evict feed caches
+ .cache(Cache((externalCacheDir ?: filesDir).resolve("img"), 20L * 1024L * 1024L))
+ .addInterceptor { chain ->
+ chain.proceed(
+ when (prefs.shouldLoadImages()) {
+ true -> chain.request()
+ false -> {
+ // Forces only cached responses to be used - if no cache then 504 is thrown
+ chain.request().newBuilder()
+ .cacheControl(
+ CacheControl.Builder()
+ .onlyIfCached()
+ .maxStale(Int.MAX_VALUE, TimeUnit.SECONDS)
+ .maxAge(Int.MAX_VALUE, TimeUnit.SECONDS)
+ .build()
+ )
+ .build()
+ }
+ }
+ )
+ }
+ .build()
+
+ ImageLoader.Builder(instance())
+ .okHttpClient(okHttpClient = okHttpClient)
+ .componentRegistry {
+ add(SvgDecoder(applicationContext))
+ if (SDK_INT >= 28) {
+ add(ImageDecoderDecoder())
+ } else {
+ add(GifDecoder())
+ }
+ }
+ .build()
+ }
+ bind() with singleton { AsyncImageLoader(kodein) }
+ bind() with instance(applicationCoroutineScope)
+ import(networkModule)
+ import(stateModule)
+ }
+
+ init {
+ // Install Conscrypt to handle TLSv1.3 pre Android10
+ Security.insertProviderAt(Conscrypt.newProvider(), 1)
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ AndroidThreeTen.init(this)
+ staticFilesDir = filesDir
+ }
+
+ override fun onTerminate() {
+ applicationCoroutineScope.cancel("Application is being terminated")
+ super.onTerminate()
+ }
+
+ companion object {
+ // Needed for database migration
+ lateinit var staticFilesDir: File
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/base/KodeinAwareActivity.kt b/app/src/main/java/com/nononsenseapps/feeder/base/KodeinAwareActivity.kt
new file mode 100644
index 0000000..3eee917
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/base/KodeinAwareActivity.kt
@@ -0,0 +1,25 @@
+package com.nononsenseapps.feeder.base
+
+import android.annotation.SuppressLint
+import android.view.MenuInflater
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.FragmentActivity
+import org.kodein.di.Kodein
+import org.kodein.di.KodeinAware
+import org.kodein.di.android.closestKodein
+import org.kodein.di.generic.bind
+import org.kodein.di.generic.instance
+import org.kodein.di.generic.provider
+
+/**
+ * A fragment which is also Kodein aware.
+ */
+@SuppressLint("Registered")
+open class KodeinAwareActivity : AppCompatActivity(), KodeinAware {
+ private val parentKodein: Kodein by closestKodein()
+ override val kodein: Kodein by Kodein.lazy {
+ extend(parentKodein)
+ bind() with provider { menuInflater }
+ bind() with instance(this@KodeinAwareActivity)
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/base/KodeinAwareDialogFragment.kt b/app/src/main/java/com/nononsenseapps/feeder/base/KodeinAwareDialogFragment.kt
new file mode 100644
index 0000000..16adfe1
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/base/KodeinAwareDialogFragment.kt
@@ -0,0 +1,13 @@
+package com.nononsenseapps.feeder.base
+
+import androidx.fragment.app.DialogFragment
+import org.kodein.di.Kodein
+import org.kodein.di.KodeinAware
+import org.kodein.di.android.x.closestKodein
+
+/**
+ * A dialog fragment which is also Kodein aware.
+ */
+open class KodeinAwareDialogFragment : DialogFragment(), KodeinAware {
+ override val kodein: Kodein by closestKodein()
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/base/KodeinAwareFragment.kt b/app/src/main/java/com/nononsenseapps/feeder/base/KodeinAwareFragment.kt
new file mode 100644
index 0000000..94d01bd
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/base/KodeinAwareFragment.kt
@@ -0,0 +1,13 @@
+package com.nononsenseapps.feeder.base
+
+import androidx.fragment.app.Fragment
+import org.kodein.di.Kodein
+import org.kodein.di.KodeinAware
+import org.kodein.di.android.x.closestKodein
+
+/**
+ * A fragment which is also Kodein aware.
+ */
+open class KodeinAwareFragment : Fragment(), KodeinAware {
+ override val kodein: Kodein by closestKodein()
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/base/KodeinAwareIntentService.kt b/app/src/main/java/com/nononsenseapps/feeder/base/KodeinAwareIntentService.kt
new file mode 100644
index 0000000..0f0d351
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/base/KodeinAwareIntentService.kt
@@ -0,0 +1,10 @@
+package com.nononsenseapps.feeder.base
+
+import android.app.IntentService
+import org.kodein.di.Kodein
+import org.kodein.di.KodeinAware
+import org.kodein.di.android.closestKodein
+
+abstract class KodeinAwareIntentService(name: String) : IntentService(name), KodeinAware {
+ override val kodein: Kodein by closestKodein()
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/base/KodeinAwareViewModel.kt b/app/src/main/java/com/nononsenseapps/feeder/base/KodeinAwareViewModel.kt
new file mode 100644
index 0000000..2187f31
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/base/KodeinAwareViewModel.kt
@@ -0,0 +1,63 @@
+package com.nononsenseapps.feeder.base
+
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import org.kodein.di.Kodein
+import org.kodein.di.KodeinAware
+import org.kodein.di.bindings.Factory
+import org.kodein.di.bindings.Provider
+import org.kodein.di.direct
+import org.kodein.di.generic.bind
+import org.kodein.di.generic.factory
+import org.kodein.di.generic.instance
+import org.kodein.di.generic.provider
+import java.lang.reflect.InvocationTargetException
+
+/**
+ * A view model which is also kodein aware. Construct any deriving class by using the getViewModel()
+ * extension function.
+ */
+open class KodeinAwareViewModel(override val kodein: Kodein) : AndroidViewModel(kodein.direct.instance()), KodeinAware
+
+class KodeinAwareViewModelFactory(override val kodein: Kodein) :
+ ViewModelProvider.AndroidViewModelFactory(kodein.direct.instance()), KodeinAware {
+ override fun create(modelClass: Class): T {
+ return if (KodeinAwareViewModel::class.java.isAssignableFrom(modelClass)) {
+ try {
+ modelClass.getConstructor(Kodein::class.java).newInstance(kodein)
+ } catch (e: NoSuchMethodException) {
+ throw RuntimeException("No such constructor $modelClass", e)
+ } catch (e: IllegalAccessException) {
+ throw RuntimeException("Cannot create an instance of $modelClass", e)
+ } catch (e: InstantiationException) {
+ throw RuntimeException("Cannot create an instance of $modelClass", e)
+ } catch (e: InvocationTargetException) {
+ throw RuntimeException("Cannot create an instance of $modelClass", e)
+ }
+ } else {
+ super.create(modelClass)
+ }
+ }
+}
+
+inline fun Kodein.BindBuilder.WithContext.activityViewModelProvider():
+ Provider {
+ return provider {
+ ViewModelProvider(instance(), instance()).get(T::class.java)
+ }
+}
+
+inline fun Kodein.BindBuilder.WithContext.fragmentViewModelFactory():
+ Factory {
+ return factory { fragment: Fragment ->
+ ViewModelProvider(fragment, instance()).get(T::class.java)
+ }
+}
+
+inline fun Kodein.Builder.bindWithKodeinAwareViewModelFactory() {
+ bind() with activityViewModelProvider()
+ bind() with fragmentViewModelFactory()
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/blob/Blob.kt b/app/src/main/java/com/nononsenseapps/feeder/blob/Blob.kt
new file mode 100644
index 0000000..9ab37bf
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/blob/Blob.kt
@@ -0,0 +1,30 @@
+package com.nononsenseapps.feeder.blob
+
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.util.zip.GZIPInputStream
+import java.util.zip.GZIPOutputStream
+
+fun blobFile(itemId: Long, filesDir: File): File =
+ File(filesDir, "$itemId.txt.gz")
+
+@Throws(IOException::class)
+fun blobInputStream(itemId: Long, filesDir: File): InputStream =
+ GZIPInputStream(blobFile(itemId = itemId, filesDir = filesDir).inputStream())
+
+@Throws(IOException::class)
+fun blobOutputStream(itemId: Long, filesDir: File): OutputStream =
+ GZIPOutputStream(blobFile(itemId = itemId, filesDir = filesDir).outputStream())
+
+fun blobFullFile(itemId: Long, filesDir: File): File =
+ File(filesDir, "$itemId.full.html.gz")
+
+@Throws(IOException::class)
+fun blobFullInputStream(itemId: Long, filesDir: File): InputStream =
+ GZIPInputStream(blobFullFile(itemId = itemId, filesDir = filesDir).inputStream())
+
+@Throws(IOException::class)
+fun blobFullOutputStream(itemId: Long, filesDir: File): OutputStream =
+ GZIPOutputStream(blobFullFile(itemId = itemId, filesDir = filesDir).outputStream())
diff --git a/app/src/main/java/com/nononsenseapps/feeder/db/Constants.kt b/app/src/main/java/com/nononsenseapps/feeder/db/Constants.kt
new file mode 100644
index 0000000..cdc534a
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/db/Constants.kt
@@ -0,0 +1,31 @@
+package com.nononsenseapps.feeder.db
+
+const val FEEDS_TABLE_NAME = "feeds"
+const val FEED_ITEMS_TABLE_NAME = "feed_items"
+
+const val COL_ID = "id"
+const val COL_TITLE = "title"
+const val COL_CUSTOM_TITLE = "custom_title"
+const val COL_URL = "url"
+const val COL_TAG = "tag"
+const val COL_NOTIFY = "notify"
+const val COL_GUID = "guid"
+const val COL_PLAINTITLE = "plain_title"
+const val COL_PLAINSNIPPET = "plain_snippet"
+const val COL_IMAGEURL = "image_url"
+const val COL_ENCLOSURELINK = "enclosure_link"
+const val COL_LINK = "link"
+const val COL_AUTHOR = "author"
+const val COL_PUBDATE = "pub_date"
+const val COL_UNREAD = "unread"
+const val COL_NOTIFIED = "notified"
+const val COL_FEEDID = "feed_id"
+const val COL_FEEDTITLE = "feed_title"
+const val COL_FEEDCUSTOMTITLE = "feed_customtitle"
+const val COL_FEEDURL = "feed_url"
+const val COL_LASTSYNC = "last_sync"
+const val COL_RESPONSEHASH = "response_hash"
+const val COL_FIRSTSYNCEDTIME = "first_synced_time"
+const val COL_PRIMARYSORTTIME = "primary_sort_time"
+const val COL_FULLTEXT_BY_DEFAULT = "fulltext_by_default"
+const val COL_OPEN_ARTICLES_WITH = "open_articles_with"
\ No newline at end of file
diff --git a/app/src/main/java/com/nononsenseapps/feeder/db/Uri.kt b/app/src/main/java/com/nononsenseapps/feeder/db/Uri.kt
new file mode 100644
index 0000000..4f5610f
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/db/Uri.kt
@@ -0,0 +1,16 @@
+@file:Suppress("RECEIVER_NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS")
+
+package com.nononsenseapps.feeder.db
+
+import android.net.Uri
+
+const val AUTHORITY = "com.nononsenseapps.feeder.provider"
+const val SCHEME = "content://"
+
+// URIs
+// Feed
+@JvmField
+val URI_FEEDS: Uri = Uri.withAppendedPath(Uri.parse(SCHEME + AUTHORITY), "feeds")
+// Feed item
+@JvmField
+val URI_FEEDITEMS: Uri = Uri.withAppendedPath(Uri.parse(SCHEME + AUTHORITY), "feed_items")
diff --git a/app/src/main/java/com/nononsenseapps/feeder/db/room/AppDatabase.kt b/app/src/main/java/com/nononsenseapps/feeder/db/room/AppDatabase.kt
new file mode 100644
index 0000000..5a9b0f6
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/db/room/AppDatabase.kt
@@ -0,0 +1,354 @@
+package com.nononsenseapps.feeder.db.room
+
+import android.content.Context
+import android.database.sqlite.SQLiteDatabase
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+import com.nononsenseapps.feeder.FeederApplication
+import com.nononsenseapps.feeder.blob.blobOutputStream
+import com.nononsenseapps.feeder.util.contentValues
+import com.nononsenseapps.feeder.util.forEach
+import com.nononsenseapps.feeder.util.setInt
+import com.nononsenseapps.feeder.util.setLong
+import com.nononsenseapps.feeder.util.setString
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+
+const val DATABASE_NAME = "rssDatabase"
+const val ID_UNSET: Long = 0
+const val ID_ALL_FEEDS: Long = -10
+
+/**
+ * Database versions
+ * 4: Was using the RSS Server
+ * 5: Added feed url field to feed_item
+ * 6: Added feed icon field to feeds
+ * 7: Migration to Room
+ */
+
+@FlowPreview
+@Database(entities = [Feed::class, FeedItem::class], version = 14)
+@TypeConverters(Converters::class)
+abstract class AppDatabase : RoomDatabase() {
+ abstract fun feedDao(): FeedDao
+ abstract fun feedItemDao(): FeedItemDao
+
+ @FlowPreview
+ @ExperimentalCoroutinesApi
+ companion object {
+ // For Singleton instantiation
+ @Volatile
+ private var instance: AppDatabase? = null
+
+ /**
+ * Use this in tests only
+ */
+ internal fun setInstance(db: AppDatabase) {
+ instance = db
+ }
+
+ fun getInstance(context: Context): AppDatabase {
+ return instance ?: synchronized(this) {
+ instance ?: buildDatabase(context).also { instance = it }
+ }
+ }
+
+ private fun buildDatabase(context: Context): AppDatabase {
+ return Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
+ .addMigrations(*allMigrations)
+ .build()
+ }
+ }
+}
+
+@FlowPreview
+@ExperimentalCoroutinesApi
+val allMigrations = arrayOf(
+ MIGRATION_5_7,
+ MIGRATION_6_7,
+ MIGRATION_7_8,
+ MIGRATION_8_9,
+ MIGRATION_9_10,
+ MIGRATION_10_11,
+ MIGRATION_11_12,
+ MIGRATION_12_13,
+ MIGRATION_13_14
+)
+
+/*
+ * 6 represents legacy database
+ * 7 represents new Room database
+ */
+
+@FlowPreview
+@ExperimentalCoroutinesApi
+@Suppress("ClassName")
+object MIGRATION_13_14 : Migration(13, 14) {
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL(
+ """
+ ALTER TABLE feeds ADD COLUMN open_articles_with TEXT NOT NULL DEFAULT ''
+ """.trimIndent()
+ )
+ }
+}
+
+@FlowPreview
+@ExperimentalCoroutinesApi
+@Suppress("ClassName")
+object MIGRATION_12_13 : Migration(12, 13) {
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL(
+ """
+ ALTER TABLE feeds ADD COLUMN fulltext_by_default INTEGER NOT NULL DEFAULT 0
+ """.trimIndent()
+ )
+ }
+}
+
+@FlowPreview
+@ExperimentalCoroutinesApi
+@Suppress("ClassName")
+object MIGRATION_11_12 : Migration(11, 12) {
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL(
+ """
+ ALTER TABLE feed_items ADD COLUMN primary_sort_time INTEGER NOT NULL DEFAULT 0
+ """.trimIndent()
+ )
+ }
+}
+
+@FlowPreview
+@ExperimentalCoroutinesApi
+@Suppress("ClassName")
+object MIGRATION_10_11 : Migration(10, 11) {
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL(
+ """
+ ALTER TABLE feed_items ADD COLUMN first_synced_time INTEGER NOT NULL DEFAULT 0
+ """.trimIndent()
+ )
+ }
+}
+
+@FlowPreview
+@ExperimentalCoroutinesApi
+@Suppress("ClassName")
+object MIGRATION_9_10 : Migration(9, 10) {
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `feed_items_new` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `plain_title` TEXT NOT NULL, `plain_snippet` TEXT NOT NULL, `image_url` TEXT, `enclosure_link` TEXT, `author` TEXT, `pub_date` TEXT, `link` TEXT, `unread` INTEGER NOT NULL, `notified` INTEGER NOT NULL, `feed_id` INTEGER, FOREIGN KEY(`feed_id`) REFERENCES `feeds`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )
+ """.trimIndent()
+ )
+
+ database.execSQL(
+ """
+ INSERT INTO `feed_items_new` (`id`, `guid`, `title`, `plain_title`, `plain_snippet`, `image_url`, `enclosure_link`, `author`, `pub_date`, `link`, `unread`, `notified`, `feed_id`)
+ SELECT `id`, `guid`, `title`, `plain_title`, `plain_snippet`, `image_url`, `enclosure_link`, `author`, `pub_date`, `link`, `unread`, `notified`, `feed_id` FROM `feed_items`
+ """.trimIndent()
+ )
+
+ // Iterate over all items using the minimum query. Also restrict the text field to
+ // 1 MB which should be safe enough considering the window size is 2MB large.
+ database.query(
+ """
+ SELECT id, substr(description,0,1000000) FROM feed_items
+ """.trimIndent()
+ )?.use { cursor ->
+ cursor.forEach {
+ val feedItemId = cursor.getLong(0)
+ val description = cursor.getString(1)
+
+ blobOutputStream(feedItemId, FeederApplication.staticFilesDir).bufferedWriter().use {
+ it.write(description)
+ }
+ }
+ }
+
+ database.execSQL(
+ """
+ DROP TABLE feed_items
+ """.trimIndent()
+ )
+
+ database.execSQL(
+ """
+ ALTER TABLE feed_items_new RENAME TO feed_items
+ """.trimIndent()
+ )
+
+ database.execSQL(
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS `index_feed_items_guid_feed_id` ON `feed_items` (`guid`, `feed_id`)
+ """.trimIndent()
+ )
+
+ database.execSQL(
+ """
+ CREATE INDEX IF NOT EXISTS `index_feed_items_feed_id` ON `feed_items` (`feed_id`)
+ """.trimIndent()
+ )
+
+ // And reset response hash on all feeds to trigger parsing of results next sync so items
+ // are written disk (in case migration substring was too short)
+ database.execSQL(
+ """
+ UPDATE `feeds` SET `response_hash` = 0
+ """.trimIndent()
+ )
+ }
+}
+
+object MIGRATION_8_9 : Migration(8, 9) {
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL(
+ """
+ ALTER TABLE feeds ADD COLUMN response_hash INTEGER NOT NULL DEFAULT 0
+ """.trimIndent()
+ )
+ }
+}
+
+object MIGRATION_7_8 : Migration(7, 8) {
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL(
+ """
+ ALTER TABLE feeds ADD COLUMN last_sync INTEGER NOT NULL DEFAULT 0
+ """.trimIndent()
+ )
+ }
+}
+
+object MIGRATION_6_7 : Migration(6, 7) {
+ override fun migrate(database: SupportSQLiteDatabase) {
+ legacyMigration(database, 6)
+ }
+}
+
+object MIGRATION_5_7 : Migration(5, 7) {
+ override fun migrate(database: SupportSQLiteDatabase) {
+ legacyMigration(database, 5)
+ }
+}
+
+private fun legacyMigration(database: SupportSQLiteDatabase, version: Int) {
+ // Create new tables and indices
+ // Feeds
+ database.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `feeds` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `custom_title` TEXT NOT NULL, `url` TEXT NOT NULL, `tag` TEXT NOT NULL, `notify` INTEGER NOT NULL, `image_url` TEXT)
+ """.trimIndent()
+ )
+ database.execSQL(
+ """
+ CREATE UNIQUE INDEX `index_Feed_url` ON `feeds` (`url`)
+ """.trimIndent()
+ )
+ database.execSQL(
+ """
+ CREATE UNIQUE INDEX `index_Feed_id_url_title` ON `feeds` (`id`, `url`, `title`)
+ """.trimIndent()
+ )
+
+ // Items
+ database.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `feed_items` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `plain_title` TEXT NOT NULL, `plain_snippet` TEXT NOT NULL, `image_url` TEXT, `enclosure_link` TEXT, `author` TEXT, `pub_date` TEXT, `link` TEXT, `unread` INTEGER NOT NULL, `notified` INTEGER NOT NULL, `feed_id` INTEGER, FOREIGN KEY(`feed_id`) REFERENCES `feeds`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )
+ """.trimIndent()
+ )
+ database.execSQL(
+ """
+ CREATE UNIQUE INDEX `index_feed_item_guid_feed_id` ON `feed_items` (`guid`, `feed_id`)
+ """.trimIndent()
+ )
+ database.execSQL(
+ """
+ CREATE INDEX `index_feed_item_feed_id` ON `feed_items` (`feed_id`)
+ """.trimIndent()
+ )
+
+ // Migrate to new tables
+ database.query(
+ """
+ SELECT _id, title, url, tag, customtitle, notify ${if (version == 6) ", imageUrl" else ""}
+ FROM Feed
+ """.trimIndent()
+ )?.use { cursor ->
+ cursor.forEach { _ ->
+ val oldFeedId = cursor.getLong(0)
+
+ val newFeedId = database.insert(
+ "feeds",
+ SQLiteDatabase.CONFLICT_FAIL,
+ contentValues {
+ setString("title" to cursor.getString(1))
+ setString("custom_title" to cursor.getString(4))
+ setString("url" to cursor.getString(2))
+ setString("tag" to cursor.getString(3))
+ setInt("notify" to cursor.getInt(5))
+ if (version == 6) {
+ setString("image_url" to cursor.getString(6))
+ }
+ }
+ )
+
+ database.query(
+ """
+ SELECT title, description, plainTitle, plainSnippet, imageUrl, link, author,
+ pubdate, unread, feed, enclosureLink, notified, guid
+ FROM FeedItem
+ WHERE feed = $oldFeedId
+ """.trimIndent()
+ )?.use { cursor ->
+ database.inTransaction {
+ cursor.forEach { _ ->
+ database.insert(
+ "feed_items",
+ SQLiteDatabase.CONFLICT_FAIL,
+ contentValues {
+ setString("guid" to cursor.getString(12))
+ setString("title" to cursor.getString(0))
+ setString("description" to cursor.getString(1))
+ setString("plain_title" to cursor.getString(2))
+ setString("plain_snippet" to cursor.getString(3))
+ setString("image_url" to cursor.getString(4))
+ setString("enclosure_link" to cursor.getString(10))
+ setString("author" to cursor.getString(6))
+ setString("pub_date" to cursor.getString(7))
+ setString("link" to cursor.getString(5))
+ setInt("unread" to cursor.getInt(8))
+ setInt("notified" to cursor.getInt(11))
+ setLong("feed_id" to newFeedId)
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // Remove all legacy content
+ database.execSQL("DROP TRIGGER IF EXISTS trigger_tag_updater")
+
+ database.execSQL("DROP VIEW IF EXISTS WithUnreadCount")
+ database.execSQL("DROP VIEW IF EXISTS TagsWithUnreadCount")
+
+ database.execSQL("DROP TABLE IF EXISTS Feed")
+ database.execSQL("DROP TABLE IF EXISTS FeedItem")
+}
+
+fun SupportSQLiteDatabase.inTransaction(init: (SupportSQLiteDatabase) -> Unit) {
+ beginTransaction()
+ try {
+ init(this)
+ setTransactionSuccessful()
+ } finally {
+ endTransaction()
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/db/room/Converters.kt b/app/src/main/java/com/nononsenseapps/feeder/db/room/Converters.kt
new file mode 100644
index 0000000..df6dd91
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/db/room/Converters.kt
@@ -0,0 +1,46 @@
+package com.nononsenseapps.feeder.db.room
+
+import androidx.room.TypeConverter
+import com.nononsenseapps.feeder.util.sloppyLinkToStrictURLNoThrows
+import org.threeten.bp.Instant
+import org.threeten.bp.ZonedDateTime
+import java.net.URL
+
+class Converters {
+
+ @TypeConverter
+ fun dateTimeFromString(value: String?): ZonedDateTime? {
+ var dt: ZonedDateTime? = null
+ if (value != null) {
+ try {
+ dt = ZonedDateTime.parse(value)
+ } catch (t: Throwable) {
+ }
+ }
+ return dt
+ }
+
+ @TypeConverter
+ fun stringFromDateTime(value: ZonedDateTime?): String? =
+ value?.toString()
+
+ @TypeConverter
+ fun stringFromURL(value: URL?): String? =
+ value?.toString()
+
+ @TypeConverter
+ fun urlFromString(value: String?): URL? =
+ value?.let { sloppyLinkToStrictURLNoThrows(it) }
+
+ @TypeConverter
+ fun instantFromLong(value: Long?): Instant? =
+ try {
+ value?.let { Instant.ofEpochMilli(it) }
+ } catch (t: Throwable) {
+ null
+ }
+
+ @TypeConverter
+ fun longFromInstant(value: Instant?): Long? =
+ value?.toEpochMilli()
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/db/room/Feed.kt b/app/src/main/java/com/nononsenseapps/feeder/db/room/Feed.kt
new file mode 100644
index 0000000..95497f9
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/db/room/Feed.kt
@@ -0,0 +1,51 @@
+package com.nononsenseapps.feeder.db.room
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.Ignore
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import com.nononsenseapps.feeder.db.COL_CUSTOM_TITLE
+import com.nononsenseapps.feeder.db.COL_FULLTEXT_BY_DEFAULT
+import com.nononsenseapps.feeder.db.COL_ID
+import com.nononsenseapps.feeder.db.COL_IMAGEURL
+import com.nononsenseapps.feeder.db.COL_LASTSYNC
+import com.nononsenseapps.feeder.db.COL_NOTIFY
+import com.nononsenseapps.feeder.db.COL_RESPONSEHASH
+import com.nononsenseapps.feeder.db.COL_TAG
+import com.nononsenseapps.feeder.db.COL_TITLE
+import com.nononsenseapps.feeder.db.COL_URL
+import com.nononsenseapps.feeder.db.COL_OPEN_ARTICLES_WITH
+import com.nononsenseapps.feeder.db.FEEDS_TABLE_NAME
+import com.nononsenseapps.feeder.util.sloppyLinkToStrictURL
+import org.threeten.bp.Instant
+import java.net.URL
+
+const val OPEN_ARTICLE_WITH_APPLICATION_DEFAULT = ""
+
+@Entity(
+ tableName = FEEDS_TABLE_NAME,
+ indices = [
+ Index(value = [COL_URL], unique = true),
+ Index(value = [COL_ID, COL_URL, COL_TITLE], unique = true)
+ ]
+)
+data class Feed @Ignore constructor(
+ @PrimaryKey(autoGenerate = true)
+ @ColumnInfo(name = COL_ID) var id: Long = ID_UNSET,
+ @ColumnInfo(name = COL_TITLE) var title: String = "",
+ @ColumnInfo(name = COL_CUSTOM_TITLE) var customTitle: String = "",
+ @ColumnInfo(name = COL_URL) var url: URL = sloppyLinkToStrictURL(""),
+ @ColumnInfo(name = COL_TAG) var tag: String = "",
+ @ColumnInfo(name = COL_NOTIFY) var notify: Boolean = false,
+ @ColumnInfo(name = COL_IMAGEURL) var imageUrl: URL? = null,
+ @ColumnInfo(name = COL_LASTSYNC, typeAffinity = ColumnInfo.INTEGER) var lastSync: Instant = Instant.EPOCH,
+ @ColumnInfo(name = COL_RESPONSEHASH) var responseHash: Int = 0,
+ @ColumnInfo(name = COL_FULLTEXT_BY_DEFAULT) var fullTextByDefault: Boolean = false,
+ @ColumnInfo(name = COL_OPEN_ARTICLES_WITH) var openArticlesWith: String = ""
+) {
+ constructor() : this(id = ID_UNSET)
+
+ val displayTitle: String
+ get() = (if (customTitle.isBlank()) title else customTitle)
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedDao.kt b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedDao.kt
new file mode 100644
index 0000000..2f87efe
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedDao.kt
@@ -0,0 +1,134 @@
+package com.nononsenseapps.feeder.db.room
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import com.nononsenseapps.feeder.db.COL_CUSTOM_TITLE
+import com.nononsenseapps.feeder.db.COL_ID
+import com.nononsenseapps.feeder.db.COL_TAG
+import com.nononsenseapps.feeder.db.COL_TITLE
+import com.nononsenseapps.feeder.model.FeedUnreadCount
+import kotlinx.coroutines.flow.Flow
+import java.net.URL
+
+@Dao
+interface FeedDao {
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertFeed(feed: Feed): Long
+
+ @Update
+ suspend fun updateFeed(feed: Feed)
+
+ @Delete
+ suspend fun deleteFeed(feed: Feed)
+
+ @Query("DELETE FROM feeds WHERE id IS :feedId")
+ suspend fun deleteFeedWithId(feedId: Long)
+
+ @Query(
+ """
+ DELETE FROM feeds WHERE id IN (:ids)
+ """
+ )
+ suspend fun deleteFeeds(ids: List)
+
+ @Query("SELECT * FROM feeds WHERE id IS :feedId")
+ fun loadLiveFeed(feedId: Long): Flow
+
+ @Query("SELECT DISTINCT tag FROM feeds ORDER BY tag COLLATE NOCASE")
+ suspend fun loadTags(): List
+
+ @Query("SELECT * FROM feeds WHERE id IS :feedId")
+ suspend fun loadFeed(feedId: Long): Feed?
+
+ @Query(
+ """
+ SELECT * FROM feeds
+ WHERE id is :feedId
+ AND last_sync < :staleTime
+ """
+ )
+ suspend fun loadFeedIfStale(feedId: Long, staleTime: Long): Feed?
+
+ @Query("SELECT * FROM feeds WHERE tag IS :tag")
+ suspend fun loadFeeds(tag: String): List
+
+ @Query("SELECT * FROM feeds WHERE tag IS :tag AND last_sync < :staleTime")
+ suspend fun loadFeedsIfStale(tag: String, staleTime: Long): List
+
+ @Query("SELECT notify FROM feeds WHERE tag IS :tag")
+ fun loadLiveFeedsNotify(tag: String): Flow>
+
+ @Query("SELECT notify FROM feeds WHERE id IS :feedId")
+ fun loadLiveFeedsNotify(feedId: Long): Flow>
+
+ @Query("SELECT notify FROM feeds")
+ fun loadLiveFeedsNotify(): Flow>
+
+ @Query("SELECT * FROM feeds")
+ suspend fun loadFeeds(): List
+
+ @Query("SELECT * FROM feeds WHERE last_sync < :staleTime")
+ suspend fun loadFeedsIfStale(staleTime: Long): List
+
+ @Query("SELECT * FROM feeds WHERE url IS :url")
+ suspend fun loadFeedWithUrl(url: URL): Feed?
+
+ @Query("SELECT id FROM feeds WHERE notify IS 1")
+ suspend fun loadFeedIdsToNotify(): List
+
+ @Query(
+ """
+ SELECT id, title, url, tag, custom_title, notify, image_url, unread_count
+ FROM feeds
+ LEFT JOIN (SELECT COUNT(1) AS unread_count, feed_id
+ FROM feed_items
+ WHERE unread IS 1
+ GROUP BY feed_id
+ )
+ ON feeds.id = feed_id
+ """
+ )
+ fun loadLiveFeedsWithUnreadCounts(): Flow>
+
+ @Query("UPDATE feeds SET notify = :notify WHERE id IS :id")
+ suspend fun setNotify(id: Long, notify: Boolean)
+
+ @Query("UPDATE feeds SET notify = :notify WHERE tag IS :tag")
+ suspend fun setNotify(tag: String, notify: Boolean)
+
+ @Query("UPDATE feeds SET notify = :notify")
+ suspend fun setAllNotify(notify: Boolean)
+
+ @Query("SELECT $COL_ID, $COL_TITLE, $COL_CUSTOM_TITLE FROM feeds WHERE id IS :feedId")
+ suspend fun getFeedTitle(feedId: Long): List
+
+ @Query(
+ """
+ SELECT $COL_ID, $COL_TITLE, $COL_CUSTOM_TITLE
+ FROM feeds
+ WHERE $COL_TAG IS :feedTag
+ """
+ )
+ suspend fun getFeedTitlesWithTag(feedTag: String): List
+
+ @Query("SELECT $COL_ID, $COL_TITLE, $COL_CUSTOM_TITLE FROM feeds")
+ suspend fun getAllFeedTitles(): List
+}
+
+/**
+ * Inserts or updates feed depending on if ID is valid. Returns ID.
+ */
+suspend fun FeedDao.upsertFeed(feed: Feed): Long = when (feed.id > ID_UNSET) {
+ true -> {
+ updateFeed(feed)
+ feed.id
+ }
+ false -> {
+ insertFeed(feed)
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItem.kt b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItem.kt
new file mode 100644
index 0000000..50ae9da
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItem.kt
@@ -0,0 +1,158 @@
+package com.nononsenseapps.feeder.db.room
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.ForeignKey.CASCADE
+import androidx.room.Ignore
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import com.nononsenseapps.feeder.db.COL_AUTHOR
+import com.nononsenseapps.feeder.db.COL_ENCLOSURELINK
+import com.nononsenseapps.feeder.db.COL_FEEDID
+import com.nononsenseapps.feeder.db.COL_FIRSTSYNCEDTIME
+import com.nononsenseapps.feeder.db.COL_GUID
+import com.nononsenseapps.feeder.db.COL_ID
+import com.nononsenseapps.feeder.db.COL_IMAGEURL
+import com.nononsenseapps.feeder.db.COL_LINK
+import com.nononsenseapps.feeder.db.COL_NOTIFIED
+import com.nononsenseapps.feeder.db.COL_PLAINSNIPPET
+import com.nononsenseapps.feeder.db.COL_PLAINTITLE
+import com.nononsenseapps.feeder.db.COL_PRIMARYSORTTIME
+import com.nononsenseapps.feeder.db.COL_PUBDATE
+import com.nononsenseapps.feeder.db.COL_TITLE
+import com.nononsenseapps.feeder.db.COL_UNREAD
+import com.nononsenseapps.feeder.db.FEED_ITEMS_TABLE_NAME
+import com.nononsenseapps.feeder.ui.text.HtmlToPlainTextConverter
+import com.nononsenseapps.feeder.util.relativeLinkIntoAbsolute
+import com.nononsenseapps.feeder.util.sloppyLinkToStrictURL
+import com.nononsenseapps.jsonfeed.Item
+import kotlinx.coroutines.FlowPreview
+import org.threeten.bp.Instant
+import org.threeten.bp.ZoneOffset
+import org.threeten.bp.ZonedDateTime
+import java.net.URI
+import java.net.URL
+
+const val MAX_TITLE_LENGTH = 200
+const val MAX_SNIPPET_LENGTH = 200
+
+@FlowPreview
+@Entity(
+ tableName = FEED_ITEMS_TABLE_NAME,
+ indices = [
+ Index(value = [COL_GUID, COL_FEEDID], unique = true),
+ Index(value = [COL_FEEDID])
+ ],
+ foreignKeys = [
+ ForeignKey(
+ entity = Feed::class,
+ parentColumns = [COL_ID],
+ childColumns = [COL_FEEDID],
+ onDelete = CASCADE
+ )
+ ]
+)
+data class FeedItem @Ignore constructor(
+ @PrimaryKey(autoGenerate = true)
+ @ColumnInfo(name = COL_ID) override var id: Long = ID_UNSET,
+ @ColumnInfo(name = COL_GUID) var guid: String = "",
+ @Deprecated("This is never different from plainTitle", replaceWith = ReplaceWith("plainTitle"))
+ @ColumnInfo(name = COL_TITLE) var title: String = "",
+ @ColumnInfo(name = COL_PLAINTITLE) var plainTitle: String = "",
+ @ColumnInfo(name = COL_PLAINSNIPPET) var plainSnippet: String = "",
+ @ColumnInfo(name = COL_IMAGEURL) var imageUrl: String? = null,
+ @ColumnInfo(name = COL_ENCLOSURELINK) var enclosureLink: String? = null,
+ @ColumnInfo(name = COL_AUTHOR) var author: String? = null,
+ @ColumnInfo(name = COL_PUBDATE, typeAffinity = ColumnInfo.TEXT) var pubDate: ZonedDateTime? = null,
+ @ColumnInfo(name = COL_LINK) override var link: String? = null,
+ @ColumnInfo(name = COL_UNREAD) var unread: Boolean = true,
+ @ColumnInfo(name = COL_NOTIFIED) var notified: Boolean = false,
+ @ColumnInfo(name = COL_FEEDID) var feedId: Long? = null,
+ @ColumnInfo(name = COL_FIRSTSYNCEDTIME, typeAffinity = ColumnInfo.INTEGER) var firstSyncedTime: Instant = Instant.EPOCH,
+ @ColumnInfo(name = COL_PRIMARYSORTTIME, typeAffinity = ColumnInfo.INTEGER) var primarySortTime: Instant = Instant.EPOCH
+) : FeedItemForFetching {
+
+ constructor() : this(id = ID_UNSET)
+
+ fun updateFromParsedEntry(entry: Item, entryGuid: String, feed: com.nononsenseapps.jsonfeed.Feed) {
+ val converter = HtmlToPlainTextConverter()
+ // Be careful about nulls.
+ val text = entry.content_html ?: entry.content_text ?: ""
+ val summary: String? = (
+ entry.summary ?: entry.content_text
+ ?: converter.convert(text)
+ ).take(MAX_SNIPPET_LENGTH)
+
+ // Make double sure no base64 images are used as thumbnails
+ val safeImage = when {
+ entry.image?.startsWith("data") == true -> null
+ else -> entry.image
+ }
+
+ val absoluteImage = when {
+ feed.feed_url != null && safeImage != null -> {
+ relativeLinkIntoAbsolute(sloppyLinkToStrictURL(feed.feed_url!!), safeImage)
+ }
+ else -> safeImage
+ }
+
+ this.guid = entryGuid
+ entry.title?.let { this.plainTitle = it.take(MAX_TITLE_LENGTH) }
+ @Suppress("DEPRECATION")
+ this.title = this.plainTitle
+ summary?.let { this.plainSnippet = it }
+
+ this.imageUrl = absoluteImage
+ this.enclosureLink = entry.attachments?.firstOrNull()?.url
+ this.author = entry.author?.name ?: feed.author?.name
+ this.link = entry.url
+
+ this.pubDate =
+ try {
+ // Allow an actual pubdate to be updated
+ ZonedDateTime.parse(entry.date_published)
+ } catch (t: Throwable) {
+ // If a pubdate is missing, then don't update if one is already set
+ this.pubDate ?: ZonedDateTime.now(ZoneOffset.UTC)
+ }
+ primarySortTime = minOf(firstSyncedTime, pubDate?.toInstant() ?: firstSyncedTime)
+ }
+
+ val pubDateString: String?
+ get() = pubDate?.toString()
+
+ val enclosureFilename: String?
+ get() {
+ enclosureLink?.let { enclosureLink ->
+ var fname: String? = null
+ try {
+ fname = URI(enclosureLink).path.split("/").last()
+ } catch (e: Exception) {
+ }
+ return if (fname == null || fname.isEmpty()) {
+ null
+ } else {
+ fname
+ }
+ }
+ return null
+ }
+
+ val domain: String?
+ get() {
+ val l: String? = enclosureLink ?: link
+ if (l != null) {
+ try {
+ return URL(l).host.replace("www.", "")
+ } catch (e: Throwable) {
+ }
+ }
+ return null
+ }
+}
+
+interface FeedItemForFetching {
+ val id: Long
+ val link: String?
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItemDao.kt b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItemDao.kt
new file mode 100644
index 0000000..f039168
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItemDao.kt
@@ -0,0 +1,335 @@
+package com.nononsenseapps.feeder.db.room
+
+import androidx.paging.DataSource
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import com.nononsenseapps.feeder.db.COL_URL
+import com.nononsenseapps.feeder.db.FEEDS_TABLE_NAME
+import com.nononsenseapps.feeder.model.PreviewItem
+import com.nononsenseapps.feeder.model.previewColumns
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.Flow
+import java.net.URL
+
+@FlowPreview
+@Dao
+interface FeedItemDao {
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertFeedItem(item: FeedItem): Long
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertFeedItems(items: List): List
+
+ @Update
+ suspend fun updateFeedItem(item: FeedItem): Int
+
+ @Update
+ suspend fun updateFeedItems(items: List): Int
+
+ @Delete
+ suspend fun deleteFeedItem(item: FeedItem)
+
+ @Query(
+ """
+ DELETE FROM feed_items WHERE id IN (:ids)
+ """
+ )
+ suspend fun deleteFeedItems(ids: List)
+
+ @Query(
+ """
+ SELECT id FROM feed_items
+ WHERE feed_id IS :feedId
+ ORDER BY primary_sort_time DESC, pub_date DESC
+ LIMIT -1 OFFSET :keepCount
+ """
+ )
+ suspend fun getItemsToBeCleanedFromFeed(feedId: Long, keepCount: Int): List
+
+ @Query("SELECT * FROM feed_items WHERE guid IS :guid AND feed_id IS :feedId")
+ suspend fun loadFeedItem(guid: String, feedId: Long?): FeedItem?
+
+ @Query("SELECT * FROM feed_items WHERE id IS :id")
+ suspend fun loadFeedItem(id: Long): FeedItem?
+
+ @Query(
+ """
+ SELECT $feedItemColumnsWithFeed
+ FROM feed_items
+ LEFT JOIN feeds ON feed_items.feed_id = feeds.id
+ WHERE feed_items.id IS :id
+ """
+ )
+ suspend fun loadFeedItemWithFeed(id: Long): FeedItemWithFeed?
+
+ @Query(
+ """
+ SELECT $FEEDS_TABLE_NAME.$COL_URL
+ FROM feed_items
+ LEFT JOIN feeds ON feed_items.feed_id = feeds.id
+ WHERE feed_items.id IS :id
+ """
+ )
+ suspend fun loadFeedUrlOfFeedItem(id: Long): URL?
+
+ @Query(
+ """
+ SELECT *
+ FROM feed_items
+ WHERE feed_items.feed_id = :feedId
+ ORDER BY primary_sort_time DESC, pub_date DESC
+ """
+ )
+ suspend fun loadFeedItemsInFeedDesc(feedId: Long): List
+
+ @Query(
+ """
+ SELECT *
+ FROM feed_items
+ WHERE feed_items.feed_id = :feedId
+ ORDER BY primary_sort_time ASC, pub_date ASC
+ """
+ )
+ suspend fun loadFeedItemsInFeedAsc(feedId: Long): List
+
+ @Query(
+ """
+ SELECT $feedItemColumnsWithFeed
+ FROM feed_items
+ LEFT JOIN feeds ON feed_items.feed_id = feeds.id
+ WHERE feed_items.id IS :id
+ """
+ )
+ fun loadLiveFeedItem(id: Long): Flow
+
+ @Query(
+ """
+ SELECT $previewColumns
+ FROM feed_items
+ LEFT JOIN feeds ON feed_items.feed_id = feeds.id
+ WHERE feed_id IS :feedId
+ ORDER BY primary_sort_time DESC, pub_date DESC
+ """
+ )
+ fun loadLivePreviewsDesc(feedId: Long): DataSource.Factory
+
+ @Query(
+ """
+ SELECT $previewColumns
+ FROM feed_items
+ LEFT JOIN feeds ON feed_items.feed_id = feeds.id
+ WHERE feed_id IS :feedId
+ ORDER BY primary_sort_time ASC, pub_date ASC
+ """
+ )
+ fun loadLivePreviewsAsc(feedId: Long): DataSource.Factory
+
+ @Query(
+ """
+ SELECT $previewColumns
+ FROM feed_items
+ LEFT JOIN feeds ON feed_items.feed_id = feeds.id
+ WHERE tag IS :tag
+ ORDER BY primary_sort_time DESC, pub_date DESC
+ """
+ )
+ fun loadLivePreviewsDesc(tag: String): DataSource.Factory
+
+ @Query(
+ """
+ SELECT $previewColumns
+ FROM feed_items
+ LEFT JOIN feeds ON feed_items.feed_id = feeds.id
+ WHERE tag IS :tag
+ ORDER BY primary_sort_time ASC, pub_date ASC
+ """
+ )
+ fun loadLivePreviewsAsc(tag: String): DataSource.Factory
+
+ @Query(
+ """
+ SELECT $previewColumns
+ FROM feed_items
+ LEFT JOIN feeds ON feed_items.feed_id = feeds.id
+ ORDER BY primary_sort_time DESC, pub_date DESC
+ """
+ )
+ fun loadLivePreviewsDesc(): DataSource.Factory
+
+ @Query(
+ """
+ SELECT $previewColumns
+ FROM feed_items
+ LEFT JOIN feeds ON feed_items.feed_id = feeds.id
+ ORDER BY primary_sort_time ASC, pub_date ASC
+ """
+ )
+ fun loadLivePreviewsAsc(): DataSource.Factory
+
+ @Query(
+ """
+ SELECT $previewColumns
+ FROM feed_items
+ LEFT JOIN feeds ON feed_items.feed_id = feeds.id
+ WHERE feed_id IS :feedId AND unread IS :unread
+ ORDER BY primary_sort_time DESC, pub_date DESC
+ """
+ )
+ fun loadLiveUnreadPreviewsDesc(feedId: Long?, unread: Boolean = true): DataSource.Factory
+
+ @Query(
+ """
+ SELECT $previewColumns
+ FROM feed_items
+ LEFT JOIN feeds ON feed_items.feed_id = feeds.id
+ WHERE feed_id IS :feedId AND unread IS :unread
+ ORDER BY primary_sort_time ASC, pub_date ASC
+ """
+ )
+ fun loadLiveUnreadPreviewsAsc(feedId: Long?, unread: Boolean = true): DataSource.Factory
+
+ @Query(
+ """
+ SELECT $previewColumns
+ FROM feed_items
+ LEFT JOIN feeds ON feed_items.feed_id = feeds.id
+ WHERE tag IS :tag AND unread IS :unread
+ ORDER BY primary_sort_time DESC, pub_date DESC
+ """
+ )
+ fun loadLiveUnreadPreviewsDesc(tag: String, unread: Boolean = true): DataSource.Factory
+
+ @Query(
+ """
+ SELECT $previewColumns
+ FROM feed_items
+ LEFT JOIN feeds ON feed_items.feed_id = feeds.id
+ WHERE tag IS :tag AND unread IS :unread
+ ORDER BY primary_sort_time ASC, pub_date ASC
+ """
+ )
+ fun loadLiveUnreadPreviewsAsc(tag: String, unread: Boolean = true): DataSource.Factory
+
+ @Query(
+ """
+ SELECT $previewColumns
+ FROM feed_items
+ LEFT JOIN feeds ON feed_items.feed_id = feeds.id
+ WHERE unread IS :unread
+ ORDER BY primary_sort_time DESC, pub_date DESC
+ """
+ )
+ fun loadLiveUnreadPreviewsDesc(unread: Boolean = true): DataSource.Factory
+
+ @Query(
+ """
+ SELECT $previewColumns
+ FROM feed_items
+ LEFT JOIN feeds ON feed_items.feed_id = feeds.id
+ WHERE unread IS :unread
+ ORDER BY primary_sort_time ASC, pub_date ASC
+ """
+ )
+ fun loadLiveUnreadPreviewsAsc(unread: Boolean = true): DataSource.Factory
+
+ @Query(
+ """
+ SELECT $feedItemColumnsWithFeed
+ FROM feed_items
+ LEFT JOIN feeds ON feed_items.feed_id = feeds.id
+ WHERE feed_id IN (:feedIds) AND notified IS 0 AND unread IS 1
+ """
+ )
+ suspend fun loadItemsToNotify(feedIds: List): List
+
+ @Query("UPDATE feed_items SET unread = 0")
+ suspend fun markAllAsRead()
+
+ @Query("UPDATE feed_items SET unread = 0 WHERE feed_id IS :feedId")
+ suspend fun markAllAsRead(feedId: Long?)
+
+ @Query(
+ """
+ UPDATE feed_items
+ SET unread = 0
+ WHERE id IN (
+ SELECT feed_items.id
+ FROM feed_items
+ LEFT JOIN feeds ON feed_items.feed_id = feeds.id
+ WHERE tag IS :tag
+ )"""
+ )
+ suspend fun markAllAsRead(tag: String)
+
+ @Query("UPDATE feed_items SET unread = :unread WHERE id IS :id")
+ suspend fun markAsRead(id: Long, unread: Boolean = false)
+
+ @Query("UPDATE feed_items SET unread = :unread WHERE id IN (:ids)")
+ suspend fun markAsRead(ids: List, unread: Boolean = false)
+
+ @Query("UPDATE feed_items SET notified = :notified WHERE id IN (:ids)")
+ suspend fun markAsNotified(ids: List, notified: Boolean = true)
+
+ @Query("UPDATE feed_items SET notified = :notified WHERE id IS :id")
+ suspend fun markAsNotified(id: Long, notified: Boolean = true)
+
+ @Query(
+ """
+ UPDATE feed_items
+ SET notified = :notified
+ WHERE id IN (
+ SELECT feed_items.id
+ FROM feed_items
+ LEFT JOIN feeds ON feed_items.feed_id = feeds.id
+ WHERE tag IS :tag
+ )"""
+ )
+ suspend fun markTagAsNotified(tag: String, notified: Boolean = true)
+
+ @Query("UPDATE feed_items SET notified = :notified")
+ suspend fun markAllAsNotified(notified: Boolean = true)
+
+ @Query("UPDATE feed_items SET unread = 0, notified = 1 WHERE id IS :id")
+ suspend fun markAsReadAndNotified(id: Long)
+}
+
+@FlowPreview
+suspend fun FeedItemDao.upsertFeedItem(item: FeedItem): Long = when (item.id > ID_UNSET) {
+ true -> {
+ updateFeedItem(item)
+ item.id
+ }
+ false -> insertFeedItem(item)
+}
+
+@FlowPreview
+suspend fun FeedItemDao.upsertFeedItems(
+ itemsWithText: List>,
+ block: suspend (FeedItem, String) -> Unit
+) {
+ val updatedItems = itemsWithText.filter { (item, _) ->
+ item.id > ID_UNSET
+ }
+ updateFeedItems(updatedItems.map { (item, _) -> item })
+
+ val insertedItems = itemsWithText.filter { (item, _) ->
+ item.id <= ID_UNSET
+ }
+ val insertedIds = insertFeedItems(insertedItems.map { (item, _) -> item })
+
+ updatedItems.forEach { (item, text) ->
+ block(item, text)
+ }
+
+ insertedIds.zip(insertedItems).forEach { (itemId, itemToText) ->
+ val (item, text) = itemToText
+
+ item.id = itemId
+
+ block(item, text)
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItemWithFeed.kt b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItemWithFeed.kt
new file mode 100644
index 0000000..e43f8eb
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedItemWithFeed.kt
@@ -0,0 +1,124 @@
+package com.nononsenseapps.feeder.db.room
+
+import android.os.Bundle
+import androidx.room.ColumnInfo
+import androidx.room.Ignore
+import com.nononsenseapps.feeder.db.COL_AUTHOR
+import com.nononsenseapps.feeder.db.COL_CUSTOM_TITLE
+import com.nononsenseapps.feeder.db.COL_ENCLOSURELINK
+import com.nononsenseapps.feeder.db.COL_FEEDCUSTOMTITLE
+import com.nononsenseapps.feeder.db.COL_FEEDID
+import com.nononsenseapps.feeder.db.COL_FEEDTITLE
+import com.nononsenseapps.feeder.db.COL_FEEDURL
+import com.nononsenseapps.feeder.db.COL_FULLTEXT_BY_DEFAULT
+import com.nononsenseapps.feeder.db.COL_GUID
+import com.nononsenseapps.feeder.db.COL_ID
+import com.nononsenseapps.feeder.db.COL_IMAGEURL
+import com.nononsenseapps.feeder.db.COL_LINK
+import com.nononsenseapps.feeder.db.COL_PLAINSNIPPET
+import com.nononsenseapps.feeder.db.COL_PLAINTITLE
+import com.nononsenseapps.feeder.db.COL_PUBDATE
+import com.nononsenseapps.feeder.db.COL_TAG
+import com.nononsenseapps.feeder.db.COL_TITLE
+import com.nononsenseapps.feeder.db.COL_UNREAD
+import com.nononsenseapps.feeder.db.COL_URL
+import com.nononsenseapps.feeder.db.FEEDS_TABLE_NAME
+import com.nononsenseapps.feeder.db.FEED_ITEMS_TABLE_NAME
+import com.nononsenseapps.feeder.ui.ARG_AUTHOR
+import com.nononsenseapps.feeder.ui.ARG_DATE
+import com.nononsenseapps.feeder.ui.ARG_ENCLOSURE
+import com.nononsenseapps.feeder.ui.ARG_FEED_TITLE
+import com.nononsenseapps.feeder.ui.ARG_FEED_URL
+import com.nononsenseapps.feeder.ui.ARG_ID
+import com.nononsenseapps.feeder.ui.ARG_IMAGEURL
+import com.nononsenseapps.feeder.ui.ARG_LINK
+import com.nononsenseapps.feeder.ui.ARG_TITLE
+import com.nononsenseapps.feeder.util.setLong
+import com.nononsenseapps.feeder.util.setString
+import com.nononsenseapps.feeder.util.sloppyLinkToStrictURLNoThrows
+import org.threeten.bp.ZonedDateTime
+import java.net.URI
+import java.net.URL
+
+const val feedItemColumnsWithFeed = "$FEED_ITEMS_TABLE_NAME.$COL_ID AS $COL_ID, $COL_GUID, $FEED_ITEMS_TABLE_NAME.$COL_TITLE AS $COL_TITLE, " +
+ "$COL_PLAINTITLE, $COL_PLAINSNIPPET, $FEED_ITEMS_TABLE_NAME.$COL_IMAGEURL, $COL_ENCLOSURELINK, " +
+ "$COL_AUTHOR, $COL_PUBDATE, $COL_LINK, $COL_UNREAD, $FEEDS_TABLE_NAME.$COL_TAG AS $COL_TAG, $FEEDS_TABLE_NAME.$COL_ID AS $COL_FEEDID, " +
+ "$FEEDS_TABLE_NAME.$COL_TITLE AS $COL_FEEDTITLE, " +
+ "$FEEDS_TABLE_NAME.$COL_CUSTOM_TITLE AS $COL_FEEDCUSTOMTITLE, " +
+ "$FEEDS_TABLE_NAME.$COL_URL AS $COL_FEEDURL, " +
+ "$FEEDS_TABLE_NAME.$COL_FULLTEXT_BY_DEFAULT AS $COL_FULLTEXT_BY_DEFAULT"
+
+data class FeedItemWithFeed @Ignore constructor(
+ override var id: Long = ID_UNSET,
+ var guid: String = "",
+ @Deprecated("This is never different from plainTitle", replaceWith = ReplaceWith("plainTitle"))
+ var title: String = "",
+ @ColumnInfo(name = COL_PLAINTITLE) var plainTitle: String = "",
+ @ColumnInfo(name = COL_PLAINSNIPPET) var plainSnippet: String = "",
+ @ColumnInfo(name = COL_IMAGEURL) var imageUrl: String? = null,
+ @ColumnInfo(name = COL_ENCLOSURELINK) var enclosureLink: String? = null,
+ var author: String? = null,
+ @ColumnInfo(name = COL_PUBDATE) var pubDate: ZonedDateTime? = null,
+ override var link: String? = null,
+ var tag: String = "",
+ var unread: Boolean = true,
+ @ColumnInfo(name = COL_FEEDID) var feedId: Long? = null,
+ @ColumnInfo(name = COL_FEEDTITLE) var feedTitle: String = "",
+ @ColumnInfo(name = COL_FEEDCUSTOMTITLE) var feedCustomTitle: String = "",
+ @ColumnInfo(name = COL_FEEDURL) var feedUrl: URL = sloppyLinkToStrictURLNoThrows(""),
+ @ColumnInfo(name = COL_FULLTEXT_BY_DEFAULT) var fullTextByDefault: Boolean = false
+) : FeedItemForFetching {
+ constructor() : this(id = ID_UNSET)
+
+ val feedDisplayTitle: String
+ get() = if (feedCustomTitle.isBlank()) feedTitle else feedCustomTitle
+
+ val enclosureFilename: String?
+ get() {
+ enclosureLink?.let { enclosureLink ->
+ var fname: String? = null
+ try {
+ fname = URI(enclosureLink).path.split("/").last()
+ } catch (e: Exception) {
+ }
+ return if (fname == null || fname.isEmpty()) {
+ null
+ } else {
+ fname
+ }
+ }
+ return null
+ }
+
+ val pubDateString: String?
+ get() = pubDate?.toString()
+
+ val domain: String?
+ get() {
+ val l: String? = enclosureLink ?: link
+ if (l != null) {
+ try {
+ return URL(l).host.replace("www.", "")
+ } catch (e: Throwable) {
+ }
+ }
+ return null
+ }
+
+ fun storeInBundle(bundle: Bundle): Bundle {
+ bundle.storeFeedItem()
+ return bundle
+ }
+
+ private fun Bundle.storeFeedItem() {
+ setLong(ARG_ID to id)
+ setString(ARG_TITLE to plainTitle)
+ setString(ARG_LINK to link)
+ setString(ARG_ENCLOSURE to enclosureLink)
+ setString(ARG_IMAGEURL to imageUrl)
+ setString(ARG_FEED_TITLE to feedDisplayTitle)
+ setString(ARG_AUTHOR to author)
+ setString(ARG_DATE to pubDateString)
+ setString(ARG_FEED_URL to feedUrl.toString())
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedTitle.kt b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedTitle.kt
new file mode 100644
index 0000000..cb33f0d
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/db/room/FeedTitle.kt
@@ -0,0 +1,18 @@
+package com.nononsenseapps.feeder.db.room
+
+import androidx.room.ColumnInfo
+import androidx.room.Ignore
+import com.nononsenseapps.feeder.db.COL_CUSTOM_TITLE
+import com.nononsenseapps.feeder.db.COL_ID
+import com.nononsenseapps.feeder.db.COL_TITLE
+
+data class FeedTitle @Ignore constructor(
+ @ColumnInfo(name = COL_ID) var id: Long = ID_UNSET,
+ @ColumnInfo(name = COL_TITLE) var title: String = "",
+ @ColumnInfo(name = COL_CUSTOM_TITLE) var customTitle: String = ""
+) {
+ constructor() : this(id = ID_UNSET)
+
+ val displayTitle: String
+ get() = (if (customTitle.isBlank()) title else customTitle)
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/di/NetworkModule.kt b/app/src/main/java/com/nononsenseapps/feeder/di/NetworkModule.kt
new file mode 100644
index 0000000..17f8a05
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/di/NetworkModule.kt
@@ -0,0 +1,24 @@
+package com.nononsenseapps.feeder.di
+
+import com.nononsenseapps.feeder.model.FeedParser
+import com.nononsenseapps.feeder.ui.CustomTabsWarmer
+import com.nononsenseapps.jsonfeed.Feed
+import com.nononsenseapps.jsonfeed.JsonFeedParser
+import com.nononsenseapps.jsonfeed.feedAdapter
+import com.squareup.moshi.JsonAdapter
+import kotlinx.coroutines.FlowPreview
+import okhttp3.OkHttpClient
+import org.kodein.di.Kodein
+import org.kodein.di.generic.bind
+import org.kodein.di.generic.instance
+import org.kodein.di.generic.provider
+import org.kodein.di.generic.singleton
+
+@FlowPreview
+val networkModule = Kodein.Module(name = "network") {
+ // Parsers can carry state so safer to use providers
+ bind>() with provider { feedAdapter() }
+ bind() with provider { JsonFeedParser(instance(), instance()) }
+ bind() with provider { FeedParser(kodein) }
+ bind() with singleton { CustomTabsWarmer(kodein) }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/di/StateModule.kt b/app/src/main/java/com/nononsenseapps/feeder/di/StateModule.kt
new file mode 100644
index 0000000..0bc9b3a
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/di/StateModule.kt
@@ -0,0 +1,16 @@
+package com.nononsenseapps.feeder.di
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.ConflatedBroadcastChannel
+import org.kodein.di.Kodein
+import org.kodein.di.generic.bind
+import org.kodein.di.generic.singleton
+
+@ExperimentalCoroutinesApi
+val stateModule = Kodein.Module(name = "state objects") {
+ bind>(tag = CURRENTLY_SYNCING_STATE) with singleton {
+ ConflatedBroadcastChannel(value = false)
+ }
+}
+
+const val CURRENTLY_SYNCING_STATE = "CurrentlySyncingState"
diff --git a/app/src/main/java/com/nononsenseapps/feeder/di/ViewModelModule.kt b/app/src/main/java/com/nononsenseapps/feeder/di/ViewModelModule.kt
new file mode 100644
index 0000000..764f3a6
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/di/ViewModelModule.kt
@@ -0,0 +1,31 @@
+package com.nononsenseapps.feeder.di
+
+import com.nononsenseapps.feeder.base.KodeinAwareViewModelFactory
+import com.nononsenseapps.feeder.base.activityViewModelProvider
+import com.nononsenseapps.feeder.base.bindWithKodeinAwareViewModelFactory
+import com.nononsenseapps.feeder.model.EphemeralState
+import com.nononsenseapps.feeder.model.FeedItemViewModel
+import com.nononsenseapps.feeder.model.FeedItemsViewModel
+import com.nononsenseapps.feeder.model.FeedListViewModel
+import com.nononsenseapps.feeder.model.FeedViewModel
+import com.nononsenseapps.feeder.model.SettingsViewModel
+import com.nononsenseapps.feeder.model.TextToSpeechViewModel
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import org.kodein.di.Kodein
+import org.kodein.di.generic.bind
+import org.kodein.di.generic.singleton
+
+@FlowPreview
+@ExperimentalCoroutinesApi
+val viewModelModule = Kodein.Module(name = "view models") {
+ bind() with singleton { KodeinAwareViewModelFactory(kodein) }
+ bindWithKodeinAwareViewModelFactory()
+ bindWithKodeinAwareViewModelFactory()
+ bindWithKodeinAwareViewModelFactory()
+ bindWithKodeinAwareViewModelFactory()
+ bindWithKodeinAwareViewModelFactory()
+ bindWithKodeinAwareViewModelFactory()
+
+ bind() with activityViewModelProvider()
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/EphemeralState.kt b/app/src/main/java/com/nononsenseapps/feeder/model/EphemeralState.kt
new file mode 100644
index 0000000..8f6032d
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/EphemeralState.kt
@@ -0,0 +1,26 @@
+package com.nononsenseapps.feeder.model
+
+import com.nononsenseapps.feeder.base.KodeinAwareViewModel
+import com.nononsenseapps.feeder.db.room.ID_UNSET
+import org.kodein.di.Kodein
+
+/**
+ * Should only be created with the activity as its lifecycle
+ */
+class EphemeralState(kodein: Kodein) : KodeinAwareViewModel(kodein) {
+ var lastOpenFeedId: Long = ID_UNSET
+ set(value) {
+ if (value != lastOpenFeedId) {
+ firstVisibleListItem = null
+ }
+ field = value
+ }
+ var lastOpenFeedTag: String = ""
+ set(value) {
+ if (value != lastOpenFeedTag) {
+ firstVisibleListItem = null
+ }
+ field = value
+ }
+ var firstVisibleListItem: Int? = null
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/FeedItemViewModel.kt b/app/src/main/java/com/nononsenseapps/feeder/model/FeedItemViewModel.kt
new file mode 100644
index 0000000..185a455
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/FeedItemViewModel.kt
@@ -0,0 +1,258 @@
+package com.nononsenseapps.feeder.model
+
+import android.app.Activity
+import android.app.Application
+import android.graphics.Point
+import android.text.SpannableString
+import android.text.Spanned
+import android.text.style.ImageSpan
+import android.util.Log
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.LiveDataScope
+import androidx.lifecycle.asLiveData
+import androidx.lifecycle.liveData
+import androidx.lifecycle.viewModelScope
+import com.nononsenseapps.feeder.R
+import com.nononsenseapps.feeder.base.KodeinAwareViewModel
+import com.nononsenseapps.feeder.blob.blobFullFile
+import com.nononsenseapps.feeder.blob.blobFullInputStream
+import com.nononsenseapps.feeder.blob.blobInputStream
+import com.nononsenseapps.feeder.db.room.FeedItemDao
+import com.nononsenseapps.feeder.db.room.FeedItemWithFeed
+import com.nononsenseapps.feeder.ui.text.UrlClickListener
+import com.nononsenseapps.feeder.ui.text.toSpannedWithImages
+import com.nononsenseapps.feeder.ui.text.toSpannedWithNoImages
+import com.nononsenseapps.feeder.util.TabletUtils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import org.kodein.di.Kodein
+import org.kodein.di.generic.instance
+import java.io.InputStream
+import java.net.URL
+import kotlin.math.roundToInt
+
+@FlowPreview
+class FeedItemViewModel(kodein: Kodein) : KodeinAwareViewModel(kodein) {
+ private val dao: FeedItemDao by instance()
+ val context: Application by instance()
+ private val okHttpClient: OkHttpClient by instance()
+
+ private lateinit var liveItem: LiveData
+ private lateinit var feedItem: FeedItemWithFeed
+
+ private lateinit var liveDefaultText: LiveData
+ private var currentDefaultTextOptions: TextOptions? = null
+
+ private lateinit var liveFullText: LiveData
+ private var currentFullTextOptions: TextOptions? = null
+
+ private var fragmentUrlClickListener: UrlClickListener? = null
+
+ fun getLiveItem(id: Long): LiveData {
+ if (!this::liveItem.isInitialized) {
+ liveItem = dao.loadLiveFeedItem(id).asLiveData()
+ }
+ return liveItem
+ }
+
+ suspend fun getItem(id: Long): FeedItemWithFeed {
+ if (!this::feedItem.isInitialized || feedItem.id != id) {
+ feedItem = dao.loadFeedItemWithFeed(id) ?: error("no such item $id")
+ }
+ return feedItem
+ }
+
+ suspend fun getLiveTextMaybeFull(
+ options: TextOptions,
+ urlClickListener: UrlClickListener?
+ ): LiveData =
+ when (getItem(options.itemId).fullTextByDefault) {
+ true -> getLiveFullText(options, urlClickListener)
+ false -> getLiveDefaultText(options, urlClickListener)
+ }
+
+ suspend fun getLiveDefaultText(
+ options: TextOptions,
+ urlClickListener: UrlClickListener?
+ ): LiveData {
+ // Always update urlClickListener
+ fragmentUrlClickListener = urlClickListener
+
+ if (this::liveDefaultText.isInitialized && currentDefaultTextOptions == options) {
+ Log.d("FeederItemViewModel", "Requested default text for old options: $options")
+ return liveDefaultText
+ }
+
+ Log.d("FeederItemViewModel", "Requested default text for new options: $options")
+
+ liveDefaultText = liveData(context = viewModelScope.coroutineContext) {
+ loadTextFrom(options) {
+ blobInputStream(
+ itemId = options.itemId,
+ filesDir = context.filesDir
+ )
+ }
+ }
+
+ currentDefaultTextOptions = options
+ return liveDefaultText
+ }
+
+ fun getLiveFullText(
+ options: TextOptions,
+ urlClickListener: UrlClickListener?
+ ): LiveData {
+ // Always update urlClickListener
+ fragmentUrlClickListener = urlClickListener
+
+ if (this::liveFullText.isInitialized && currentFullTextOptions == options) {
+ Log.d("FeederItemViewModel", "Requested full text for old options: $options")
+ return liveFullText
+ }
+
+ Log.d("FeederItemViewModel", "Requested full text for new options: $options")
+
+ liveFullText = liveData(context = viewModelScope.coroutineContext) {
+ val fullTextPresent = fetchFullArticleIfMissing(
+ itemId = options.itemId
+ )
+ if (fullTextPresent) {
+ loadTextFrom(options) {
+ blobFullInputStream(
+ itemId = options.itemId,
+ filesDir = context.filesDir
+ )
+ }
+ }
+ }
+
+ currentFullTextOptions = options
+ return liveFullText
+ }
+
+ suspend fun markAsRead(id: Long, unread: Boolean = false) = dao.markAsRead(id = id, unread = unread)
+ suspend fun markAsReadAndNotified(id: Long) = dao.markAsReadAndNotified(id = id)
+
+ private suspend fun LiveDataScope.loadTextFrom(
+ options: TextOptions,
+ streamProvider: () -> InputStream
+ ) {
+ val feedUrl = dao.loadFeedUrlOfFeedItem(id = options.itemId)
+ ?: URL("https://missing.feedurl")
+
+ Log.d("FeederItemViewModel", "Loading noImages for $options")
+ val hasImageSpans = try {
+ val noImages = withContext(Dispatchers.IO) {
+ streamProvider().bufferedReader().use { reader ->
+ toSpannedWithNoImages(
+ kodein = kodein,
+ source = reader,
+ siteUrl = feedUrl,
+ maxSize = options.maxImageSize,
+ urlClickListener = { link ->
+ fragmentUrlClickListener?.invoke(link)
+ }
+ )
+ }
+ }
+ emit(noImages)
+ noImages.getAllImageSpans().isNotEmpty()
+ } catch (e: Exception) {
+ Log.e(
+ "FeederItemViewModel",
+ "Failed to load text with no images for $options",
+ e
+ )
+ emit(
+ SpannableString("Could not read blob for item with id [${options.itemId}]")
+ )
+ false
+ }
+
+ try {
+ if (hasImageSpans) {
+ Log.d("FeederItemViewModel", "Loading withImages for $options")
+ val withImages = withContext(Dispatchers.IO) {
+ streamProvider().bufferedReader().use { reader ->
+ toSpannedWithImages(
+ kodein = kodein,
+ source = reader,
+ siteUrl = feedUrl,
+ maxSize = options.maxImageSize,
+ urlClickListener = { link ->
+ fragmentUrlClickListener?.invoke(link)
+ }
+ )
+ }
+ }
+ emit(
+ withImages
+ )
+ }
+ } catch (e: Exception) {
+ Log.e(
+ "FeederItemViewModel",
+ "Failed to load text with images for $options",
+ e
+ )
+ }
+ }
+
+ private suspend fun LiveDataScope.fetchFullArticleIfMissing(itemId: Long): Boolean {
+ return if (blobFullFile(itemId, context.filesDir).isFile) {
+ true
+ } else {
+ Log.d("FeederItemViewModel", "Fetching full text for $itemId")
+ emit(SpannableString(context.getString(R.string.fetching_full_article)))
+
+ val item = dao.loadFeedItem(itemId)
+
+ if (item == null) {
+ Log.e("FeederItemViewModel", "No such item: $itemId")
+ emit(SpannableString(context.getString(R.string.failed_to_fetch_full_article)))
+ return false
+ }
+
+ val (result, throwable) = parseFullArticle(
+ item,
+ okHttpClient,
+ context.filesDir
+ )
+
+ if (!result) {
+ var reason = context.getString(R.string.failed_to_fetch_full_article)
+ if (throwable != null) {
+ reason += "\n${throwable.message}"
+ }
+ emit(SpannableString(reason))
+ }
+
+ return result
+ }
+ }
+}
+
+internal fun Activity.maxImageSize(): Point {
+ val size = Point()
+ windowManager?.defaultDisplay?.getSize(size)
+ if (TabletUtils.isTablet(this)) {
+ // Using twice window height since we do scroll vertically
+ size.set(resources.getDimension(R.dimen.reader_tablet_width).roundToInt(), 2 * size.y)
+ } else {
+ // Base it on window size
+ size.set(size.x - 2 * resources.getDimension(R.dimen.keyline_1).roundToInt(), 2 * size.y)
+ }
+
+ return size
+}
+
+private fun Spanned.getAllImageSpans(): Array =
+ getSpans(0, length, ImageSpan::class.java) ?: emptyArray()
+
+data class TextOptions(
+ val itemId: Long,
+ val maxImageSize: Point,
+ val nightMode: Boolean
+)
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/FeedItemsViewModel.kt b/app/src/main/java/com/nononsenseapps/feeder/model/FeedItemsViewModel.kt
new file mode 100644
index 0000000..108091d
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/FeedItemsViewModel.kt
@@ -0,0 +1,136 @@
+package com.nononsenseapps.feeder.model
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.Transformations
+import androidx.paging.DataSource
+import androidx.paging.LivePagedListBuilder
+import androidx.paging.PagedList
+import com.nononsenseapps.feeder.base.KodeinAwareViewModel
+import com.nononsenseapps.feeder.db.room.FeedItem
+import com.nononsenseapps.feeder.db.room.FeedItemDao
+import com.nononsenseapps.feeder.db.room.ID_ALL_FEEDS
+import com.nononsenseapps.feeder.db.room.ID_UNSET
+import kotlinx.coroutines.FlowPreview
+import org.kodein.di.Kodein
+import org.kodein.di.generic.instance
+
+private val PAGE_SIZE = 50
+
+@FlowPreview
+class FeedItemsViewModel(kodein: Kodein) : KodeinAwareViewModel(kodein) {
+ private val dao: FeedItemDao by instance()
+ private val liveOnlyUnread = MutableLiveData()
+ private val liveNewestFirst = MutableLiveData()
+
+ init {
+ liveOnlyUnread.value = true
+ liveNewestFirst.value = true
+ }
+
+ private lateinit var livePagedAll: LiveData>
+ private lateinit var livePagedUnread: LiveData>
+ private lateinit var livePreviews: LiveData>
+
+ fun getLiveDbPreviews(feedId: Long, tag: String): LiveData> {
+ if (!this::livePreviews.isInitialized) {
+ livePagedAll = Transformations.switchMap(liveNewestFirst) { newestFirst ->
+ LivePagedListBuilder(
+ when {
+ feedId > ID_UNSET -> loadLivePreviews(feedId = feedId, newestFirst = newestFirst)
+ feedId == ID_ALL_FEEDS -> loadLivePreviews(newestFirst = newestFirst)
+ tag.isNotEmpty() -> loadLivePreviews(tag = tag, newestFirst = newestFirst)
+ else -> throw IllegalArgumentException("Tag was empty, but no valid feed id was provided either")
+ },
+ PAGE_SIZE
+ ).build()
+ }
+
+ livePagedUnread = Transformations.switchMap(liveNewestFirst) { newestFirst ->
+ LivePagedListBuilder(
+ when {
+ feedId > ID_UNSET -> loadLiveUnreadPreviews(feedId = feedId, newestFirst = newestFirst)
+ feedId == ID_ALL_FEEDS -> loadLiveUnreadPreviews(newestFirst = newestFirst)
+ tag.isNotEmpty() -> loadLiveUnreadPreviews(tag = tag, newestFirst = newestFirst)
+ else -> throw IllegalArgumentException("Tag was empty, but no valid feed id was provided either")
+ },
+ PAGE_SIZE
+ ).build()
+ }
+
+ livePreviews = Transformations.switchMap(liveOnlyUnread) { onlyUnread ->
+ if (onlyUnread) {
+ livePagedUnread
+ } else {
+ livePagedAll
+ }
+ }
+ }
+ return livePreviews
+ }
+
+ fun setOnlyUnread(onlyUnread: Boolean) {
+ liveOnlyUnread.value = onlyUnread
+ }
+
+ fun setNewestFirst(newestFirst: Boolean) {
+ liveNewestFirst.value = newestFirst
+ }
+
+ suspend fun markAllAsRead(feedId: Long, tag: String) {
+ when {
+ feedId > ID_UNSET -> dao.markAllAsRead(feedId)
+ feedId == ID_ALL_FEEDS -> dao.markAllAsRead()
+ tag.isNotEmpty() -> dao.markAllAsRead(tag)
+ }
+ }
+
+ suspend fun toggleReadState(feedItem: PreviewItem) {
+ dao.markAsRead(feedItem.id, unread = !feedItem.unread)
+ cancelNotification(getApplication(), feedItem.id)
+ }
+
+ suspend fun markAsNotified(feedId: Long, tag: String) = when {
+ feedId > ID_UNSET -> dao.markAsNotified(feedId)
+ feedId == ID_ALL_FEEDS -> dao.markAllAsNotified()
+ tag.isNotEmpty() -> dao.markTagAsNotified(tag)
+ else -> error("Invalid input for markAsNotified")
+ }
+
+ suspend fun markAsNotified(ids: List, notified: Boolean = true) =
+ dao.markAsNotified(ids = ids, notified = notified)
+
+ suspend fun markAsRead(ids: List, unread: Boolean = false) =
+ dao.markAsRead(ids = ids, unread = unread)
+
+ suspend fun markAsRead(id: Long, unread: Boolean = false) =
+ dao.markAsRead(id = id, unread = unread)
+
+ suspend fun loadFeedItemsInFeed(feedId: Long, newestFirst: Boolean): List {
+ return if (newestFirst) dao.loadFeedItemsInFeedDesc(feedId) else dao.loadFeedItemsInFeedAsc(feedId)
+ }
+
+ fun loadLivePreviews(feedId: Long, newestFirst: Boolean): DataSource.Factory {
+ return if (newestFirst) dao.loadLivePreviewsDesc(feedId) else dao.loadLivePreviewsAsc(feedId)
+ }
+
+ fun loadLivePreviews(tag: String, newestFirst: Boolean): DataSource.Factory {
+ return if (newestFirst) dao.loadLivePreviewsDesc(tag) else dao.loadLivePreviewsAsc(tag)
+ }
+
+ fun loadLivePreviews(newestFirst: Boolean): DataSource.Factory {
+ return if (newestFirst) dao.loadLivePreviewsDesc() else dao.loadLivePreviewsAsc()
+ }
+
+ fun loadLiveUnreadPreviews(feedId: Long?, unread: Boolean = true, newestFirst: Boolean): DataSource.Factory {
+ return if (newestFirst) dao.loadLiveUnreadPreviewsDesc(feedId, unread) else dao.loadLiveUnreadPreviewsAsc(feedId, unread)
+ }
+
+ fun loadLiveUnreadPreviews(tag: String, unread: Boolean = true, newestFirst: Boolean): DataSource.Factory {
+ return if (newestFirst) dao.loadLiveUnreadPreviewsDesc(tag, unread) else dao.loadLiveUnreadPreviewsAsc(tag, unread)
+ }
+
+ fun loadLiveUnreadPreviews(unread: Boolean = true, newestFirst: Boolean): DataSource.Factory {
+ return if (newestFirst) dao.loadLiveUnreadPreviewsDesc(unread) else dao.loadLiveUnreadPreviewsAsc(unread)
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/FeedListViewModel.kt b/app/src/main/java/com/nononsenseapps/feeder/model/FeedListViewModel.kt
new file mode 100644
index 0000000..90f1c54
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/FeedListViewModel.kt
@@ -0,0 +1,53 @@
+package com.nononsenseapps.feeder.model
+
+import androidx.collection.ArrayMap
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.liveData
+import androidx.lifecycle.viewModelScope
+import com.nononsenseapps.feeder.base.KodeinAwareViewModel
+import com.nononsenseapps.feeder.db.room.FeedDao
+import com.nononsenseapps.feeder.db.room.ID_ALL_FEEDS
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.collectLatest
+import org.kodein.di.Kodein
+import org.kodein.di.generic.instance
+import kotlin.collections.set
+
+@ExperimentalCoroutinesApi
+class FeedListViewModel(kodein: Kodein) : KodeinAwareViewModel(kodein) {
+ private val dao: FeedDao by instance()
+ private val feedsWithUnreadCounts = dao.loadLiveFeedsWithUnreadCounts()
+
+ val liveFeedsAndTagsWithUnreadCounts: LiveData> by lazy {
+ liveData>(viewModelScope.coroutineContext + Dispatchers.Default, 5000L) {
+ feedsWithUnreadCounts.collectLatest { feeds ->
+ val topTag = FeedUnreadCount(id = ID_ALL_FEEDS)
+ val tags: MutableMap = ArrayMap()
+ val data: MutableList = mutableListOf(topTag)
+
+ feeds.forEach { feed ->
+ if (feed.tag.isNotEmpty()) {
+ if (!tags.contains(feed.tag)) {
+ val tag = FeedUnreadCount(tag = feed.tag)
+ data.add(tag)
+ tags[feed.tag] = tag
+ }
+
+ tags[feed.tag]?.let { tag ->
+ tag.unreadCount += feed.unreadCount
+ }
+ }
+
+ topTag.unreadCount += feed.unreadCount
+
+ data.add(feed)
+ }
+
+ data.sortWith(Comparator { a, b -> a.compareTo(b) })
+
+ emit(data)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/FeedParser.kt b/app/src/main/java/com/nononsenseapps/feeder/model/FeedParser.kt
new file mode 100644
index 0000000..f59f1fa
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/FeedParser.kt
@@ -0,0 +1,378 @@
+package com.nononsenseapps.feeder.model
+
+import android.util.Log
+import com.nononsenseapps.feeder.util.asFeed
+import com.nononsenseapps.feeder.util.relativeLinkIntoAbsolute
+import com.nononsenseapps.feeder.util.relativeLinkIntoAbsoluteOrThrow
+import com.nononsenseapps.feeder.util.sloppyLinkToStrictURL
+import com.nononsenseapps.jsonfeed.Feed
+import com.nononsenseapps.jsonfeed.JsonFeedParser
+import com.rometools.rome.io.SyndFeedInput
+import com.rometools.rome.io.XmlReader
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.withContext
+import okhttp3.Authenticator
+import okhttp3.CacheControl
+import okhttp3.Credentials
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.Route
+import okio.Buffer
+import okio.GzipSource
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Document
+import org.kodein.di.Kodein
+import org.kodein.di.KodeinAware
+import org.kodein.di.generic.instance
+import java.io.EOFException
+import java.io.IOException
+import java.io.InputStream
+import java.net.MalformedURLException
+import java.net.URL
+import java.net.URLDecoder
+import java.nio.charset.Charset
+import java.util.concurrent.TimeUnit
+
+val slashPattern = """<\s*slash:comments\s*/>""".toRegex(RegexOption.IGNORE_CASE)
+private const val YOUTUBE_CHANNEL_ID_ATTR = "data-channel-external-id"
+
+@FlowPreview
+class FeedParser(override val kodein: Kodein) : KodeinAware {
+ private val client: OkHttpClient by instance()
+ private val jsonFeedParser: JsonFeedParser by instance()
+
+ /**
+ * Finds the preferred alternate link in the header of an HTML/XML document pointing to feeds.
+ */
+ fun findFeedUrl(
+ html: String,
+ preferRss: Boolean = false,
+ preferAtom: Boolean = false,
+ preferJSON: Boolean = false
+ ): URL? {
+
+ val feedLinks = getAlternateFeedLinksInHtml(html)
+ .sortedBy {
+ val t = it.second.toLowerCase()
+ when {
+ preferAtom && t.contains("atom") -> "0"
+ preferRss && t.contains("rss") -> "1"
+ preferJSON && t.contains("json") -> "2"
+ else -> t
+ }
+ }
+ .map {
+ sloppyLinkToStrictURL(it.first) to it.second
+ }
+
+ return feedLinks.firstOrNull()?.first
+ }
+
+ /**
+ * Returns all alternate links in the header of an HTML/XML document pointing to feeds.
+ */
+ suspend fun getAlternateFeedLinksAtUrl(url: URL): List> {
+ return try {
+ val html = curl(url)
+ when {
+ html != null -> getAlternateFeedLinksInHtml(html, baseUrl = url)
+ else -> emptyList()
+ }
+ } catch (t: Throwable) {
+ Log.e("FeedParser", "Error when fetching alternate links", t)
+ emptyList()
+ }
+ }
+
+ /**
+ * Returns all alternate links in the HTML/XML document pointing to feeds.
+ */
+ fun getAlternateFeedLinksInHtml(html: String, baseUrl: URL? = null): List> {
+ val doc = Jsoup.parse(html.byteInputStream(), "UTF-8", "")
+
+ val feeds = doc.getElementsByAttributeValue("rel", "alternate")
+ ?.filter { it.hasAttr("href") && it.hasAttr("type") }
+ ?.filter {
+ val t = it.attr("type").toLowerCase()
+ when {
+ t.contains("application/atom") -> true
+ t.contains("application/rss") -> true
+ // Youtube for example has alternate links with application/json+oembed type.
+ t == "application/json" -> true
+ else -> false
+ }
+ }
+ ?.filter {
+ val l = it.attr("href").toLowerCase()
+ try {
+ if (baseUrl != null) {
+ relativeLinkIntoAbsoluteOrThrow(base = baseUrl, link = l)
+ } else {
+ URL(l)
+ }
+ true
+ } catch (_: MalformedURLException) {
+ false
+ }
+ }
+ ?.map {
+ when {
+ baseUrl != null -> relativeLinkIntoAbsolute(base = baseUrl, link = it.attr("href")) to it.attr("type")
+ else -> sloppyLinkToStrictURL(it.attr("href")).toString() to it.attr("type")
+ }
+ } ?: emptyList()
+
+ return when {
+ feeds.isNotEmpty() -> feeds
+ baseUrl?.host == "www.youtube.com" || baseUrl?.host == "youtube.com" -> findFeedLinksForYoutube(doc)
+ else -> emptyList()
+ }
+ }
+
+ private fun findFeedLinksForYoutube(doc: Document): List> {
+ val channelId: String? = doc.body()?.getElementsByAttribute(YOUTUBE_CHANNEL_ID_ATTR)
+ ?.firstOrNull()
+ ?.attr(YOUTUBE_CHANNEL_ID_ATTR)
+
+ return when (channelId) {
+ null -> emptyList()
+ else -> listOf("https://www.youtube.com/feeds/videos.xml?channel_id=$channelId" to "atom")
+ }
+ }
+
+ /**
+ * @throws IOException if request fails due to network issue for example
+ */
+ private suspend fun curl(url: URL) = client.curl(url)
+
+ /**
+ * @throws IOException if request fails due to network issue for example
+ */
+ private suspend fun curlAndOnResponse(url: URL, block: (suspend (Response) -> Unit)) =
+ client.curlAndOnResponse(url, block)
+
+ @Throws(FeedParsingError::class)
+ suspend fun parseFeedUrl(url: URL): Feed? {
+ try {
+ var result: Feed? = null
+ curlAndOnResponse(url) {
+ result = parseFeedResponse(it)
+ }
+ // Preserve original URL to maintain authentication data and/or tokens in query params
+ return result?.copy(feed_url = url.toString())
+ } catch (e: Throwable) {
+ throw FeedParsingError(url, e)
+ }
+ }
+
+ @Throws(FeedParsingError::class)
+ fun parseFeedResponse(response: Response): Feed? =
+ response.safeBody()?.let { body ->
+ parseFeedResponse(response, body)
+ }
+
+ /**
+ * Takes body as bytes to handle encoding correctly
+ */
+ @Throws(FeedParsingError::class)
+ fun parseFeedResponse(response: Response, body: ByteArray): Feed {
+ try {
+ val feed = when ((response.header("content-type") ?: "").contains("json")) {
+ true -> jsonFeedParser.parseJsonBytes(body)
+ false -> parseRssAtomBytes(response.request.url.toUrl(), body)
+ }
+
+ return if (feed.feed_url == null) {
+ // Nice to return non-null value here
+ feed.copy(feed_url = response.request.url.toString())
+ } else {
+ feed
+ }
+ } catch (e: Throwable) {
+ throw FeedParsingError(response.request.url.toUrl(), e)
+ }
+ }
+
+ /**
+ * Takes body as bytes to handle encoding correctly
+ */
+ @Throws(FeedParsingError::class)
+ suspend fun parseFeedResponseOrFallbackToAlternateLink(response: Response): Feed? =
+ response.body?.use { responseBody ->
+ responseBody.bytes().let { body ->
+ // Encoding is not an issue for reading HTML (probably)
+ val alternateFeedLink = findFeedUrl(String(body), preferAtom = true)
+
+ return if (alternateFeedLink != null) {
+ parseFeedUrl(alternateFeedLink)
+ } else {
+ parseFeedResponse(response, body)
+ }
+ }
+ }
+
+ @Throws(FeedParsingError::class)
+ internal fun parseRssAtomBytes(baseUrl: URL, feedXml: ByteArray): Feed {
+ try {
+ feedXml.inputStream().use { return parseFeedInputStream(baseUrl, it) }
+ } catch (e: NumberFormatException) {
+ try {
+ // Try to work around bug in Rome
+ var encoding: String? = null
+ val xml: String = slashPattern.replace(
+ feedXml.inputStream().use {
+ XmlReader(it).use {
+ encoding = it.encoding
+ it.readText()
+ }
+ },
+ ""
+ )
+
+ xml.byteInputStream(Charset.forName(encoding ?: "UTF-8")).use {
+ return parseFeedInputStream(baseUrl, it)
+ }
+ } catch (e: Throwable) {
+ throw FeedParsingError(baseUrl, e)
+ }
+ }
+ }
+
+ @Throws(FeedParsingError::class)
+ internal fun parseFeedInputStream(baseUrl: URL, `is`: InputStream): Feed {
+ `is`.use {
+ try {
+ val feed = XmlReader(`is`).use { SyndFeedInput().build(it) }
+ return feed.asFeed(baseUrl = baseUrl)
+ } catch (e: NumberFormatException) {
+ throw e
+ } catch (e: Throwable) {
+ throw FeedParsingError(baseUrl, e)
+ }
+ }
+ }
+
+ class FeedParsingError(val url: URL, e: Throwable) : Exception(e.message, e)
+}
+
+fun Response.safeBody(): ByteArray? {
+ return this.body?.use { body ->
+ if (header("Transfer-Encoding") == "chunked") {
+ val source =
+ if (header("Content-Encoding") == "gzip") {
+ GzipSource(body.source())
+ } else {
+ body.source()
+ }
+ val buffer = Buffer()
+ try {
+ var readBytes: Long = 0
+ while (readBytes != -1L) {
+ readBytes = source.read(buffer, Long.MAX_VALUE)
+ }
+ } catch (e: EOFException) {
+ // This is not always fatal - sometimes the server might have sent the wrong
+ // content-length (I suspect)
+ Log.e(
+ "FeedParser",
+ "Encountered EOF exception while parsing response with headers: $headers",
+ e
+ )
+ }
+ buffer.readByteArray()
+ } else {
+ body.bytes()
+ }
+ }
+}
+
+suspend fun OkHttpClient.getResponse(url: URL, forceNetwork: Boolean = false): Response {
+ val request = Request.Builder()
+ .url(url)
+ .cacheControl(
+ CacheControl.Builder()
+ .let {
+ if (forceNetwork) {
+ // Force a cache revalidation
+ it.maxAge(0, TimeUnit.SECONDS)
+ } else {
+ // Do a cache revalidation at most every minute
+ it.maxAge(1, TimeUnit.MINUTES)
+ }
+ }
+ .build()
+ )
+ .build()
+
+ val clientToUse = if (url.userInfo?.isNotBlank() == true) {
+ val parts = url.userInfo.split(':')
+ val user = parts.first()
+ val pass = if (parts.size > 1) {
+ parts[1]
+ } else {
+ ""
+ }
+ val decodedUser = URLDecoder.decode(user, "UTF-8")
+ val decodedPass = URLDecoder.decode(pass, "UTF-8")
+ val credentials = Credentials.basic(decodedUser, decodedPass)
+ newBuilder()
+ .authenticator(object: Authenticator {
+ override fun authenticate(route: Route?, response: Response): Request? {
+ return when {
+ response.request.header("Authorization") != null -> {
+ null
+ }
+ else -> {
+ response.request.newBuilder()
+ .header("Authorization", credentials)
+ .build()
+ }
+ }
+ }
+ })
+ .proxyAuthenticator(object: Authenticator {
+ override fun authenticate(route: Route?, response: Response): Request? {
+ return when {
+ response.request.header("Proxy-Authorization") != null -> {
+ null
+ }
+ else -> {
+ response.request.newBuilder()
+ .header("Proxy-Authorization", credentials)
+ .build()
+ }
+ }
+ }
+
+ })
+ .build()
+ } else {
+ this
+ }
+
+ return withContext(IO) {
+ clientToUse.newCall(request).execute()
+ }
+}
+
+suspend fun OkHttpClient.curl(url: URL): String? {
+ var result: String? = null
+ curlAndOnResponse(url) {
+ result = it.body?.string()
+ }
+ return result
+}
+
+suspend fun OkHttpClient.curlAndOnResponse(url: URL, block: (suspend (Response) -> Unit)) {
+ val response = getResponse(url)
+
+ if (!response.isSuccessful) {
+ throw IOException("Unexpected code $response")
+ }
+
+ response.use {
+ block(it)
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/FeedSyncer.kt b/app/src/main/java/com/nononsenseapps/feeder/model/FeedSyncer.kt
new file mode 100644
index 0000000..26209d3
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/FeedSyncer.kt
@@ -0,0 +1,168 @@
+package com.nononsenseapps.feeder.model
+
+import android.content.Context
+import androidx.work.Constraints
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import androidx.work.workDataOf
+import com.nononsenseapps.feeder.db.room.ID_UNSET
+import com.nononsenseapps.feeder.di.CURRENTLY_SYNCING_STATE
+import com.nononsenseapps.feeder.ui.ARG_FEED_ID
+import com.nononsenseapps.feeder.ui.ARG_FEED_TAG
+import com.nononsenseapps.feeder.util.Prefs
+import com.nononsenseapps.feeder.util.currentlyCharging
+import com.nononsenseapps.feeder.util.currentlyConnected
+import com.nononsenseapps.feeder.util.currentlyUnmetered
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.channels.ConflatedBroadcastChannel
+import org.kodein.di.Kodein
+import org.kodein.di.KodeinAware
+import org.kodein.di.android.closestKodein
+import org.kodein.di.generic.instance
+import java.util.concurrent.TimeUnit
+
+const val ARG_FORCE_NETWORK = "force_network"
+
+const val UNIQUE_PERIODIC_NAME = "feeder_periodic"
+const val PARALLEL_SYNC = "parallel_sync"
+const val MIN_FEED_AGE_MINUTES = "min_feed_age_minutes"
+const val IGNORE_CONNECTIVITY_SETTINGS = "ignore_connectivity_settings"
+
+fun isOkToSyncAutomatically(context: Context): Boolean {
+ val kodein: Kodein by closestKodein(context)
+ val prefs: Prefs by kodein.instance()
+ return (
+ currentlyConnected(context) &&
+ (!prefs.onlySyncWhileCharging || currentlyCharging(context)) &&
+ (!prefs.onlySyncOnWIfi || currentlyUnmetered(context))
+ )
+}
+
+@FlowPreview
+@ExperimentalCoroutinesApi
+class FeedSyncer(val context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams), KodeinAware {
+ override val kodein: Kodein by closestKodein(context)
+ private val currentlySyncing: ConflatedBroadcastChannel by instance(tag = CURRENTLY_SYNCING_STATE)
+
+ override suspend fun doWork(): Result {
+ val goParallel = inputData.getBoolean(PARALLEL_SYNC, false)
+ val ignoreConnectivitySettings = inputData.getBoolean(IGNORE_CONNECTIVITY_SETTINGS, false)
+
+ var success = false
+
+ if (ignoreConnectivitySettings || isOkToSyncAutomatically(context)) {
+ if (!currentlySyncing.isClosedForSend) {
+ currentlySyncing.offer(true)
+ }
+
+ val feedId = inputData.getLong(ARG_FEED_ID, ID_UNSET)
+ val feedTag = inputData.getString(ARG_FEED_TAG) ?: ""
+ val forceNetwork = inputData.getBoolean(ARG_FORCE_NETWORK, false)
+ val minFeedAgeMinutes = inputData.getInt(MIN_FEED_AGE_MINUTES, 15)
+
+ success = syncFeeds(
+ context = applicationContext,
+ feedId = feedId,
+ feedTag = feedTag,
+ forceNetwork = forceNetwork,
+ parallel = goParallel,
+ minFeedAgeMinutes = minFeedAgeMinutes
+ )
+ // Send notifications for configured feeds
+ notify(applicationContext)
+ }
+
+ if (!currentlySyncing.isClosedForSend) {
+ currentlySyncing.offer(false)
+ }
+
+ return when (success) {
+ true -> Result.success()
+ false -> Result.failure()
+ }
+ }
+}
+
+@FlowPreview
+@ExperimentalCoroutinesApi
+fun requestFeedSync(
+ kodein: Kodein,
+ feedId: Long = ID_UNSET,
+ feedTag: String = "",
+ ignoreConnectivitySettings: Boolean = false,
+ forceNetwork: Boolean = false,
+ parallell: Boolean = false
+) {
+ val workRequest = OneTimeWorkRequestBuilder()
+
+ val data = workDataOf(
+ ARG_FEED_ID to feedId,
+ ARG_FEED_TAG to feedTag,
+ PARALLEL_SYNC to parallell,
+ IGNORE_CONNECTIVITY_SETTINGS to ignoreConnectivitySettings,
+ ARG_FORCE_NETWORK to forceNetwork
+ )
+
+ workRequest.setInputData(data)
+ val workManager by kodein.instance()
+ workManager.enqueue(workRequest.build())
+}
+
+@FlowPreview
+@ExperimentalCoroutinesApi
+fun configurePeriodicSync(context: Context, forceReplace: Boolean = false) {
+ val kodein by closestKodein(context)
+ val workManager: WorkManager by kodein.instance()
+ val prefs: Prefs by kodein.instance()
+ val shouldSync = prefs.shouldSync()
+
+ if (shouldSync) {
+ val constraints = Constraints.Builder()
+ .setRequiresBatteryNotLow(true)
+ .setRequiresCharging(prefs.onlySyncWhileCharging)
+
+ if (prefs.onlySyncOnWIfi) {
+ constraints.setRequiredNetworkType(NetworkType.UNMETERED)
+ } else {
+ constraints.setRequiredNetworkType(NetworkType.CONNECTED)
+ }
+
+ var timeInterval = prefs.synchronizationFrequency
+
+ if (timeInterval in 1..12 || timeInterval == 24L) {
+ // Old value for periodic sync was in hours, convert it to minutes
+ timeInterval *= 60
+ prefs.synchronizationFrequency = timeInterval
+ }
+
+ val workRequestBuilder = PeriodicWorkRequestBuilder(
+ timeInterval, TimeUnit.MINUTES,
+ timeInterval / 2, TimeUnit.MINUTES
+ )
+
+ val syncWork = workRequestBuilder
+ .setConstraints(constraints.build())
+ .addTag("periodic_sync")
+ .build()
+
+ val existingWorkPolicy = if (forceReplace) {
+ ExistingPeriodicWorkPolicy.REPLACE
+ } else {
+ ExistingPeriodicWorkPolicy.KEEP
+ }
+
+ workManager.enqueueUniquePeriodicWork(
+ UNIQUE_PERIODIC_NAME,
+ existingWorkPolicy,
+ syncWork
+ )
+ } else {
+ workManager.cancelUniqueWork(UNIQUE_PERIODIC_NAME)
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/FeedUnreadCount.kt b/app/src/main/java/com/nononsenseapps/feeder/model/FeedUnreadCount.kt
new file mode 100644
index 0000000..4ad29ed
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/FeedUnreadCount.kt
@@ -0,0 +1,74 @@
+package com.nononsenseapps.feeder.model
+
+import androidx.room.ColumnInfo
+import androidx.room.Ignore
+import com.nononsenseapps.feeder.db.room.ID_ALL_FEEDS
+import com.nononsenseapps.feeder.db.room.ID_UNSET
+import com.nononsenseapps.feeder.util.sloppyLinkToStrictURLNoThrows
+import java.net.URL
+
+data class FeedUnreadCount @Ignore constructor(
+ var id: Long = ID_UNSET,
+ var title: String = "",
+ var url: URL = sloppyLinkToStrictURLNoThrows(""),
+ var tag: String = "",
+ @ColumnInfo(name = "custom_title") var customTitle: String = "",
+ var notify: Boolean = false,
+ @ColumnInfo(name = "image_url") var imageUrl: URL? = null,
+ @ColumnInfo(name = "unread_count") var unreadCount: Int = 0
+) {
+ constructor() : this(id = ID_UNSET)
+
+ val displayTitle: String
+ get() = (if (customTitle.isBlank()) title else customTitle)
+
+ val isTop: Boolean
+ get() = id == ID_ALL_FEEDS
+
+ val isTag: Boolean
+ get() = id < 1 && tag.isNotEmpty()
+
+ operator fun compareTo(other: FeedUnreadCount): Int {
+ return when {
+ // Top tag is always at the top (implies empty tags)
+ isTop -> -1
+ other.isTop -> 1
+ // Feeds with no tags are always last
+ isTag && !other.isTag && other.tag.isEmpty() -> -1
+ !isTag && other.isTag && tag.isEmpty() -> 1
+ !isTag && !other.isTag && tag.isNotEmpty() && other.tag.isEmpty() -> -1
+ !isTag && !other.isTag && tag.isEmpty() && other.tag.isNotEmpty() -> 1
+ // Feeds with identical tags compare by title
+ tag == other.tag -> displayTitle.compareTo(other.displayTitle, ignoreCase = true)
+ // In other cases it's just a matter of comparing tags
+ else -> tag.compareTo(other.tag, ignoreCase = true)
+ }
+ }
+
+ override fun equals(other: Any?): Boolean {
+ return when (other) {
+ null -> false
+ is FeedUnreadCount -> {
+ // val f = other as FeedWrapper?
+ if (isTag && other.isTag) {
+ // Compare tags
+ tag == other.tag
+ } else {
+ // Compare items
+ !isTag && !other.isTag && id == other.id
+ }
+ }
+ else -> false
+ }
+ }
+
+ override fun hashCode(): Int {
+ return if (isTag) {
+ // Tag
+ tag.hashCode()
+ } else {
+ // Item
+ id.hashCode()
+ }
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/FeedViewModel.kt b/app/src/main/java/com/nononsenseapps/feeder/model/FeedViewModel.kt
new file mode 100644
index 0000000..46986e1
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/FeedViewModel.kt
@@ -0,0 +1,87 @@
+package com.nononsenseapps.feeder.model
+
+import android.content.Context
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.asLiveData
+import com.nononsenseapps.feeder.base.KodeinAwareViewModel
+import com.nononsenseapps.feeder.db.room.Feed
+import com.nononsenseapps.feeder.db.room.FeedDao
+import com.nononsenseapps.feeder.db.room.FeedTitle
+import com.nononsenseapps.feeder.db.room.ID_ALL_FEEDS
+import com.nononsenseapps.feeder.db.room.ID_UNSET
+import com.nononsenseapps.feeder.util.removeDynamicShortcutToFeed
+import org.kodein.di.Kodein
+import org.kodein.di.generic.instance
+
+class FeedViewModel(kodein: Kodein) : KodeinAwareViewModel(kodein) {
+ private val dao: FeedDao by instance()
+
+ private lateinit var liveFeedsNotify: LiveData>
+
+ fun getLiveFeedsNotify(id: Long, tag: String): LiveData> {
+ if (!this::liveFeedsNotify.isInitialized) {
+ liveFeedsNotify = when {
+ id > ID_UNSET -> dao.loadLiveFeedsNotify(feedId = id)
+ id == ID_UNSET && tag.isNotEmpty() -> dao.loadLiveFeedsNotify(tag = tag)
+ else -> dao.loadLiveFeedsNotify()
+ }.asLiveData()
+ }
+ return liveFeedsNotify
+ }
+
+ private lateinit var liveFeed: LiveData
+
+ fun getLiveFeed(id: Long): LiveData {
+ if (!this::liveFeed.isInitialized) {
+ liveFeed = dao.loadLiveFeed(feedId = id).asLiveData()
+ }
+ return liveFeed
+ }
+
+ suspend fun getFeed(id: Long): Feed? {
+ return dao.loadFeed(feedId = id)
+ }
+
+ suspend fun setNotify(tag: String, notify: Boolean) {
+ dao.setNotify(tag = tag, notify = notify)
+ }
+
+ suspend fun setNotify(id: Long, notify: Boolean) {
+ dao.setNotify(id = id, notify = notify)
+ }
+
+ suspend fun setAllNotify(notify: Boolean) {
+ dao.setAllNotify(notify = notify)
+ }
+
+ suspend fun deleteFeed(id: Long) {
+ dao.deleteFeedWithId(feedId = id)
+
+ val context: Context by instance()
+ context.removeDynamicShortcutToFeed(id)
+ }
+
+ suspend fun deleteFeeds(ids: List) {
+ dao.deleteFeeds(ids)
+
+ val context: Context by instance()
+ for (id in ids) {
+ context.removeDynamicShortcutToFeed(id)
+ }
+ }
+
+ suspend fun getVisibleFeeds(id: Long, feedTag: String?): List {
+ return when {
+ id == ID_UNSET && feedTag?.isNotEmpty() == true -> {
+ dao.getFeedTitlesWithTag(feedTag = feedTag)
+ }
+ id == ID_UNSET || id == ID_ALL_FEEDS -> {
+ dao.getAllFeedTitles()
+ }
+ id > ID_UNSET -> {
+ dao.getFeedTitle(id)
+ }
+ else -> emptyList()
+ }
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/FeederService.kt b/app/src/main/java/com/nononsenseapps/feeder/model/FeederService.kt
new file mode 100644
index 0000000..42d754b
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/FeederService.kt
@@ -0,0 +1,37 @@
+package com.nononsenseapps.feeder.model
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import com.nononsenseapps.feeder.base.KodeinAwareIntentService
+import com.nononsenseapps.feeder.db.room.FeedItemDao
+import kotlinx.coroutines.runBlocking
+import org.kodein.di.generic.instance
+
+const val ACTION_MARK_AS_UNREAD = "MARK_AS_READ"
+
+class FeederService : KodeinAwareIntentService("FeederService") {
+ private val dao: FeedItemDao by instance()
+
+ override fun onHandleIntent(intent: Intent?) {
+ when (intent?.action) {
+ ACTION_MARK_AS_UNREAD -> intent.data?.let { markAsUnread(it) }
+ }
+ }
+
+ private fun markAsUnread(data: Uri) {
+ data.lastPathSegment?.toLongOrNull()?.let { id ->
+ runBlocking {
+ dao.markAsRead(id = id, unread = true)
+ }
+ }
+ }
+
+ companion object {
+ fun getIntentForId(context: Context, feedItemId: Long): Intent =
+ Intent(context, FeederService::class.java).apply {
+ action = ACTION_MARK_AS_UNREAD
+ data = Uri.parse("com.nononsenseapps.feeder/feeditem/$feedItemId")
+ }
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/FullTextParser.kt b/app/src/main/java/com/nononsenseapps/feeder/model/FullTextParser.kt
new file mode 100644
index 0000000..ffe271d
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/FullTextParser.kt
@@ -0,0 +1,141 @@
+package com.nononsenseapps.feeder.model
+
+import android.content.Context
+import android.util.Log
+import androidx.work.CoroutineWorker
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import androidx.work.workDataOf
+import com.nononsenseapps.feeder.blob.blobFullFile
+import com.nononsenseapps.feeder.blob.blobFullOutputStream
+import com.nononsenseapps.feeder.db.room.FeedItemForFetching
+import com.nononsenseapps.feeder.db.room.ID_UNSET
+import com.nononsenseapps.feeder.di.CURRENTLY_SYNCING_STATE
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.channels.ConflatedBroadcastChannel
+import kotlinx.coroutines.withContext
+import net.dankito.readability4j.Readability4J
+import okhttp3.OkHttpClient
+import org.kodein.di.Kodein
+import org.kodein.di.KodeinAware
+import org.kodein.di.android.closestKodein
+import org.kodein.di.generic.instance
+import java.io.File
+import java.net.URL
+
+const val ARG_FEED_ITEM_ID = "feed_item_id"
+const val ARG_FEED_ITEM_LINK = "feed_item_link"
+
+@FlowPreview
+@ExperimentalCoroutinesApi
+fun scheduleFullTextParse(
+ kodein: Kodein,
+ feedItem: FeedItemForFetching
+) {
+ val workRequest = OneTimeWorkRequestBuilder()
+
+ val data = workDataOf(
+ ARG_FEED_ITEM_ID to feedItem.id,
+ ARG_FEED_ITEM_LINK to feedItem.link
+ )
+
+ workRequest.setInputData(data)
+ val workManager by kodein.instance()
+ workManager.enqueue(workRequest.build())
+}
+
+@FlowPreview
+@ExperimentalCoroutinesApi
+class FullTextWorker(
+ val context: Context,
+ workerParams: WorkerParameters
+) : CoroutineWorker(context, workerParams), KodeinAware {
+ override val kodein: Kodein by closestKodein(context)
+ private val currentlySyncing: ConflatedBroadcastChannel by instance(tag = CURRENTLY_SYNCING_STATE)
+ private val okHttpClient: OkHttpClient by instance()
+
+ override suspend fun doWork(): Result {
+ val ignoreConnectivitySettings = inputData.getBoolean(IGNORE_CONNECTIVITY_SETTINGS, false)
+ var success = false
+
+ if (ignoreConnectivitySettings || isOkToSyncAutomatically(context)) {
+ val feedItemId: Long = inputData.getLong(ARG_FEED_ITEM_ID, ID_UNSET)
+ val link: String? = inputData.getString(ARG_FEED_ITEM_LINK)
+ ?: throw RuntimeException("No link provided")
+
+ if (!currentlySyncing.isClosedForSend) {
+ currentlySyncing.offer(true)
+ }
+
+ Log.i("FeederFullText", "Worker going to parse $feedItemId: $link")
+
+ success = parseFullArticleIfMissing(
+ feedItem = object : FeedItemForFetching {
+ override val id = feedItemId
+ override val link = link
+ },
+ okHttpClient = okHttpClient,
+ filesDir = context.filesDir
+ )
+
+ if (!currentlySyncing.isClosedForSend) {
+ currentlySyncing.offer(false)
+ }
+ }
+
+ return when (success) {
+ true -> Result.success()
+ false -> Result.failure()
+ }
+ }
+}
+
+suspend fun parseFullArticleIfMissing(
+ feedItem: FeedItemForFetching,
+ okHttpClient: OkHttpClient,
+ filesDir: File
+): Boolean {
+ val fullArticleFile = blobFullFile(itemId = feedItem.id, filesDir = filesDir)
+ return fullArticleFile.isFile || parseFullArticle(
+ feedItem = feedItem,
+ okHttpClient = okHttpClient,
+ filesDir = filesDir
+ ).first
+}
+
+suspend fun parseFullArticle(
+ feedItem: FeedItemForFetching,
+ okHttpClient: OkHttpClient,
+ filesDir: File
+): Pair = withContext(Dispatchers.Default) {
+ return@withContext try {
+ val url = feedItem.link ?: return@withContext false to null
+ Log.i("FeederFullText", "Fetching full page ${feedItem.link}")
+ val html: String = okHttpClient.curl(URL(url)) ?: return@withContext false to null
+
+ // TODO verify encoding is respected in reader
+ Log.i("FeederFullText", "Parsing article ${feedItem.link}")
+ val article = Readability4J(url, html).parse()
+
+ // TODO set image on item if none already
+ // naiveFindImageLink(article.content)?.let { Parser.unescapeEntities(it, true) }
+
+ Log.i("FeederFullText", "Writing article ${feedItem.link}")
+ withContext(Dispatchers.IO) {
+ blobFullOutputStream(feedItem.id, filesDir).bufferedWriter().use { writer ->
+ writer.write(article.contentWithUtf8Encoding)
+ }
+ }
+ true to null
+ } catch (e: Throwable) {
+ Log.e(
+ "FeederFullText",
+ "Failed to get fulltext for ${feedItem.link}: ${e.message}",
+ e
+ )
+ false to e
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/Networking.kt b/app/src/main/java/com/nononsenseapps/feeder/model/Networking.kt
new file mode 100644
index 0000000..86f299e
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/Networking.kt
@@ -0,0 +1,18 @@
+package com.nononsenseapps.feeder.model
+
+import com.nononsenseapps.feeder.BuildConfig
+import okhttp3.Interceptor
+import okhttp3.Response
+
+object UserAgentInterceptor : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ return chain.proceed(
+ chain.request()
+ .newBuilder()
+ .header("User-Agent", USER_AGENT_STRING)
+ .build()
+ )
+ }
+}
+
+const val USER_AGENT_STRING = "Feeder / ${BuildConfig.VERSION_NAME}(${BuildConfig.VERSION_CODE})"
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/OPMLParserToDatabase.kt b/app/src/main/java/com/nononsenseapps/feeder/model/OPMLParserToDatabase.kt
new file mode 100644
index 0000000..0740541
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/OPMLParserToDatabase.kt
@@ -0,0 +1,9 @@
+package com.nononsenseapps.feeder.model
+
+import com.nononsenseapps.feeder.db.room.Feed
+
+interface OPMLParserToDatabase {
+ suspend fun getFeed(url: String): Feed?
+
+ suspend fun saveFeed(feed: Feed)
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/PreviewItem.kt b/app/src/main/java/com/nononsenseapps/feeder/model/PreviewItem.kt
new file mode 100644
index 0000000..28c2b97
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/PreviewItem.kt
@@ -0,0 +1,68 @@
+package com.nononsenseapps.feeder.model
+
+import androidx.room.ColumnInfo
+import androidx.room.Ignore
+import com.nononsenseapps.feeder.db.room.ID_UNSET
+import com.nononsenseapps.feeder.util.sloppyLinkToStrictURLNoThrows
+import org.threeten.bp.ZonedDateTime
+import java.net.URI
+import java.net.URL
+
+const val previewColumns = "feed_items.id AS id, guid, plain_title, plain_snippet, feed_items.image_url, enclosure_link, " +
+ "author, pub_date, link, unread, feeds.tag AS tag, feeds.id AS feed_id, feeds.title AS feed_title, feeds.custom_title as feed_customtitle, feeds.url AS feed_url, feeds.open_articles_with AS feed_open_articles_with"
+
+data class PreviewItem @Ignore constructor(
+ var id: Long = ID_UNSET,
+ var guid: String = "",
+ @ColumnInfo(name = "plain_title") var plainTitle: String = "",
+ @ColumnInfo(name = "plain_snippet") var plainSnippet: String = "",
+ @ColumnInfo(name = "image_url") var imageUrl: String? = null,
+ @ColumnInfo(name = "enclosure_link") var enclosureLink: String? = null,
+ var author: String? = null,
+ @ColumnInfo(name = "pub_date") var pubDate: ZonedDateTime? = null,
+ var link: String? = null,
+ var tag: String = "",
+ var unread: Boolean = true,
+ @ColumnInfo(name = "feed_id") var feedId: Long? = null,
+ @ColumnInfo(name = "feed_title") var feedTitle: String = "",
+ @ColumnInfo(name = "feed_customtitle") var feedCustomTitle: String = "",
+ @ColumnInfo(name = "feed_url") var feedUrl: URL = sloppyLinkToStrictURLNoThrows(""),
+ @ColumnInfo(name = "feed_open_articles_with") var feedOpenArticlesWith: String = ""
+) {
+ constructor() : this(id = ID_UNSET)
+
+ val feedDisplayTitle: String
+ get() = if (feedCustomTitle.isBlank()) feedTitle else feedCustomTitle
+
+ val enclosureFilename: String?
+ get() {
+ if (enclosureLink != null) {
+ var fname: String? = null
+ try {
+ fname = URI(enclosureLink).path.split("/").last()
+ } catch (e: Exception) {
+ }
+ if (fname == null || fname.isEmpty()) {
+ return null
+ } else {
+ return fname
+ }
+ }
+ return null
+ }
+
+ val pubDateString: String?
+ get() = pubDate?.toString()
+
+ val domain: String?
+ get() {
+ val l: String? = enclosureLink ?: link
+ if (l != null) {
+ try {
+ return URL(l).host.replace("www.", "")
+ } catch (e: Throwable) {
+ }
+ }
+ return null
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/RssLocalSync.kt b/app/src/main/java/com/nononsenseapps/feeder/model/RssLocalSync.kt
new file mode 100644
index 0000000..00f02df
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/RssLocalSync.kt
@@ -0,0 +1,262 @@
+package com.nononsenseapps.feeder.model
+
+import android.content.Context
+import android.util.Log
+import com.nononsenseapps.feeder.blob.blobFile
+import com.nononsenseapps.feeder.blob.blobOutputStream
+import com.nononsenseapps.feeder.db.room.AppDatabase
+import com.nononsenseapps.feeder.db.room.FeedDao
+import com.nononsenseapps.feeder.db.room.FeedItem
+import com.nononsenseapps.feeder.db.room.ID_UNSET
+import com.nononsenseapps.feeder.db.room.upsertFeed
+import com.nononsenseapps.feeder.db.room.upsertFeedItems
+import com.nononsenseapps.feeder.util.Prefs
+import com.nononsenseapps.feeder.util.sloppyLinkToStrictURLNoThrows
+import com.nononsenseapps.jsonfeed.Feed
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.supervisorScope
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Response
+import org.kodein.di.Kodein
+import org.kodein.di.android.closestKodein
+import org.kodein.di.generic.instance
+import org.threeten.bp.Instant
+import org.threeten.bp.temporal.ChronoUnit
+import java.io.File
+import java.io.IOException
+import java.util.concurrent.Executors
+import kotlin.math.max
+import kotlin.system.measureTimeMillis
+
+val singleThreadedSync = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
+val syncMutex = Mutex()
+
+@FlowPreview
+suspend fun syncFeeds(
+ context: Context,
+ feedId: Long = ID_UNSET,
+ feedTag: String = "",
+ forceNetwork: Boolean = false,
+ parallel: Boolean = false,
+ minFeedAgeMinutes: Int = 15
+): Boolean {
+ val kodein: Kodein by closestKodein(context)
+ val prefs: Prefs by kodein.instance()
+ Log.d("CoroutineSync", "${Thread.currentThread().name}: Taking sync mutex")
+ return syncMutex.withLock {
+ withContext(singleThreadedSync) {
+ syncFeeds(
+ kodein,
+ filesDir = context.filesDir,
+ feedId = feedId,
+ feedTag = feedTag,
+ maxFeedItemCount = prefs.maximumCountPerFeed,
+ forceNetwork = forceNetwork,
+ parallel = parallel,
+ minFeedAgeMinutes = minFeedAgeMinutes
+ )
+ }
+ }
+}
+
+@FlowPreview
+internal suspend fun syncFeeds(
+ kodein: Kodein,
+ filesDir: File,
+ feedId: Long = ID_UNSET,
+ feedTag: String = "",
+ maxFeedItemCount: Int = 100,
+ forceNetwork: Boolean = false,
+ parallel: Boolean = false,
+ minFeedAgeMinutes: Int = 15
+): Boolean {
+ val db: AppDatabase by kodein.instance()
+ var result = false
+ // Let all new items share download time
+ val downloadTime = Instant.now()
+ val time = measureTimeMillis {
+ try {
+ supervisorScope {
+ val staleTime: Long = if (forceNetwork) {
+ Instant.now().toEpochMilli()
+ } else {
+ Instant.now().minus(minFeedAgeMinutes.toLong(), ChronoUnit.MINUTES)
+ .toEpochMilli()
+ }
+ val feedsToFetch = feedsToSync(db.feedDao(), feedId, feedTag, staleTime = staleTime)
+
+ Log.d("CoroutineSync", "Syncing ${feedsToFetch.size} feeds")
+
+ val coroutineContext = when (parallel) {
+ true -> Dispatchers.Default
+ false -> this.coroutineContext
+ } + CoroutineExceptionHandler { _, throwable ->
+ Log.e("CoroutineSync", "Error during sync", throwable)
+ }
+
+ feedsToFetch.forEach {
+ launch(coroutineContext) {
+ try {
+ syncFeed(
+ kodein = kodein,
+ feedSql = it,
+ filesDir = filesDir,
+ maxFeedItemCount = maxFeedItemCount,
+ forceNetwork = forceNetwork,
+ downloadTime = downloadTime
+ )
+ } catch (e: Throwable) {
+ Log.e(
+ "CoroutineSync",
+ "Failed to sync ${it.displayTitle}: ${it.url}",
+ e
+ )
+ }
+ }
+ }
+
+ result = true
+ }
+ } catch (e: Throwable) {
+ Log.e("CoroutineSync", "Outer error", e)
+ }
+ }
+ Log.d("CoroutineSync", "Completed in $time ms")
+ return result
+}
+
+@FlowPreview
+private suspend fun syncFeed(
+ kodein: Kodein,
+ feedSql: com.nononsenseapps.feeder.db.room.Feed,
+ filesDir: File,
+ maxFeedItemCount: Int,
+ forceNetwork: Boolean = false,
+ downloadTime: Instant
+) {
+ Log.d("CoroutineSync", "Fetching ${feedSql.displayTitle}")
+ val db: AppDatabase by kodein.instance()
+ val feedParser: FeedParser by kodein.instance()
+ val okHttpClient: OkHttpClient by kodein.instance()
+
+ val response: Response = okHttpClient.getResponse(feedSql.url, forceNetwork = forceNetwork)
+
+ var responseHash = 0
+
+ val feed: Feed? =
+ response.use {
+ val responseBody = it.safeBody()
+ responseBody?.let { body ->
+ responseHash = body.contentHashCode()
+ when {
+ !response.isSuccessful -> {
+ throw ResponseFailure("${response.code} when fetching ${feedSql.displayTitle}: ${feedSql.url}")
+ }
+ feedSql.responseHash == responseHash -> null // no change
+ else -> feedParser.parseFeedResponse(it, body)
+ }
+ }
+ }?.let {
+ // Double check that icon is not base64
+ when {
+ it.icon?.startsWith("data") == true -> it.copy(icon = null)
+ else -> it
+ }
+ }
+
+ // Always update the feeds last sync field
+ feedSql.lastSync = Instant.now()
+
+ if (feed == null) {
+ db.feedDao().upsertFeed(feedSql)
+ } else {
+ val itemDao = db.feedItemDao()
+
+ val feedItemSqls =
+ feed.items
+ ?.map {
+ val guid = it.id ?: "${it.title}-${it.summary}"
+ it to guid
+ }
+ ?.reversed()
+ ?.map { (item, guid) ->
+ val feedItemSql = itemDao.loadFeedItem(
+ guid = guid,
+ feedId = feedSql.id
+ ) ?: FeedItem(firstSyncedTime = downloadTime)
+
+ feedItemSql.updateFromParsedEntry(item, guid, feed)
+ feedItemSql.feedId = feedSql.id
+ feedItemSql to (item.content_html ?: item.content_text ?: "")
+ } ?: emptyList()
+
+ itemDao.upsertFeedItems(feedItemSqls) { feedItem, text ->
+ if (feedSql.fullTextByDefault) {
+ scheduleFullTextParse(
+ kodein = kodein,
+ feedItem = feedItem
+ )
+ }
+
+ withContext(Dispatchers.IO) {
+ blobOutputStream(feedItem.id, filesDir).bufferedWriter().use {
+ it.write(text)
+ }
+ }
+ }
+
+ // Update feed last so lastsync is only set after all items have been handled
+ // for the rare case that the job is cancelled prematurely
+ feedSql.responseHash = responseHash
+ feedSql.title = feed.title ?: feedSql.title
+ // Not changing feed url because I don't want to override auth or token params
+ // See https://gitlab.com/spacecowboy/Feeder/-/issues/390
+// feedSql.url = feed.feed_url?.let { sloppyLinkToStrictURLNoThrows(it) } ?: feedSql.url
+ feedSql.imageUrl = feed.icon?.let { sloppyLinkToStrictURLNoThrows(it) }
+ ?: feedSql.imageUrl
+ db.feedDao().upsertFeed(feedSql)
+
+ // Finally, prune database of old items
+ val ids = db.feedItemDao().getItemsToBeCleanedFromFeed(
+ feedId = feedSql.id,
+ keepCount = max(maxFeedItemCount, feed.items?.size ?: 0)
+ )
+
+ for (id in ids) {
+ val file = blobFile(itemId = id, filesDir = filesDir)
+ try {
+ if (file.isFile) {
+ file.delete()
+ }
+ } catch (e: IOException) {
+ Log.e("CoroutineSync", "Failed to delete $file", e)
+ }
+ }
+
+ db.feedItemDao().deleteFeedItems(ids)
+ }
+}
+
+internal suspend fun feedsToSync(feedDao: FeedDao, feedId: Long, tag: String, staleTime: Long = -1L): List {
+ return when {
+ feedId > 0 -> {
+ val feed = if (staleTime > 0) feedDao.loadFeedIfStale(feedId, staleTime = staleTime) else feedDao.loadFeed(feedId)
+ if (feed != null) {
+ listOf(feed)
+ } else {
+ emptyList()
+ }
+ }
+ tag.isNotEmpty() -> if (staleTime > 0) feedDao.loadFeedsIfStale(tag = tag, staleTime = staleTime) else feedDao.loadFeeds(tag)
+ else -> if (staleTime > 0) feedDao.loadFeedsIfStale(staleTime) else feedDao.loadFeeds()
+ }
+}
+
+class ResponseFailure(message: String?) : Exception(message)
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/RssNotificationBroadcastReceiver.kt b/app/src/main/java/com/nononsenseapps/feeder/model/RssNotificationBroadcastReceiver.kt
new file mode 100644
index 0000000..bcb4eaa
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/RssNotificationBroadcastReceiver.kt
@@ -0,0 +1,39 @@
+package com.nononsenseapps.feeder.model
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import com.nononsenseapps.feeder.db.room.FeedItemDao
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import org.kodein.di.android.closestKodein
+import org.kodein.di.generic.instance
+
+const val ACTION_MARK_AS_NOTIFIED: String = "mark_as_notified"
+
+const val EXTRA_FEEDITEM_ID_ARRAY: String = "extra_feeditem_id_array"
+
+class RssNotificationBroadcastReceiver : BroadcastReceiver() {
+ @FlowPreview
+ override fun onReceive(context: Context, intent: Intent) {
+ val ids = intent.getLongArrayExtra(EXTRA_FEEDITEM_ID_ARRAY)
+ Log.d("RssNotificationReceiver", "onReceive: ${intent.action}; ${ids?.joinToString(", ")}")
+ val kodein by closestKodein(context)
+ val dao: FeedItemDao by kodein.instance()
+ when (intent.action) {
+ ACTION_MARK_AS_NOTIFIED -> markAsNotified(dao, ids)
+ }
+ }
+}
+
+@FlowPreview
+private fun markAsNotified(feedItemDao: FeedItemDao, itemIds: LongArray?) {
+ if (itemIds != null) {
+ GlobalScope.launch(Dispatchers.Default) {
+ feedItemDao.markAsNotified(itemIds.toList())
+ }
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/RssNotifications.kt b/app/src/main/java/com/nononsenseapps/feeder/model/RssNotifications.kt
new file mode 100644
index 0000000..9f69c35
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/RssNotifications.kt
@@ -0,0 +1,286 @@
+package com.nononsenseapps.feeder.model
+
+import android.annotation.TargetApi
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Context.NOTIFICATION_SERVICE
+import android.content.Intent
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.os.Build
+import android.provider.Browser.EXTRA_CREATE_NEW_TAB
+import androidx.annotation.RequiresApi
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.navigation.NavDeepLinkBuilder
+import com.nononsenseapps.feeder.R
+import com.nononsenseapps.feeder.db.COL_LINK
+import com.nononsenseapps.feeder.db.URI_FEEDITEMS
+import com.nononsenseapps.feeder.db.room.FeedDao
+import com.nononsenseapps.feeder.db.room.FeedItemDao
+import com.nononsenseapps.feeder.db.room.FeedItemWithFeed
+import com.nononsenseapps.feeder.db.room.ID_ALL_FEEDS
+import com.nononsenseapps.feeder.ui.ARG_FEED_ID
+import com.nononsenseapps.feeder.ui.ARG_ID
+import com.nononsenseapps.feeder.ui.EXTRA_FEEDITEMS_TO_MARK_AS_NOTIFIED
+import com.nononsenseapps.feeder.ui.OpenLinkInDefaultActivity
+import com.nononsenseapps.feeder.util.bundle
+import com.nononsenseapps.feeder.util.notificationManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.withContext
+import org.kodein.di.Kodein
+import org.kodein.di.android.closestKodein
+import org.kodein.di.generic.instance
+
+const val notificationId = 73583
+const val channelId = "feederNotifications"
+
+@FlowPreview
+suspend fun notify(appContext: Context) = withContext(Dispatchers.Default) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ createNotificationChannel(appContext)
+ }
+
+ val kodein by closestKodein(appContext)
+
+ val nm: NotificationManagerCompat by kodein.instance()
+
+ val feedItems = getItemsToNotify(kodein)
+
+ val notifications: List> = if (feedItems.isEmpty()) {
+ emptyList()
+ } else {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N || feedItems.size < 4) {
+ // Cancel inbox notification if present
+ nm.cancel(notificationId)
+ // Platform automatically bundles 4 or more notifications
+ feedItems.map {
+ it.id.toInt() to singleNotification(appContext, it)
+ }
+ } else {
+ // In this case, also cancel any individual notifications
+ feedItems.forEach {
+ nm.cancel(it.id.toInt())
+ }
+ // Use an inbox style notification to bundle many notifications together
+ listOf(notificationId to inboxNotification(appContext, feedItems))
+ }
+ }
+
+ notifications.forEach { (id, notification) ->
+ nm.notify(id, notification)
+ }
+}
+
+@FlowPreview
+suspend fun cancelNotification(context: Context, feedItemId: Long) = withContext(Dispatchers.Default) {
+ val nm = context.notificationManager
+ nm.cancel(feedItemId.toInt())
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
+ notify(context)
+ }
+}
+
+/**
+ * This is an update operation if channel already exists so it's safe to call multiple times
+ */
+@TargetApi(Build.VERSION_CODES.O)
+@RequiresApi(Build.VERSION_CODES.O)
+private fun createNotificationChannel(context: Context) {
+ val name = context.getString(R.string.notification_channel_name)
+ val description = context.getString(R.string.notification_channel_description)
+
+ val notificationManager: NotificationManager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+
+ val channel = NotificationChannel(channelId, name, NotificationManager.IMPORTANCE_LOW)
+ channel.description = description
+
+ notificationManager.createNotificationChannel(channel)
+}
+
+@FlowPreview
+private fun singleNotification(context: Context, item: FeedItemWithFeed): Notification {
+ val style = NotificationCompat.BigTextStyle()
+ val title = item.plainTitle
+ val text = item.feedDisplayTitle
+
+ style.bigText(text)
+ style.setBigContentTitle(title)
+
+ val contentIntent =
+ NavDeepLinkBuilder(context)
+ .setGraph(R.navigation.nav_graph)
+ .setDestination(R.id.readerFragment)
+ .setArguments(
+ bundle {
+ putLong(ARG_ID, item.id)
+ }
+ )
+ .createPendingIntent(requestCode = item.id.toInt())
+
+ val builder = notificationBuilder(context)
+
+ builder.setContentText(text)
+ .setContentTitle(title)
+ .setContentIntent(contentIntent)
+ .setDeleteIntent(getPendingDeleteIntent(context, item))
+ .setNumber(1)
+
+ // Note that notifications must use PNG resources, because there is no compatibility for vector drawables here
+
+ item.enclosureLink?.let { enclosureLink ->
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(enclosureLink))
+ intent.putExtra(EXTRA_CREATE_NEW_TAB, true)
+ builder.addAction(
+ R.drawable.notification_play_circle_outline,
+ context.getString(R.string.open_enclosed_media),
+ PendingIntent.getActivity(
+ context,
+ item.id.toInt(),
+ getOpenInDefaultActivityIntent(context, item.id, enclosureLink),
+ PendingIntent.FLAG_UPDATE_CURRENT
+ )
+ )
+ }
+
+ item.link?.let { link ->
+ builder.addAction(
+ R.drawable.notification_open_in_browser,
+ context.getString(R.string.open_link_in_browser),
+ PendingIntent.getActivity(
+ context,
+ item.id.toInt(),
+ getOpenInDefaultActivityIntent(context, item.id, link),
+ PendingIntent.FLAG_UPDATE_CURRENT
+ )
+ )
+ }
+
+ builder.addAction(
+ R.drawable.notification_check,
+ context.getString(R.string.mark_as_read),
+ PendingIntent.getActivity(
+ context,
+ item.id.toInt(),
+ getOpenInDefaultActivityIntent(context, item.id, link = null),
+ PendingIntent.FLAG_UPDATE_CURRENT
+ )
+ )
+
+ style.setBuilder(builder)
+ return style.build()
+}
+
+@FlowPreview
+internal fun getOpenInDefaultActivityIntent(context: Context, feedItemId: Long, link: String? = null): Intent =
+ Intent(
+ Intent.ACTION_VIEW,
+ // Important to keep the URI different so PendingIntents don't collide
+ URI_FEEDITEMS.buildUpon().appendPath("$feedItemId").also {
+ if (link != null) {
+ it.appendQueryParameter(COL_LINK, link)
+ }
+ }.build(),
+ context,
+ OpenLinkInDefaultActivity::class.java
+ )
+
+/**
+ * Use this on platforms older than 24 to bundle notifications together
+ */
+private fun inboxNotification(context: Context, feedItems: List): Notification {
+ val style = NotificationCompat.InboxStyle()
+ val title = context.getString(R.string.updated_feeds)
+ val text = feedItems.map { it.feedDisplayTitle }.toSet().joinToString(separator = ", ")
+
+ style.setBigContentTitle(title)
+ feedItems.forEach {
+ style.addLine("${it.feedDisplayTitle} \u2014 ${it.plainTitle}")
+ }
+
+ val contentIntent = NavDeepLinkBuilder(context)
+ .setGraph(R.navigation.nav_graph)
+ .setDestination(R.id.feedFragment)
+ .setArguments(
+ bundle {
+ putLongArray(EXTRA_FEEDITEMS_TO_MARK_AS_NOTIFIED, LongArray(feedItems.size) { i -> feedItems[i].id })
+ // We can be a little bit smart - if all items are from the same feed then go to that feed
+ // Otherwise we should go to All feeds
+ val feedIds = feedItems.map { it.feedId }.toSet()
+ if (feedIds.toSet().size == 1) {
+ feedIds.first()?.let {
+ putLong(ARG_FEED_ID, it)
+ }
+ } else {
+ putLong(ARG_FEED_ID, ID_ALL_FEEDS)
+ }
+ }
+ )
+ .createPendingIntent(requestCode = notificationId)
+
+ val builder = notificationBuilder(context)
+
+ builder.setContentText(text)
+ .setContentTitle(title)
+ .setContentIntent(contentIntent)
+ .setDeleteIntent(getDeleteIntent(context, feedItems))
+ .setNumber(feedItems.size)
+
+ style.setBuilder(builder)
+ return style.build()
+}
+
+private fun getDeleteIntent(context: Context, feedItems: List): PendingIntent {
+ val intent = Intent(context, RssNotificationBroadcastReceiver::class.java)
+ intent.action = ACTION_MARK_AS_NOTIFIED
+
+ val ids = LongArray(feedItems.size) { i -> feedItems[i].id }
+ intent.putExtra(EXTRA_FEEDITEM_ID_ARRAY, ids)
+
+ return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
+}
+
+internal fun getDeleteIntent(context: Context, feedItem: FeedItemWithFeed): Intent {
+ val intent = Intent(context, RssNotificationBroadcastReceiver::class.java)
+ intent.action = ACTION_MARK_AS_NOTIFIED
+ intent.data = Uri.withAppendedPath(URI_FEEDITEMS, "${feedItem.id}")
+ val ids: LongArray = longArrayOf(feedItem.id)
+ intent.putExtra(EXTRA_FEEDITEM_ID_ARRAY, ids)
+
+ return intent
+}
+
+private fun getPendingDeleteIntent(context: Context, feedItem: FeedItemWithFeed): PendingIntent =
+ PendingIntent.getBroadcast(context, 0, getDeleteIntent(context, feedItem), PendingIntent.FLAG_UPDATE_CURRENT)
+
+private fun notificationBuilder(context: Context): NotificationCompat.Builder {
+ val bm = BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)
+
+ return NotificationCompat.Builder(context, channelId)
+ .setSmallIcon(R.drawable.ic_stat_f)
+ .setLargeIcon(bm)
+ .setAutoCancel(true)
+ .setCategory(NotificationCompat.CATEGORY_SOCIAL)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+}
+
+@FlowPreview
+private suspend fun getItemsToNotify(kodein: Kodein): List {
+ val feedDao: FeedDao by kodein.instance()
+ val feedItemDao: FeedItemDao by kodein.instance()
+
+ val feeds = feedDao.loadFeedIdsToNotify()
+
+ return when (feeds.isEmpty()) {
+ true -> emptyList()
+ false -> feedItemDao.loadItemsToNotify(feeds)
+ }
+}
+
+fun NavDeepLinkBuilder.createPendingIntent(requestCode: Int): PendingIntent? =
+ this.createTaskStackBuilder().getPendingIntent(requestCode, PendingIntent.FLAG_UPDATE_CURRENT)
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/SettingsViewModel.kt b/app/src/main/java/com/nononsenseapps/feeder/model/SettingsViewModel.kt
new file mode 100644
index 0000000..7b6afba
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/SettingsViewModel.kt
@@ -0,0 +1,83 @@
+package com.nononsenseapps.feeder.model
+
+import android.app.Application
+import android.content.Context
+import android.content.SharedPreferences
+import android.os.Build
+import androidx.annotation.ColorInt
+import androidx.annotation.ColorRes
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.asLiveData
+import com.nononsenseapps.feeder.R
+import com.nononsenseapps.feeder.base.KodeinAwareViewModel
+import com.nononsenseapps.feeder.util.CurrentTheme
+import com.nononsenseapps.feeder.util.PREF_THEME
+import com.nononsenseapps.feeder.util.Prefs
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.channels.ConflatedBroadcastChannel
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.conflate
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.map
+import org.kodein.di.Kodein
+import org.kodein.di.generic.instance
+
+@FlowPreview
+@ExperimentalCoroutinesApi
+class SettingsViewModel(kodein: Kodein) : KodeinAwareViewModel(kodein), SharedPreferences.OnSharedPreferenceChangeListener {
+ private val app: Application by instance()
+ private val prefs: Prefs by instance()
+ private val sharedPreferences: SharedPreferences by instance()
+
+ private val keyChannel = ConflatedBroadcastChannel()
+ private val keyFlow = keyChannel.asFlow()
+
+ val liveThemePreferenceNoInitial: LiveData =
+ keyFlow.filter { it == PREF_THEME }
+ .map { prefs.currentTheme }
+ .conflate()
+ .asLiveData()
+
+ val liveIsNightMode: MutableLiveData by lazy { MutableLiveData(prefs.isNightMode) }
+
+ val backgroundColor: Int
+ get() =
+ when (prefs.isNightMode) {
+ true -> app.getColorCompat(R.color.night_background)
+ false -> app.getColorCompat(R.color.day_background)
+ }
+
+ val accentColor: Int
+ get() =
+ when (prefs.isNightMode) {
+ true -> app.getColorCompat(R.color.accentNight)
+ false -> app.getColorCompat(R.color.accentDay)
+ }
+
+ init {
+ sharedPreferences.registerOnSharedPreferenceChangeListener(this)
+ }
+
+ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
+ if (key != null && !keyChannel.isClosedForSend) {
+ keyChannel.offer(key)
+ }
+ }
+
+ override fun onCleared() {
+ keyChannel.close()
+ super.onCleared()
+ }
+}
+
+@ColorInt
+fun Context.getColorCompat(@ColorRes color: Int): Int {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ getColor(color)
+ } else {
+ @Suppress("DEPRECATION")
+ resources.getColor(color)
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/TextToSpeechViewModel.kt b/app/src/main/java/com/nononsenseapps/feeder/model/TextToSpeechViewModel.kt
new file mode 100644
index 0000000..c163909
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/TextToSpeechViewModel.kt
@@ -0,0 +1,128 @@
+package com.nononsenseapps.feeder.model
+
+import android.app.Application
+import android.content.Context
+import android.os.Build
+import android.speech.tts.TextToSpeech
+import android.speech.tts.TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID
+import android.speech.tts.UtteranceProgressListener
+import android.util.Log
+import android.widget.Toast
+import com.nononsenseapps.feeder.R
+import com.nononsenseapps.feeder.base.KodeinAwareViewModel
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import org.kodein.di.Kodein
+import java.util.Locale
+
+class TextToSpeechViewModel(kodein: Kodein) : KodeinAwareViewModel(kodein), TextToSpeech.OnInitListener {
+
+ private val textToSpeech = TextToSpeech(getApplication().applicationContext, this)
+ private val speechListener: UtteranceProgressListener = object : UtteranceProgressListener() {
+ override fun onDone(utteranceId: String) {
+ textToSpeechQueue.remove(utteranceId)
+ }
+ override fun onStart(utteranceId: String) {
+ }
+ override fun onError(utteranceId: String) {
+ textToSpeechQueue.remove(utteranceId)
+ }
+ }
+ private val textToSpeechQueue = mutableMapOf()
+ private var textToSpeechId: Int = 0
+ private var initialized: Boolean = false
+ private var startJob: Job? = null
+
+ fun textToSpeechAddText(fullText: String) {
+ val textArray = fullText.split("\n", ". ")
+ for (text in textArray) {
+ if (text.isBlank()) {
+ continue
+ }
+ textToSpeechQueue[textToSpeechId.toString()] = text
+ textToSpeechId++
+ }
+ }
+
+ fun textToSpeechStart(coroutineScope: CoroutineScope) {
+ startJob?.cancel()
+ startJob = coroutineScope.launch {
+ while (!initialized) {
+ delay(100)
+ }
+ // Can only set this once engine has been initialized
+ textToSpeech.setOnUtteranceProgressListener(speechListener)
+ for ((utteranceId, text) in textToSpeechQueue) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ textToSpeech.speak(text, TextToSpeech.QUEUE_ADD, null, utteranceId)
+ } else {
+ val params = HashMap()
+ params[KEY_PARAM_UTTERANCE_ID] = utteranceId
+ @Suppress("DEPRECATION")
+ textToSpeech.speak(text, TextToSpeech.QUEUE_ADD, params)
+ }
+ }
+ }
+ }
+
+ fun textToSpeechPause() {
+ textToSpeech.stop()
+ }
+
+ fun textToSpeechClear() {
+ textToSpeech.stop()
+ textToSpeechQueue.clear()
+ }
+
+ override fun onInit(status: Int) {
+ val context = getApplication().applicationContext
+
+ if (status == TextToSpeech.SUCCESS) {
+ val selectedLocale = context.getLocales()
+ .firstOrNull { locale ->
+ when (textToSpeech.setLanguage(locale)) {
+ TextToSpeech.LANG_MISSING_DATA, TextToSpeech.LANG_NOT_SUPPORTED -> {
+ Log.e(LOG_TAG, "${locale.displayLanguage} is not supported!")
+ false
+ }
+ else -> {
+ true
+ }
+ }
+ }
+ initialized = true
+
+ if (selectedLocale == null) {
+ Log.e(LOG_TAG, "None of the user's locales was supported by text to speech")
+ }
+ } else {
+ Log.e(LOG_TAG, "Failed to load TextToSpeech object.")
+ Toast.makeText(context, R.string.failed_to_load_text_to_speech, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ textToSpeech.shutdown()
+ }
+
+ companion object {
+ private const val LOG_TAG = "FeederTextToSpeech"
+ }
+}
+
+fun Context.getLocales(): Sequence =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ sequence {
+ val locales = resources.configuration.locales
+
+ for (i in 0..locales.size()) {
+ yield(locales[i])
+ }
+ }
+ } else {
+ @Suppress("DEPRECATION")
+ sequenceOf(resources.configuration.locale)
+ }
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/opml/OPMLToRoom.kt b/app/src/main/java/com/nononsenseapps/feeder/model/opml/OPMLToRoom.kt
new file mode 100644
index 0000000..d259a3c
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/opml/OPMLToRoom.kt
@@ -0,0 +1,27 @@
+package com.nononsenseapps.feeder.model.opml
+
+import com.nononsenseapps.feeder.db.room.AppDatabase
+import com.nononsenseapps.feeder.db.room.Feed
+import com.nononsenseapps.feeder.model.OPMLParserToDatabase
+import com.nononsenseapps.feeder.util.sloppyLinkToStrictURLNoThrows
+import kotlinx.coroutines.FlowPreview
+
+@FlowPreview
+class OPMLToRoom(db: AppDatabase) : OPMLParserToDatabase {
+
+ val dao = db.feedDao()
+
+ override suspend fun getFeed(url: String): Feed? =
+ dao.loadFeedWithUrl(sloppyLinkToStrictURLNoThrows(url))
+
+ override suspend fun saveFeed(feed: Feed) {
+ val existing = dao.loadFeedWithUrl(feed.url)
+
+ // Don't want to remove existing feed on OPML imports
+ if (existing != null) {
+ dao.updateFeed(feed.copy(id = existing.id))
+ } else {
+ dao.insertFeed(feed)
+ }
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/opml/OpmlActions.kt b/app/src/main/java/com/nononsenseapps/feeder/model/opml/OpmlActions.kt
new file mode 100644
index 0000000..05399f2
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/opml/OpmlActions.kt
@@ -0,0 +1,66 @@
+package com.nononsenseapps.feeder.model.opml
+
+import android.content.ContentResolver
+import android.net.Uri
+import android.util.Log
+import com.nononsenseapps.feeder.db.room.AppDatabase
+import com.nononsenseapps.feeder.db.room.FeedDao
+import com.nononsenseapps.feeder.model.requestFeedSync
+import com.nononsenseapps.feeder.util.ToastMaker
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.withContext
+import org.kodein.di.Kodein
+import org.kodein.di.direct
+import org.kodein.di.generic.instance
+import kotlin.system.measureTimeMillis
+
+/**
+ * Exports OPML on a background thread
+ */
+suspend fun exportOpml(kodein: Kodein, uri: Uri) = withContext(Dispatchers.IO) {
+ try {
+ val time = measureTimeMillis {
+ val contentResolver: ContentResolver by kodein.instance()
+ val feedDao: FeedDao by kodein.instance()
+ contentResolver.openOutputStream(uri)?.let {
+ writeOutputStream(
+ it,
+ feedDao.loadTags()
+ ) { tag ->
+ feedDao.loadFeeds(tag = tag)
+ }
+ }
+ }
+ Log.d("OPML", "Exported OPML in $time ms on ${Thread.currentThread().name}")
+ } catch (e: Throwable) {
+ Log.e("OMPL", "Failed to export OMPL", e)
+ kodein.direct.instance().makeToast("Failed to export OMPL")
+ }
+}
+
+/**
+ * Imports OPML on a background thread
+ */
+@FlowPreview
+@ExperimentalCoroutinesApi
+suspend fun importOpml(kodein: Kodein, uri: Uri) = withContext(Dispatchers.IO) {
+ val db: AppDatabase by kodein.instance()
+ try {
+ val time = measureTimeMillis {
+ val parser = OpmlParser(OPMLToRoom(db))
+ val contentResolver: ContentResolver by kodein.instance()
+ contentResolver.openInputStream(uri).use {
+ it?.let { stream ->
+ parser.parseInputStream(stream)
+ }
+ }
+ requestFeedSync(kodein = kodein, ignoreConnectivitySettings = false, parallell = true)
+ }
+ Log.d("OPML", "Imported OPML in $time ms on ${Thread.currentThread().name}")
+ } catch (e: Throwable) {
+ Log.e("OMPL", "Failed to import OMPL", e)
+ kodein.direct.instance().makeToast("Failed to import OMPL")
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/opml/OpmlParser.kt b/app/src/main/java/com/nononsenseapps/feeder/model/opml/OpmlParser.kt
new file mode 100644
index 0000000..622c25a
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/opml/OpmlParser.kt
@@ -0,0 +1,125 @@
+package com.nononsenseapps.feeder.model.opml
+
+import com.nononsenseapps.feeder.db.room.Feed
+import com.nononsenseapps.feeder.model.OPMLParserToDatabase
+import com.nononsenseapps.feeder.util.sloppyLinkToStrictURL
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.ccil.cowan.tagsoup.Parser
+import org.xml.sax.Attributes
+import org.xml.sax.ContentHandler
+import org.xml.sax.InputSource
+import org.xml.sax.Locator
+import org.xml.sax.SAXException
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import java.util.Stack
+
+class OpmlParser(val opmlToDb: OPMLParserToDatabase) : ContentHandler {
+
+ val parser: Parser = Parser()
+ val tagStack: Stack = Stack()
+ var isFeedTag = false
+ var ignoring = 0
+ var feeds: MutableList = mutableListOf()
+
+ init {
+ parser.contentHandler = this
+ }
+
+ @Throws(IOException::class, SAXException::class)
+ suspend fun parseFile(path: String) {
+ // Open file
+ val file = File(path)
+
+ file.inputStream().use {
+ parseInputStream(it)
+ }
+ }
+
+ @Throws(IOException::class, SAXException::class)
+ suspend fun parseInputStream(inputStream: InputStream) = withContext(Dispatchers.IO) {
+ feeds = mutableListOf()
+ tagStack.clear()
+ isFeedTag = false
+ ignoring = 0
+
+ parser.parse(InputSource(inputStream))
+
+ for (feed in feeds) {
+ opmlToDb.saveFeed(feed)
+ }
+ }
+
+ override fun endElement(uri: String?, localName: String?, qName: String?) {
+ if ("outline" == localName) {
+ when {
+ ignoring > 0 -> ignoring--
+ isFeedTag -> isFeedTag = false
+ else -> tagStack.pop()
+ }
+ }
+ }
+
+ override fun processingInstruction(target: String?, data: String?) {
+ }
+
+ override fun startPrefixMapping(prefix: String?, uri: String?) {
+ }
+
+ override fun ignorableWhitespace(ch: CharArray?, start: Int, length: Int) {
+ }
+
+ override fun characters(ch: CharArray?, start: Int, length: Int) {
+ }
+
+ override fun endDocument() {
+ }
+
+ override fun startElement(uri: String?, localName: String?, qName: String?, atts: Attributes?) {
+ if ("outline" == localName) {
+ when {
+ // Nesting not allowed
+ ignoring > 0 || isFeedTag -> ignoring++
+ outlineIsFeed(atts) -> {
+ isFeedTag = true
+ val feedTitle = unescape(
+ atts?.getValue("title") ?: atts?.getValue("text")
+ ?: ""
+ )
+ val feed = Feed(
+ title = feedTitle,
+ customTitle = feedTitle,
+ tag = if (tagStack.isNotEmpty()) tagStack.peek() else "",
+ url = sloppyLinkToStrictURL(atts?.getValue("xmlurl") ?: "")
+ )
+
+ feeds.add(feed)
+ }
+ else -> tagStack.push(
+ unescape(
+ atts?.getValue("title")
+ ?: atts?.getValue("text")
+ ?: ""
+ )
+ )
+ }
+ }
+ }
+
+ private fun outlineIsFeed(atts: Attributes?): Boolean =
+ atts?.getValue("xmlurl") != null
+
+ override fun skippedEntity(name: String?) {
+ }
+
+ override fun setDocumentLocator(locator: Locator?) {
+ }
+
+ override fun endPrefixMapping(prefix: String?) {
+ }
+
+ override fun startDocument() {
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/opml/OpmlWriter.kt b/app/src/main/java/com/nononsenseapps/feeder/model/opml/OpmlWriter.kt
new file mode 100644
index 0000000..00f42ac
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/model/opml/OpmlWriter.kt
@@ -0,0 +1,209 @@
+package com.nononsenseapps.feeder.model.opml
+
+import android.util.Log
+import com.nononsenseapps.feeder.db.room.Feed
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.OutputStream
+
+suspend fun writeFile(
+ path: String,
+ tags: Iterable,
+ feedsWithTag: suspend (String) -> Iterable
+) {
+ writeOutputStream(FileOutputStream(path), tags, feedsWithTag)
+}
+
+suspend fun writeOutputStream(
+ os: OutputStream,
+ tags: Iterable,
+ feedsWithTag: suspend (String) -> Iterable
+) {
+ try {
+ os.bufferedWriter().use {
+ it.write("\n")
+ it.write(
+ opml {
+ head {
+ title { +"Feeder" }
+ }
+ body {
+ tags.forEach {
+ if (it.isEmpty()) {
+ feedsWithTag(it).forEach {
+ outline(
+ title = escape(it.displayTitle),
+ type = "rss",
+ xmlUrl = escape(it.url.toString())
+ ) {}
+ }
+ } else {
+ outline(title = escape(it)) {
+ feedsWithTag(it).forEach {
+ outline(
+ title = escape(it.displayTitle),
+ type = "rss",
+ xmlUrl = escape(it.url.toString())
+ ) {}
+ }
+ }
+ }
+ }
+ }
+ }.toString()
+ )
+ }
+ } catch (e: IOException) {
+ Log.e("OmplWriter", "Failed to write stream", e)
+ }
+}
+
+/**
+
+ * @param s string to escape
+ * *
+ * @return String with xml stuff escaped
+ */
+internal fun escape(s: String): String {
+ return s.replace("&", "&")
+ .replace("\"", """)
+ .replace("'", "'")
+ .replace("<", "<")
+ .replace(">", ">")
+}
+
+/**
+
+ * @param s string to unescape
+ * *
+ * @return String with xml stuff unescaped
+ */
+internal fun unescape(s: String): String {
+ return s.replace(""", "\"")
+ .replace("'", "'")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("&", "&")
+}
+
+// OPML DSL
+
+suspend fun opml(init: suspend Opml.() -> Unit): Opml {
+ val opml = Opml()
+ opml.init()
+ return opml
+}
+
+interface Element {
+ fun render(builder: StringBuilder, indent: String)
+}
+
+class TextElement(val text: String) : Element {
+ override fun render(builder: StringBuilder, indent: String) {
+ builder.append("$indent$text\n")
+ }
+}
+
+abstract class Tag(val name: String) : Element {
+ val children = arrayListOf()
+ val attributes = linkedMapOf()
+
+ protected suspend fun initTag(tag: T, init: suspend T.() -> Unit): T {
+ tag.init()
+ children.add(tag)
+ return tag
+ }
+
+ override fun render(builder: StringBuilder, indent: String) {
+ builder.append("$indent<$name${renderAttributes()}")
+ if (children.isEmpty()) {
+ builder.append("/>\n")
+ } else {
+ builder.append(">\n")
+ for (c in children) {
+ c.render(builder, indent + " ")
+ }
+ builder.append("$indent$name>\n")
+ }
+ }
+
+ private fun renderAttributes(): String {
+ val builder = StringBuilder()
+ for (a in attributes.keys) {
+ builder.append(" $a=\"${attributes[a]}\"")
+ }
+ return builder.toString()
+ }
+
+ override fun toString(): String {
+ val builder = StringBuilder()
+ render(builder, "")
+ return builder.toString()
+ }
+}
+
+abstract class TagWithText(name: String) : Tag(name) {
+ operator fun String.unaryPlus() {
+ children.add(TextElement(this))
+ }
+}
+
+class Opml : TagWithText("opml") {
+ init {
+ attributes["version"] = "1.1"
+ }
+
+ suspend fun head(init: suspend Head.() -> Unit) = initTag(Head(), init)
+ suspend fun body(init: suspend Body.() -> Unit) = initTag(Body(), init)
+}
+
+class Head : TagWithText("head") {
+ suspend fun title(init: suspend Title.() -> Unit) = initTag(Title(), init)
+}
+
+class Title : TagWithText("title")
+
+abstract class BodyTag(name: String) : TagWithText(name) {
+ suspend fun outline(
+ title: String,
+ text: String = title,
+ type: String? = null,
+ xmlUrl: String? = null,
+ init: suspend Outline.() -> Unit
+ ) {
+ val o = initTag(Outline(), init)
+ o.title = title
+ o.text = text
+ if (type != null) {
+ o.type = type
+ }
+ if (xmlUrl != null) {
+ o.xmlUrl = xmlUrl
+ }
+ }
+}
+
+class Body : BodyTag("body")
+
+class Outline : BodyTag("outline") {
+ var title: String
+ get() = attributes["title"]!!
+ set(value) {
+ attributes["title"] = value
+ }
+ var text: String
+ get() = attributes["text"]!!
+ set(value) {
+ attributes["text"] = value
+ }
+ var type: String
+ get() = attributes["type"]!!
+ set(value) {
+ attributes["type"] = value
+ }
+ var xmlUrl: String
+ get() = attributes["xmlUrl"]!!
+ set(value) {
+ attributes["xmlUrl"] = value
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/CustomTabsWarmer.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/CustomTabsWarmer.kt
new file mode 100644
index 0000000..b2986db
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/ui/CustomTabsWarmer.kt
@@ -0,0 +1,53 @@
+package com.nononsenseapps.feeder.ui
+
+import android.content.ComponentName
+import android.net.Uri
+import android.os.Bundle
+import androidx.browser.customtabs.CustomTabsCallback
+import androidx.browser.customtabs.CustomTabsClient
+import androidx.browser.customtabs.CustomTabsServiceConnection
+import androidx.browser.customtabs.CustomTabsSession
+import kotlinx.coroutines.delay
+import org.kodein.di.Kodein
+import org.kodein.di.KodeinAware
+import org.kodein.di.direct
+import org.kodein.di.generic.instance
+
+class CustomTabsWarmer(override val kodein: Kodein) : KodeinAware {
+ private var customClient: CustomTabsClient? = null
+ private var customSession: CustomTabsSession? = null
+
+ init {
+ // This leaks - make it more static and reusable
+ val conn = object : CustomTabsServiceConnection() {
+ override fun onCustomTabsServiceConnected(name: ComponentName?, client: CustomTabsClient?) {
+ customClient = client
+ client?.warmup(0)
+ customSession = client?.newSession(object : CustomTabsCallback() {})
+ }
+
+ override fun onServiceDisconnected(name: ComponentName) {
+ }
+ }
+
+ CustomTabsClient.bindCustomTabsService(
+ kodein.direct.instance(),
+ "com.android.chrome",
+ conn
+ )
+ }
+
+ suspend fun preLoad(linkProvider: () -> Uri?) {
+ var time = 50L
+ while (customSession == null || linkProvider() == null) {
+ delay(time)
+ time *= 2
+ if (time > 1000L) {
+ // Give up
+ return
+ }
+ }
+
+ customSession?.mayLaunchUrl(linkProvider(), Bundle.EMPTY, emptyList())
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/DeleteFeedsDialogFragment.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/DeleteFeedsDialogFragment.kt
new file mode 100644
index 0000000..ac13420
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/ui/DeleteFeedsDialogFragment.kt
@@ -0,0 +1,64 @@
+package com.nononsenseapps.feeder.ui
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import androidx.core.os.bundleOf
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.nononsenseapps.feeder.R
+import com.nononsenseapps.feeder.base.KodeinAwareDialogFragment
+import com.nononsenseapps.feeder.db.room.ID_ALL_FEEDS
+import com.nononsenseapps.feeder.model.FeedViewModel
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import org.kodein.di.generic.instance
+
+const val ARG_FEED_IDS = "feed_ids"
+const val ARG_FEED_TITLES = "feed_titles"
+
+class DeleteFeedsDialogFragment : KodeinAwareDialogFragment() {
+ private val feedViewModel: FeedViewModel by instance()
+
+ private lateinit var feedIds: LongArray
+ private lateinit var feedTitles: Array
+
+ private val checkedItems by lazy {
+ BooleanArray(feedIds.size)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ feedIds = arguments?.getLongArray(ARG_FEED_IDS) ?: longArrayOf()
+ feedTitles = arguments?.getStringArray(ARG_FEED_TITLES) ?: arrayOf()
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val builder = MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.delete_feed)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ val idsToDelete = feedIds.zip(checkedItems.asIterable())
+ .filter { (_, checked) ->
+ checked
+ }.map { (id, _) ->
+ id
+ }
+
+ GlobalScope.launch {
+ feedViewModel.deleteFeeds(idsToDelete)
+ }
+
+ findNavController().navigate(
+ R.id.action_deleteFeedsDialogFragment_to_feedFragment,
+ bundleOf(ARG_FEED_ID to ID_ALL_FEEDS)
+ )
+ }
+ .setNegativeButton(android.R.string.cancel) { _, _ -> }
+ .setMultiChoiceItems(feedTitles, checkedItems) { _: DialogInterface, position: Int, checked: Boolean ->
+ checkedItems[position] = checked
+ }
+
+ return builder.create()
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/DividerColor.java b/app/src/main/java/com/nononsenseapps/feeder/ui/DividerColor.java
new file mode 100644
index 0000000..07b7c66
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/ui/DividerColor.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.nononsenseapps.feeder.ui;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import android.view.View;
+
+import com.nononsenseapps.feeder.R;
+
+public class DividerColor extends RecyclerView.ItemDecoration {
+
+ public static final int HORIZONTAL_LIST = LinearLayoutManager.HORIZONTAL;
+ public static final int VERTICAL_LIST = LinearLayoutManager.VERTICAL;
+
+ private final Drawable mDivider;
+ private final int skipHeaders;
+
+ private int mOrientation;
+ // in pixels
+ private static final int size = 1;
+ private final int skipFooters;
+
+ public DividerColor(Context context, int orientation) {
+ this(context, orientation, 0, 0);
+ }
+
+ public DividerColor(Context context, int orientation, int skipHeaders, int skipFooters) {
+ mDivider = context.getResources().getDrawable(R.drawable.simple_divider);
+ setOrientation(orientation);
+ this.skipHeaders = skipHeaders;
+ this.skipFooters = skipFooters;
+ }
+
+ public void setOrientation(int orientation) {
+ if (orientation != HORIZONTAL_LIST && orientation != VERTICAL_LIST) {
+ throw new IllegalArgumentException("invalid orientation");
+ }
+ mOrientation = orientation;
+ }
+
+ @Override
+ public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
+ if (mOrientation == VERTICAL_LIST) {
+ drawVertical(c, parent);
+ } else {
+ drawHorizontal(c, parent);
+ }
+ }
+
+ public void drawVertical(Canvas c, RecyclerView parent) {
+ final int left = parent.getPaddingLeft();
+ final int right = parent.getWidth() - parent.getPaddingRight();
+
+ final int childCount = parent.getChildCount();
+ for (int i = skipHeaders; i < childCount - skipFooters; i++) {
+ final View child = parent.getChildAt(i);
+ final RecyclerView.LayoutParams params =
+ (RecyclerView.LayoutParams) child.getLayoutParams();
+ final int top = child.getBottom() + params.bottomMargin;
+ final int bottom = top + size;
+ mDivider.setBounds(left, top, right, bottom);
+ mDivider.draw(c);
+ }
+ }
+
+ public void drawHorizontal(Canvas c, RecyclerView parent) {
+ final int childCount = parent.getChildCount();
+ for (int i = skipHeaders; i < childCount; i++) {
+ final View child = parent.getChildAt(i);
+ final RecyclerView.LayoutParams params =
+ (RecyclerView.LayoutParams) child.getLayoutParams();
+ final int left = child.getRight() + params.rightMargin;
+ final int right = left + size;
+ final int top = child.getTop();
+ final int bottom = child.getBottom();
+ mDivider.setBounds(left, top, right, bottom);
+ mDivider.draw(c);
+ }
+ }
+
+ @Override
+ public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
+ RecyclerView.State state) {
+ if (mOrientation == VERTICAL_LIST) {
+ outRect.set(0, 0, 0, size);
+ } else {
+ outRect.set(0, 0, size, 0);
+ }
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/DividerItemDecoration.java b/app/src/main/java/com/nononsenseapps/feeder/ui/DividerItemDecoration.java
new file mode 100644
index 0000000..0f284f4
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/ui/DividerItemDecoration.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.nononsenseapps.feeder.ui;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import android.view.View;
+
+public class DividerItemDecoration extends RecyclerView.ItemDecoration {
+
+ public static final int HORIZONTAL_LIST = LinearLayoutManager.HORIZONTAL;
+ public static final int VERTICAL_LIST = LinearLayoutManager.VERTICAL;
+ private static final int[] ATTRS = new int[]{android.R.attr.listDivider};
+ private Drawable mDivider;
+
+ private int mOrientation;
+
+ public DividerItemDecoration(Context context, int orientation) {
+ final TypedArray a = context.obtainStyledAttributes(ATTRS);
+ mDivider = a.getDrawable(0);
+ a.recycle();
+ setOrientation(orientation);
+ }
+
+ public void setOrientation(int orientation) {
+ if (orientation != HORIZONTAL_LIST && orientation != VERTICAL_LIST) {
+ throw new IllegalArgumentException("invalid orientation");
+ }
+ mOrientation = orientation;
+ }
+
+ @Override
+ public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
+ if (mOrientation == VERTICAL_LIST) {
+ drawVertical(c, parent);
+ } else {
+ drawHorizontal(c, parent);
+ }
+ }
+
+ public void drawVertical(Canvas c, RecyclerView parent) {
+ final int left = parent.getPaddingLeft();
+ final int right = parent.getWidth() - parent.getPaddingRight();
+
+ final int childCount = parent.getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ final View child = parent.getChildAt(i);
+ final RecyclerView.LayoutParams params =
+ (RecyclerView.LayoutParams) child.getLayoutParams();
+ final int top = child.getBottom() + params.bottomMargin;
+ final int bottom = top + mDivider.getIntrinsicHeight();
+ mDivider.setBounds(left, top, right, bottom);
+ mDivider.draw(c);
+ }
+ }
+
+ public void drawHorizontal(Canvas c, RecyclerView parent) {
+ final int top = parent.getPaddingTop();
+ final int bottom = parent.getHeight() - parent.getPaddingBottom();
+
+ final int childCount = parent.getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ final View child = parent.getChildAt(i);
+ final RecyclerView.LayoutParams params =
+ (RecyclerView.LayoutParams) child.getLayoutParams();
+ final int left = child.getRight() + params.rightMargin;
+ final int right = left + mDivider.getIntrinsicHeight();
+ mDivider.setBounds(left, top, right, bottom);
+ mDivider.draw(c);
+ }
+ }
+
+ @Override
+ public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
+ RecyclerView.State state) {
+ if (mOrientation == VERTICAL_LIST) {
+ outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
+ } else {
+ outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
+ }
+ }
+}
diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/EditFeedActivity.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/EditFeedActivity.kt
new file mode 100644
index 0000000..4b65734
--- /dev/null
+++ b/app/src/main/java/com/nononsenseapps/feeder/ui/EditFeedActivity.kt
@@ -0,0 +1,515 @@
+package com.nononsenseapps.feeder.ui
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.util.Log
+import android.util.TypedValue
+import android.view.KeyEvent
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputMethodManager
+import android.widget.ArrayAdapter
+import android.widget.AutoCompleteTextView
+import android.widget.Button
+import android.widget.CheckBox
+import android.widget.EditText
+import android.widget.TextView
+import android.widget.Toast
+import android.widget.Spinner
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.RecyclerView
+import com.nononsenseapps.feeder.R
+import com.nononsenseapps.feeder.base.KodeinAwareActivity
+import com.nononsenseapps.feeder.db.URI_FEEDS
+import com.nononsenseapps.feeder.db.room.FeedDao
+import com.nononsenseapps.feeder.db.room.ID_UNSET
+import com.nononsenseapps.feeder.db.room.OPEN_ARTICLE_WITH_APPLICATION_DEFAULT
+import com.nononsenseapps.feeder.db.room.upsertFeed
+import com.nononsenseapps.feeder.model.FeedParser
+import com.nononsenseapps.feeder.model.requestFeedSync
+import com.nononsenseapps.feeder.util.PREF_VAL_OPEN_WITH_BROWSER
+import com.nononsenseapps.feeder.util.PREF_VAL_OPEN_WITH_CUSTOM_TAB
+import com.nononsenseapps.feeder.util.PREF_VAL_OPEN_WITH_READER
+import com.nononsenseapps.feeder.util.PREF_VAL_OPEN_WITH_WEBVIEW
+import com.nononsenseapps.feeder.util.Prefs
+import com.nononsenseapps.feeder.util.sloppyLinkToStrictURL
+import com.nononsenseapps.feeder.util.sloppyLinkToStrictURLNoThrows
+import com.nononsenseapps.feeder.views.FloatLabelLayout
+import com.nononsenseapps.jsonfeed.Feed
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.mapNotNull
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import org.kodein.di.generic.instance
+import java.net.URL
+
+const val TEMPLATE = "template"
+
+@FlowPreview
+@ExperimentalCoroutinesApi
+class EditFeedActivity : KodeinAwareActivity() {
+ private var id: Long = ID_UNSET
+
+ // Views and shit
+ private lateinit var textTitle: EditText
+ private lateinit var textUrl: EditText
+ private lateinit var textTag: AutoCompleteTextView
+ private lateinit var checkboxDefaultFullText: CheckBox
+ private lateinit var textSearch: EditText
+ private lateinit var detailsFrame: View
+ private lateinit var listResults: androidx.recyclerview.widget.RecyclerView
+ private lateinit var resultAdapter: ResultsAdapter
+ private lateinit var searchFrame: View
+ private var feedUrl: String? = null
+ private lateinit var emptyText: TextView
+ private lateinit var loadingProgress: View
+ private lateinit var urlLabel: FloatLabelLayout
+ private lateinit var titleLabel: FloatLabelLayout
+ private lateinit var tagLabel: FloatLabelLayout
+ private lateinit var openArticlesWith: Spinner
+
+ private var feedTitle: String = ""
+
+ internal var searchJob: Job? = null
+ set(value) {
+ field?.cancel()
+ field = value
+ }
+
+ private val feedParser: FeedParser by instance()
+ private val feedDao: FeedDao by instance()
+ private val prefs: Prefs by instance()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ if (shouldBeFloatingWindow()) {
+ setupFloatingWindow()
+ }
+ when (prefs.isNightMode) {
+ true -> {
+ R.style.EditFeedThemeNight
+ }
+ false -> {
+ R.style.EditFeedThemeDay
+ }
+ }.let {
+ setTheme(it)
+ }
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_edit_feed)
+
+ // Setup views
+ textTitle = findViewById(R.id.feed_title)
+ titleLabel = textTitle.parent as FloatLabelLayout
+ textUrl = findViewById(R.id.feed_url)
+ urlLabel = textUrl.parent as FloatLabelLayout
+ textTag = findViewById(R.id.feed_tag)
+ checkboxDefaultFullText = findViewById(R.id.feed_default_full_text)
+ tagLabel = textTag.parent as FloatLabelLayout
+ detailsFrame = findViewById(R.id.feed_details_frame)
+ searchFrame = findViewById(R.id.feed_search_frame)
+ textSearch = findViewById(R.id.search_view)
+ listResults = findViewById(R.id.results_listview)
+ emptyText = findViewById(android.R.id.empty)
+ loadingProgress = findViewById(R.id.loading_progress)
+ openArticlesWith = findViewById(R.id.open_articles_with)
+ resultAdapter = ResultsAdapter()
+ // listResults.emptyView = emptyText
+ listResults.setHasFixedSize(true)
+ listResults.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(this)
+ listResults.adapter = resultAdapter
+
+ textSearch.setOnEditorActionListener(
+ TextView.OnEditorActionListener { _, actionId, event ->
+ if (actionId == EditorInfo.IME_ACTION_GO ||
+ actionId == EditorInfo.IME_NULL && event.action == KeyEvent.ACTION_DOWN && event.keyCode == KeyEvent.KEYCODE_ENTER
+ ) {
+ // Hide keyboard
+ val f = currentFocus
+ if (f != null) {
+ val imm = getSystemService(
+ Context.INPUT_METHOD_SERVICE
+ ) as InputMethodManager
+ imm.hideSoftInputFromWindow(
+ f.windowToken,
+ 0
+ )
+ }
+
+ try {
+ // Issue search
+ val url: URL = sloppyLinkToStrictURL(textSearch.text.toString().trim())
+
+ listResults.visibility = View.GONE
+ emptyText.visibility = View.GONE
+ loadingProgress.visibility = View.VISIBLE
+
+ searchJob = searchForFeeds(url)
+ } catch (exc: Exception) {
+ exc.printStackTrace()
+ Toast.makeText(
+ this@EditFeedActivity,
+ R.string.could_not_load_url,
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+
+ return@OnEditorActionListener true
+ }
+ false
+ }
+ )
+
+ val addButton = findViewById