miniflux_sanity 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ # miniflux-sanity
2
+
3
+ A Ruby command-line utility to mark older entries as read on your Miniflux app. Defaults to items older than 30 days. Switch to ~1 day to wake up to a fresh feed every day.
4
+
5
+ ![A screenshot from my Terminal showcasing the utility in action](./assets/miniflux-sanity_cli.png)
6
+
7
+ __Before:__
8
+
9
+ ![A screenshot from my Miniflux app showing 5031 unread items](./assets/miniflux-sanity_before.png)
10
+
11
+ __After:__
12
+
13
+ ![A screenshot from my Miniflux app showing 516 unread items](./assets/miniflux-sanity_after.png)
14
+
15
+ ## Motivation
16
+
17
+ If I haven't read something in the preceding month, it's unlikely I ever will. Miniflux doesn't offer an _archive_ option so we mark entries as read instead. All it really does is offer me a saner overview of "unread" items at the top.
18
+
19
+ As is usually the case for me, I wanted to build something meaningful as I pick up Ruby again. This was a small use-case that was a good first challenge to tackle.
20
+
21
+ The code is admittedly not perfect. I welcome any constructive criticism or feedback, more so if you are a Ruby enthusiast.
22
+
23
+ ## Feature-set
24
+
25
+ - Uses token authentication
26
+ - Supports cloud and self-hosted Miniflux apps
27
+ - Configure number of days before which to mark items as read
28
+ - Resumes marking as read if interrupted
29
+
30
+ ## To-do
31
+
32
+ - [ ] Publish as a RubyGem for easier usage (see the [feature/rubygem](https://github.com/hirusi/miniflux-sanity/tree/feature/rubygem) branch)
33
+ - [ ] Unit testing
34
+ - [ ] Resume fetching if command crashes in between
35
+ - [ ] If an item is starred _and_ unread, don't mark it as read.
36
+ - This could lend itself to a nice workflow where my "to-read" can be starred while scanning through items.
37
+
38
+ ## Goals
39
+
40
+ - Get comfortable with Ruby's syntax
41
+ - Work with `Class`, `Module`, `dotenv` etc.
42
+ - Work with `JSON`
43
+ - Work with Ruby's `File` API
44
+ - Interact with an API using an HTTP library
45
+
46
+ ## Usage
47
+
48
+ You must have Ruby available on your system/shell.
49
+
50
+ Install by running `gem install miniflux_sanity`. All command line options can be viewed by running `miniflux_sanity --help`.
51
+
52
+ ## Development
53
+
54
+ The Ruby version is specified in `.ruby-version`. `rbenv` is able to read and set the correct local version in your shell.
55
+
56
+ - `git clone git@github.com:hirusi/miniflux-sanity.git`
57
+ - `cd miniflux-sanity`
58
+ - `cp .env.template .env`
59
+ - Update the `.env` file as required.
60
+ - You'll need a token from your Miniflux app under `Settings > API Keys -> Create a new API key`
61
+ - Install the dependencies: `bundle`
62
+ - Run the utility: `bundle exec ruby main.rb`
63
+
64
+ If you have a Docker setup to contribute using Alpine OS as its base, I'd be very happy to merge your PR.
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'miniflux_sanity'
4
+ require 'rationalist'
5
+
6
+ argv = Rationalist.parse(ARGV, **{
7
+ :string => [
8
+ 'host',
9
+ 'token'
10
+ ],
11
+ :default => {
12
+ :host => 'https://reader.miniflux.app/',
13
+ :days => 30
14
+ }
15
+ })
16
+
17
+ if argv[:help]
18
+ puts "miniflux_sanity is a command line utility to mark items older than specified time as read in Miniflux."
19
+ puts ""
20
+ puts "You may pass in the following arguments:"
21
+ puts ""
22
+ puts "--version show currently installed version"
23
+ puts ""
24
+ puts "--help show this help message"
25
+ puts ""
26
+ puts "--host your miniflux host (optional)
27
+ default: https://reader.miniflux.app/"
28
+ puts ""
29
+ puts "--token your miniflux API token (required)
30
+ generate from Settings > API Keys > Create a new API key"
31
+ puts ""
32
+ puts "--days number of days before which to mark items as read (optional)
33
+ default: 30
34
+ example: 7"
35
+ exit true
36
+ end
37
+
38
+ if argv[:version]
39
+ puts "miniflux_sanity v0.2.0"
40
+ exit true
41
+ end
42
+
43
+ if argv[:token].nil?
44
+ puts "You must at least specify the API token!"
45
+ puts ""
46
+ puts "--token (required) your miniflux API token
47
+ generate from Settings > API Keys > Create a new API key"
48
+ exit
49
+ end
50
+
51
+ miniflux_sanity = MinifluxSanity.new token: argv[:token], host: argv[:host], days: argv[:days]
52
+ miniflux_sanity.fetch_entries
53
+ miniflux_sanity.mark_entries_as_read
@@ -0,0 +1,23 @@
1
+ class Config
2
+ attr_reader :cutoff_date, :cutoff_timestamp, :auth
3
+
4
+ def initialize(host:, token:, days:)
5
+ require 'date'
6
+ @cutoff_date = Date.today - days.to_i
7
+ @cutoff_timestamp = @cutoff_date.to_time.to_i
8
+ @auth = {
9
+ :host => host,
10
+ :token => token
11
+ }
12
+ end
13
+
14
+ def load_env
15
+ require 'dotenv'
16
+ begin
17
+ Dotenv.load
18
+ rescue
19
+ p 'Could not load environment variables.'
20
+ exit(false)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,8 @@
1
+ class ::Hash
2
+ # https://stackoverflow.com/a/25990044/2464435
3
+ def deep_merge(second)
4
+
5
+ merger = proc { |_, v1, v2| Hash === v1 && Hash === v2 ? v1.merge(v2, &merger) : Array === v1 && Array === v2 ? v1 | v2 : [:undefined, nil, :nil].include?(v2) ? v1 : v2 }
6
+ merge(second.to_h, &merger)
7
+ end
8
+ end
@@ -0,0 +1,67 @@
1
+ require "httparty"
2
+ require "date"
3
+ require_relative "hash"
4
+
5
+ class MinifluxApi
6
+ include HTTParty
7
+ maintain_method_across_redirects true
8
+
9
+ def initialize(host:, token:)
10
+ self.class.base_uri "#{host}/v1/"
11
+
12
+ @options = {
13
+ :headers => {
14
+ "X-Auth-Token": token,
15
+ "Accept": "application/json"
16
+ }
17
+ }
18
+ end
19
+
20
+ def get_entries(before:, limit: 100, offset:, status: 'unread', direction: 'asc')
21
+ begin
22
+ custom_options = @options.deep_merge({
23
+ :query => {
24
+ :status => status,
25
+ :direction => direction,
26
+ :before => before,
27
+ :offset => offset,
28
+ :limit => limit
29
+ }
30
+ })
31
+ response = self.class.get("/entries", custom_options)
32
+ response_code = response.code.to_i
33
+
34
+ if response_code >= 400
35
+ raise response.parsed_response
36
+ else
37
+ response.parsed_response["entries"]
38
+ end
39
+ rescue => error
40
+ puts "Could not get entries from your Miniflux server. More details to follow.", error
41
+ exit
42
+ end
43
+ end
44
+
45
+ # Pass in an array of IDs
46
+ def mark_entries_read(ids:)
47
+ new_options = @options.deep_merge({
48
+ :headers => {
49
+ "Content-Type": "application/json"
50
+ },
51
+ :body => {
52
+ :entry_ids => ids,
53
+ :status => "read"
54
+ }.to_json
55
+ })
56
+
57
+ response = self.class.put("/entries", new_options)
58
+
59
+ if response.code.to_i == 204
60
+ puts "Marked entries with ID #{ids.join ", "} as read."
61
+ else
62
+ puts "Could not mark entries with ID #{ids.join ", "} as read"
63
+ exit(false)
64
+ end
65
+
66
+ end
67
+ end
@@ -0,0 +1,101 @@
1
+ require "date"
2
+ require_relative "config"
3
+ require_relative "miniflux_api"
4
+ require_relative "utils/cache"
5
+
6
+ class MinifluxSanity
7
+ def initialize(token:, host:, days:)
8
+ # Configuration object
9
+ # TODO Is there a way to pass this cleanly? We're passing everything we receive, with the exact same argument names as well
10
+ @@config = Config.new token: token, host: host, days: days
11
+
12
+ # Set up miniflux and cache clients
13
+ @@miniflux_client = MinifluxApi.new host: host, token: @@config.auth[:token]
14
+ @@cache_client = Cache.new path: "cache.json"
15
+ end
16
+
17
+ def last_fetched_today?
18
+ if @@cache_client.last_fetched.nil?
19
+ false
20
+ else
21
+ Date.parse(@@cache_client.last_fetched) == Date.today
22
+ end
23
+ end
24
+
25
+ def is_older_than_cutoff?(published_at:)
26
+ if Date.parse(published_at).to_time.to_i > @@config.cutoff_timestamp
27
+ false
28
+ else
29
+ true
30
+ end
31
+ end
32
+
33
+ def fetch_entries
34
+ if self.last_fetched_today?
35
+ puts "Last run was today, skipping fetch."
36
+ else
37
+ puts "Now collecting all unread entries before the specified date."
38
+ end
39
+
40
+ # We get these in blocks of 250
41
+ # When we hit <250, we stop because that is the last call to make!
42
+ size = 0
43
+ limit = 250
44
+ count = limit
45
+ until count < limit or self.last_fetched_today? do
46
+
47
+ entries = @@miniflux_client.get_entries before: @@config.cutoff_timestamp, offset: size, limit: limit
48
+
49
+ if entries.length < 1
50
+ puts "No more new entries"
51
+ exit true
52
+ end
53
+
54
+ entries.filter do |entry|
55
+ # Just for some extra resilience, we make sure to check the published_at date before we filter it. This would be helpful where the Miniflux API itself has a bug with its before filter, for example.
56
+ unless is_older_than_cutoff? published_at: entry["published_at"]
57
+ true
58
+ else
59
+ false
60
+ end
61
+ end
62
+
63
+ count = entries.count
64
+ size = size + count
65
+ puts "Fetched #{size} entries."
66
+
67
+ @@cache_client.last_fetched = Date.today.to_s
68
+ @@cache_client.size = size
69
+ @@cache_client.add_entries_to_file data: entries
70
+
71
+ unless count < limit
72
+ puts "Fetching more..."
73
+ end
74
+ end
75
+ end
76
+
77
+ def mark_entries_as_read
78
+ start = 0
79
+ interval = 10
80
+ cached_data = @@cache_client.read_from_file
81
+
82
+ while @@cache_client.size != 0 do
83
+ stop = start + interval
84
+
85
+ # For every 10 entries, mark as read.
86
+ # Reduce size and remove entries accordingly in our file.
87
+ filtered_data = cached_data["data"][start...stop]
88
+
89
+ ids_to_mark_read = filtered_data.map { |entry| entry["id"] }
90
+
91
+ @@miniflux_client.mark_entries_read ids: ids_to_mark_read
92
+
93
+ @@cache_client.size -= interval
94
+ @@cache_client.remove_entries_from_file ids: ids_to_mark_read
95
+
96
+ start += interval
97
+
98
+ puts "#{@@cache_client.size} entries left to be mark as read."
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,67 @@
1
+ require "json"
2
+ class Cache
3
+ attr_accessor :size, :last_fetched
4
+
5
+ def initialize(path:)
6
+ @config = {
7
+ :path => path
8
+ }
9
+
10
+ @size = File.readable?(@config[:path]) ? JSON.parse(File.read(@config[:path]).to_s)["data"].count : 0
11
+
12
+ @last_fetched = File.readable?(@config[:path]) ? JSON.parse(File.read(@config[:path]).to_s)["last_fetched"] : nil
13
+ end
14
+
15
+ def read_from_file
16
+ if File.readable? @config[:path]
17
+ JSON.parse(File.read(@config[:path]).to_s)
18
+ else
19
+ return {
20
+ "size" => 0,
21
+ "last_fetched" => nil,
22
+ "data" => Array.new
23
+ }
24
+ end
25
+ end
26
+
27
+ def remove_entries_from_file(ids:)
28
+ cache = self.read_from_file
29
+
30
+ ids.each do |id|
31
+ # TODO We could implement a search algo like binary search for performance here
32
+ cache["data"].each do |cached_entry|
33
+ if cached_entry["id"].to_i == id.to_i
34
+ cache["data"].delete cached_entry
35
+ end
36
+ end
37
+ end
38
+
39
+ self.size = cache["data"].count
40
+ self.write_to_file data: cache
41
+ end
42
+
43
+ def add_entries_to_file(data:)
44
+ cache = self.read_from_file
45
+ new_entries_count = 0
46
+
47
+ # If an entry doesn't exist in the cache, we add it.
48
+ data.each do |new_entry|
49
+ unless cache["data"].find_index { |cache_entry| cache_entry["id"] == new_entry["id"] }
50
+ cache["data"].push (new_entry.filter { |key| key != "content" })
51
+ new_entries_count += 1
52
+ end
53
+ end
54
+
55
+ self.size = cache["data"].count
56
+ self.last_fetched = Date.today
57
+ self.write_to_file data: cache
58
+
59
+ puts "#{new_entries_count} new entries were written to cache."
60
+ end
61
+
62
+ def write_to_file(data:)
63
+ data["size"] = @size
64
+ data["last_fetched"] = @last_fetched
65
+ File.write(@config[:path], data.to_json)
66
+ end
67
+ end
@@ -0,0 +1,24 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'miniflux_sanity'
3
+ s.version = '0.2.0'
4
+ s.required_ruby_version = Gem::Requirement.new(">= 2.7.1")
5
+ s.date = '2020-09-11'
6
+ s.summary = "Mark items older than specified time as read in Miniflux."
7
+ s.description = "Command line utility to mark items older than specified time as read in Miniflux."
8
+ s.authors = ["hirusi"]
9
+ s.email = 'hello@rusingh.com'
10
+ s.license = 'AGPL-3.0'
11
+
12
+ s.homepage = 'https://github.com/hirusi/miniflux-sanity'
13
+ s.metadata["homepage_uri"] = s.homepage
14
+ s.metadata["source_code_uri"] = s.homepage
15
+
16
+ # Specify which files should be added to the gem when it is released.
17
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
18
+ s.files = Dir.chdir(File.expand_path('..', __FILE__)) do
19
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
20
+ end
21
+ s.bindir = "bin"
22
+ s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
23
+ s.require_paths = ["lib"]
24
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: miniflux_sanity
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - hirusi
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-09-11 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Command line utility to mark items older than specified time as read
14
+ in Miniflux.
15
+ email: hello@rusingh.com
16
+ executables:
17
+ - miniflux_sanity
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - ".editorconfig"
22
+ - ".gitignore"
23
+ - ".ruby-version"
24
+ - CODE_OF_CONDUCT.md
25
+ - Gemfile
26
+ - Gemfile.lock
27
+ - LICENSE
28
+ - README.md
29
+ - Rakefile
30
+ - assets/miniflux-sanity_after.png
31
+ - assets/miniflux-sanity_before.png
32
+ - assets/miniflux-sanity_cli.png
33
+ - bin/miniflux_sanity
34
+ - lib/config.rb
35
+ - lib/hash.rb
36
+ - lib/miniflux_api.rb
37
+ - lib/miniflux_sanity.rb
38
+ - lib/utils/cache.rb
39
+ - miniflux_sanity.gemspec
40
+ homepage: https://github.com/hirusi/miniflux-sanity
41
+ licenses:
42
+ - AGPL-3.0
43
+ metadata:
44
+ homepage_uri: https://github.com/hirusi/miniflux-sanity
45
+ source_code_uri: https://github.com/hirusi/miniflux-sanity
46
+ post_install_message:
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: 2.7.1
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 3.1.2
62
+ signing_key:
63
+ specification_version: 4
64
+ summary: Mark items older than specified time as read in Miniflux.
65
+ test_files: []