dubhe 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c44a769b6d12785140253a7c9826cbc8c88e67a7f629abf6cbcdf27a048f9070
4
+ data.tar.gz: 1140b729850ab4896396f9dc759b2fd5863f6781d00d9989a9bc92251d52b898
5
+ SHA512:
6
+ metadata.gz: fd7c98dcfc0feacc68d034a916724d5cde6854aa1f8f0425725e75e4d6413cf1ad30dc9cba2140fa7ec835519bf68680a10a94c31716601055859558b812487f
7
+ data.tar.gz: 1610be6a804566d45387b4566fb017fa563808acd3bb8a65f6aeaa89a68b4c53480b91a596ced2ba40fad9fd089aa2464c804a74fc7c63a2d5e699b29b187024
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-09-21
4
+
5
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 ydah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # Dubhe
2
+
3
+ Dubhe (α Ursae Majoris, from Arabic *dubb*, “bear”) is a feeds-first reader
4
+ core for static documents, RSS/Atom/JSON Feed subscriptions, and local mail.
5
+ It does not execute JavaScript, embed a WebView, or send mail.
6
+
7
+ ## Features
8
+
9
+ - File-backed append-only entry/state indexes and content bodies
10
+ - RSS, Atom, and JSON Feed subscriptions through Iklil
11
+ - Conditional HTTP requests with ETag/Last-Modified and bounded redirects
12
+ - Safe HTML policy through Jabbah: XSS removal and remote-image blocking
13
+ - Feed discovery and OPML import/export
14
+ - Local HTML/Markdown docs with root-confined path resolution
15
+ - Ukdah-backed `.eml`/`.mbox` source API with CID image replacement
16
+ - Small reader/history/search API and plain-text reader-mode rendering
17
+
18
+ ## Installation
19
+
20
+ ```ruby
21
+ gem "dubhe"
22
+ ```
23
+
24
+ ```sh
25
+ gem install dubhe
26
+ ```
27
+
28
+ Dubhe requires Ruby 3.2 or newer. It uses Iklil, Jabbah, and Ukdah for
29
+ parsing, while HTTP and persistence use Ruby's standard library.
30
+
31
+ ## Quick start
32
+
33
+ ```ruby
34
+ require "dubhe"
35
+
36
+ store = Dubhe::Store.new
37
+ feeds = Dubhe::Sources::Feeds.new(store: store)
38
+ feeds.add("https://example.test/feed.xml")
39
+ feeds.refresh
40
+
41
+ feeds.entries.each { |entry| puts entry.title }
42
+ ```
43
+
44
+ Use `Dubhe::Policy` before displaying arbitrary HTML. Remote images are
45
+ blocked by default and `Result#blocked_count` is intended for a visible notice
46
+ in the UI. Dubhe is a reader core; a terminal or GPU view is an application
47
+ concern.
48
+
49
+ ## Development
50
+
51
+ ```sh
52
+ bundle install
53
+ bundle exec rake test
54
+ ```
55
+
56
+ ## License
57
+
58
+ Dubhe is released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,25 @@
1
+ # ADR 001: Keep the reader core source-oriented
2
+
3
+ - Status: accepted
4
+ - Date: 2026-09-21
5
+ - Author: Yudai Takada
6
+
7
+ ## Context
8
+
9
+ Docs, feeds, and mail share fetching, normalization, safe HTML handling,
10
+ storage, and history. Their input parsers are separate libraries, while the
11
+ reader needs one small source interface.
12
+
13
+ ## Decision
14
+
15
+ Dubhe owns source orchestration, policies, file-backed storage, HTTP fetching,
16
+ and reader state. Iklil owns feed parsing, Ukdah owns MIME parsing, and Jabbah
17
+ owns HTML parsing/sanitization. The v1 renderer is intentionally plain and
18
+ does not inspect author CSS. JavaScript and WebView embedding are out of scope.
19
+
20
+ ## Consequences
21
+
22
+ The feed path is useful without a GUI and remains testable with deterministic
23
+ fetchers. A future UI can consume `Entry`, `Body`, and `Source` without
24
+ reimplementing security or persistence. A richer CSS renderer can be added
25
+ later without changing source contracts.
@@ -0,0 +1,9 @@
1
+ # Reader-mode HTML elements
2
+
3
+ The v1 plain renderer handles `h1`–`h6`, `p`, `br`, `hr`, `ul`, `ol`, `li`,
4
+ `blockquote`, `pre`, `code`, `table`, `tr`, `td`, `th`, `img`, `a`, `em`,
5
+ `strong`, `del`, `mark`, `sub`, `sup`, `figure`, and `figcaption`.
6
+
7
+ Unknown elements contribute their children. `script`, `style`, `iframe`,
8
+ `object`, `embed`, and `form` contribute nothing. Author `style` and `class`
9
+ attributes are never used for layout or typography.
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Dubhe
7
+ module Fetch
8
+ Response = Data.define(:status, :headers, :body, :uri)
9
+
10
+ class Client
11
+ DEFAULT_USER_AGENT = "Dubhe/0.1 (+https://github.com/noxdea/dubhe)"
12
+ REDIRECTS = %w[301 302 303 307 308].freeze
13
+
14
+ def initialize(user_agent: DEFAULT_USER_AGENT, open_timeout: 10, read_timeout: 30, max_bytes: 10 * 1024 * 1024)
15
+ @user_agent = user_agent
16
+ @open_timeout = open_timeout
17
+ @read_timeout = read_timeout
18
+ @max_bytes = max_bytes
19
+ end
20
+
21
+ def get(url, headers: {}, redirects: 0)
22
+ uri = URI.parse(url.to_s)
23
+ raise Dubhe::Error, "only HTTP(S) URLs are supported" unless %w[http https].include?(uri.scheme)
24
+ raise Dubhe::Error, "too many redirects" if redirects > 5
25
+
26
+ request = Net::HTTP::Get.new(uri.request_uri)
27
+ request["User-Agent"] = @user_agent
28
+ request["Accept"] = "application/rss+xml, application/atom+xml, application/feed+json, application/json, text/xml, text/html;q=0.8"
29
+ headers.each { |key, value| request[key.to_s] = value.to_s unless value.nil? || value.to_s.empty? }
30
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
31
+ open_timeout: @open_timeout, read_timeout: @read_timeout) do |http|
32
+ http.request(request)
33
+ end
34
+ if REDIRECTS.include?(response.code)
35
+ location = response["location"]
36
+ raise Dubhe::Error, "redirect without location" unless location
37
+
38
+ return get(URI.join(uri.to_s, location).to_s, headers: headers, redirects: redirects + 1)
39
+ end
40
+ body = response.body.to_s
41
+ raise Dubhe::Error, "response exceeds size limit" if body.bytesize > @max_bytes
42
+
43
+ Response.new(status: response.code.to_i, headers: response.each_header.to_h, body: body, uri: uri.to_s)
44
+ rescue SocketError, Timeout::Error, IOError, SystemCallError => error
45
+ raise Dubhe::Error, "fetch failed for #{url}: #{error.message}"
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dubhe
4
+ Entry = Data.define(:id, :source_id, :title, :author, :published_at, :updated_at,
5
+ :url, :summary, :tags, :state, :parent_id)
6
+ Body = Data.define(:kind, :content, :base_url, :attachments, :headers)
7
+
8
+ Subscription = Data.define(:id, :title, :url, :site_url, :group, :etag, :last_modified,
9
+ :interval, :next_refresh_at, :failures)
10
+
11
+ class Entry
12
+ def read? = state == :read
13
+ def starred? = state == :starred
14
+ end
15
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "uri"
5
+ require "jabbah"
6
+
7
+ module Dubhe
8
+ class Policy
9
+ class Denied < Dubhe::Error; end
10
+ Result = Data.define(:html, :document, :blocked_count)
11
+
12
+ attr_reader :profile
13
+
14
+ def initialize(profile: :feed)
15
+ @profile = profile
16
+ end
17
+
18
+ def apply(input, base_url: nil, allow_remote_images: false, on_blocked: nil)
19
+ urls = []
20
+ profile_options = Jabbah::Sanitize::PROFILES.fetch(profile.to_sym, Jabbah::Sanitize::DEFAULT).merge(
21
+ allow_remote_images: allow_remote_images)
22
+ document = Jabbah::Sanitize.clean(input, profile: profile_options, base_url: base_url,
23
+ on_blocked: lambda do |url|
24
+ urls << url
25
+ on_blocked&.call(url)
26
+ end,
27
+ allow: Jabbah::Sanitize::DEFAULT)
28
+ Result.new(html: document.to_html, document: document, blocked_count: urls.length)
29
+ end
30
+
31
+ def resolve_local(root, path)
32
+ root_path = Pathname.new(root.to_s).expand_path
33
+ raise Denied, "source root does not exist: #{root}" unless root_path.directory?
34
+
35
+ candidate = root_path.join(path.to_s).cleanpath
36
+ root_real = root_path.realpath
37
+ candidate_real = candidate.realpath
38
+ return candidate_real.to_s if candidate_real == root_real || candidate_real.to_s.start_with?("#{root_real}#{File::SEPARATOR}")
39
+
40
+ raise Denied, "path escapes source root: #{path}"
41
+ rescue Errno::ENOENT
42
+ # Check the nearest existing parent so a new path cannot escape via .. .
43
+ parent = candidate
44
+ parent = parent.parent until parent.exist? || parent.root?
45
+ parent_real = parent.realpath
46
+ return candidate.to_s if parent_real == root_real || parent_real.to_s.start_with?("#{root_real}#{File::SEPARATOR}")
47
+
48
+ raise Denied, "path escapes source root: #{path}"
49
+ end
50
+
51
+ def external_url?(url)
52
+ scheme = URI.parse(url.to_s).scheme.to_s.downcase
53
+ %w[http https mailto].include?(scheme)
54
+ rescue URI::InvalidURIError
55
+ false
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dubhe
4
+ class Reader
5
+ attr_reader :sources, :current_entry
6
+
7
+ def initialize(sources: [], store: Store.new)
8
+ @store = store
9
+ @sources = Array(sources)
10
+ @history = []
11
+ @history_index = -1
12
+ @current_entry = nil
13
+ end
14
+
15
+ def add_source(source)
16
+ @sources << source
17
+ source
18
+ end
19
+
20
+ def refresh
21
+ @sources.flat_map(&:refresh)
22
+ end
23
+
24
+ def entries(source_id: nil, filter: nil)
25
+ result = if source_id
26
+ @sources.find { |source| source.id == source_id }&.entries(filter: filter) || []
27
+ else
28
+ @sources.flat_map { |source| source.entries(filter: filter) }
29
+ end
30
+ result.sort_by { |entry| entry.updated_at || entry.published_at || Time.at(0) }.reverse
31
+ end
32
+
33
+ def open(entry)
34
+ source = @sources.find { |candidate| candidate.id == entry.source_id }
35
+ raise Error, "source not found: #{entry.source_id}" unless source
36
+
37
+ @current_entry = entry
38
+ @history = @history[0..@history_index] if @history_index >= 0
39
+ @history << entry
40
+ @history_index = @history.length - 1
41
+ source.fetch_body(entry)
42
+ end
43
+
44
+ def back
45
+ return nil if @history_index <= 0
46
+
47
+ @history_index -= 1
48
+ @current_entry = @history[@history_index]
49
+ body_for(@current_entry)
50
+ end
51
+
52
+ def forward
53
+ return nil if @history_index >= @history.length - 1
54
+
55
+ @history_index += 1
56
+ @current_entry = @history[@history_index]
57
+ body_for(@current_entry)
58
+ end
59
+
60
+ def mark_read(entry, read: true)
61
+ @store.update_state(entry.id, state: read ? :read : :unread)
62
+ entry
63
+ end
64
+
65
+ def star(entry, starred: true)
66
+ @store.update_state(entry.id, state: starred ? :starred : :read)
67
+ entry
68
+ end
69
+
70
+ def search(query, source_id: nil)
71
+ needle = query.to_s.downcase
72
+ entries(source_id: source_id).select do |entry|
73
+ body = @store.body(entry.id)&.content.to_s
74
+ [entry.title, entry.author, entry.summary, body].compact.join(" ").downcase.include?(needle)
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def body_for(entry)
81
+ source = @sources.find { |candidate| candidate.id == entry.source_id }
82
+ source&.fetch_body(entry)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+
5
+ module Dubhe
6
+ module Render
7
+ # EXTRACTION CANDIDATE: document renderer
8
+ # Canopus and hadar can share this once a second consumer needs it.
9
+ HANDLERS = {
10
+ "p" => :paragraph, "h1" => :heading, "h2" => :heading, "h3" => :heading,
11
+ "h4" => :heading, "h5" => :heading, "h6" => :heading, "li" => :list_item,
12
+ "blockquote" => :quote, "pre" => :code_block, "hr" => :rule,
13
+ "br" => :break, "table" => :table, "figure" => :figure
14
+ }.freeze
15
+ BLOCKS = (HANDLERS.keys + %w[div section article main header footer ul ol dl dt dd figcaption]).freeze
16
+
17
+ module_function
18
+
19
+ def call(node, theme: {})
20
+ root = node.respond_to?(:root) ? node.root : node
21
+ render_node(root, theme).strip
22
+ end
23
+
24
+ def render_node(node, theme)
25
+ return CGI.unescapeHTML(node.data.to_s) if node.text?
26
+ return "" unless node.element?
27
+ return "" if %w[script style iframe object embed form].include?(node.name)
28
+
29
+ content = node.children.map { |child| render_node(child, theme) }.join
30
+ case HANDLERS[node.name]
31
+ when :heading then "\n#{content.strip}\n"
32
+ when :paragraph then "\n#{content.strip}\n"
33
+ when :list_item then "\n• #{content.strip}"
34
+ when :quote then "\n#{content.lines.map { |line| "> #{line}" }.join}"
35
+ when :code_block then "\n```\n#{node.text}\n```\n"
36
+ when :rule then "\n────────\n"
37
+ when :break then "\n"
38
+ when :table then "\n#{render_table(node)}\n"
39
+ when :figure then "\n#{content.strip}\n"
40
+ else
41
+ BLOCKS.include?(node.name) ? "\n#{content}\n" : content
42
+ end
43
+ end
44
+
45
+ def render_table(node)
46
+ rows = node.search("tr").map do |row|
47
+ row.children.select { |child| child.element? && %w[td th].include?(child.name) }.map { |cell| cell.text.strip }
48
+ end
49
+ return "" if rows.empty?
50
+
51
+ width_count = rows.map(&:length).max.to_i
52
+ widths = (0...width_count).map { |index| rows.filter_map { |row| row[index]&.length }.max.to_i }
53
+ rows.map do |row|
54
+ values = (0...width_count).map { |index| row[index].to_s.ljust(widths[index]) }
55
+ "| #{values.join(" | ")} |"
56
+ end.join("\n")
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dubhe
4
+ module Source
5
+ def id = raise NotImplementedError
6
+ def title = raise NotImplementedError
7
+ def icon = nil
8
+ def refresh = raise NotImplementedError
9
+ def entries(filter: nil) = raise NotImplementedError
10
+ def fetch_body(_entry) = raise NotImplementedError
11
+ def supports?(capability) = %i[refresh search unread].include?(capability)
12
+ end
13
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "cgi/escape"
5
+ require "pathname"
6
+ require "uri"
7
+ require "jabbah"
8
+
9
+ module Dubhe
10
+ module Sources
11
+ class Docs
12
+ include Source
13
+
14
+ EXTENSIONS = %w[.html .htm .md .markdown].freeze
15
+
16
+ attr_reader :root
17
+
18
+ def initialize(root:, store: Store.new, policy: Policy.new(profile: :docs))
19
+ @root = File.expand_path(root.to_s)
20
+ @store = store
21
+ @policy = policy
22
+ end
23
+
24
+ def id = "docs:#{Digest::SHA256.hexdigest(@root)}"
25
+ def title = File.basename(@root)
26
+ def icon = "folder"
27
+
28
+ def refresh
29
+ entries = files.map do |path|
30
+ relative = Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
31
+ Entry.new(id: "#{id}:#{relative}", source_id: id, title: title_for(path), author: nil,
32
+ published_at: nil, updated_at: File.mtime(path), url: "file://#{path}", summary: nil,
33
+ tags: [], state: :unread, parent_id: parent_id(relative))
34
+ end
35
+ entries.each { |entry| @store.save_entry(entry); @store.save_body(entry.id, fetch_body(entry)) }
36
+ entries
37
+ end
38
+
39
+ def entries(filter: nil)
40
+ result = @store.entries(source_id: id)
41
+ result = refresh if result.empty?
42
+ filter ? result.select { |entry| filter.call(entry) } : result
43
+ end
44
+
45
+ def fetch_body(entry)
46
+ path = resolve(entry.url.to_s.sub("file://", ""))
47
+ if markdown?(path)
48
+ Body.new(kind: :html, content: markdown_to_html(File.read(path)), base_url: "file://#{path}", attachments: [], headers: {})
49
+ else
50
+ result = @policy.apply(File.binread(path), base_url: nil)
51
+ Body.new(kind: :html, content: result.html, base_url: "file://#{path}", attachments: [], headers: {"blocked_count" => result.blocked_count})
52
+ end
53
+ end
54
+
55
+ def resolve(path)
56
+ candidate = path.to_s.start_with?("file://") ? path.to_s.sub("file://", "") : path
57
+ @policy.resolve_local(@root, candidate)
58
+ end
59
+
60
+ def supports?(capability)
61
+ %i[refresh search tree unread].include?(capability)
62
+ end
63
+
64
+ private
65
+
66
+ def files
67
+ Dir.glob(File.join(@root, "**", "*")).select do |path|
68
+ File.file?(path) && EXTENSIONS.include?(File.extname(path).downcase) &&
69
+ path.split(File::SEPARATOR).none? { |part| part.start_with?(".") || %w[node_modules vendor].include?(part) }
70
+ end.sort
71
+ end
72
+
73
+ def markdown?(path)
74
+ %w[.md .markdown].include?(File.extname(path).downcase)
75
+ end
76
+
77
+ def title_for(path)
78
+ return File.basename(path, File.extname(path)) unless markdown?(path)
79
+
80
+ first_heading = File.foreach(path).find { |line| line.match?(/\A#\s+/) }
81
+ first_heading ? first_heading.sub(/\A#\s+/, "").strip : File.basename(path, File.extname(path))
82
+ end
83
+
84
+ def parent_id(relative)
85
+ directory = File.dirname(relative)
86
+ return nil if directory == "."
87
+
88
+ "#{id}:#{directory}"
89
+ end
90
+
91
+ def markdown_to_html(text)
92
+ lines = text.to_s.lines
93
+ output = []
94
+ paragraph = []
95
+ flush = lambda do
96
+ output << "<p>#{paragraph.join(" ").strip}</p>" unless paragraph.empty?
97
+ paragraph.clear
98
+ end
99
+ in_code = false
100
+ code = []
101
+ lines.each do |line|
102
+ if line.start_with?("```")
103
+ if in_code
104
+ output << "<pre><code>#{CGI.escapeHTML(code.join)}</code></pre>"
105
+ code.clear
106
+ end
107
+ in_code = !in_code
108
+ elsif in_code
109
+ code << line
110
+ elsif line.match?(/\A#+\s+/)
111
+ flush.call
112
+ level = line[/\A#+/].length
113
+ output << "<h#{level}>#{CGI.escapeHTML(line.sub(/\A#+\s+/, "").strip)}</h#{level}>"
114
+ elsif line.strip.empty?
115
+ flush.call
116
+ else
117
+ paragraph << CGI.escapeHTML(line.strip)
118
+ end
119
+ end
120
+ in_code ? output << "<pre><code>#{CGI.escapeHTML(code.join)}</code></pre>" : flush.call
121
+ output.join
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "time"
7
+ require "uri"
8
+ require "iklil"
9
+ require "jabbah"
10
+
11
+ module Dubhe
12
+ module Sources
13
+ class Feeds
14
+ include Source
15
+
16
+ attr_reader :last_errors
17
+
18
+ def initialize(subscriptions: nil, store: Store.new, fetcher: Fetch::Client.new,
19
+ policy: Policy.new(profile: :feed), interval: 3600, clock: -> { Time.now })
20
+ @store = store
21
+ @fetcher = fetcher
22
+ @policy = policy
23
+ @interval = interval
24
+ @clock = clock
25
+ @subscriptions_path = File.join(@store.root, "sources.jsonc")
26
+ values = subscriptions.nil? ? load_subscriptions : subscriptions
27
+ @subscriptions = Array(values).map { |value| normalize_subscription(value) }
28
+ @raw_entries = {}
29
+ @last_errors = {}
30
+ end
31
+
32
+ def id = "feeds"
33
+ def title = "Feeds"
34
+ def icon = "rss"
35
+
36
+ def add(url, title: nil, site_url: nil, group: nil)
37
+ normalized = url.to_s.strip
38
+ existing = @subscriptions.find { |subscription| subscription.url == normalized }
39
+ return existing if existing
40
+
41
+ subscription = Subscription.new(id: Digest::SHA256.hexdigest(normalized), title: title || normalized,
42
+ url: normalized, site_url: site_url, group: group, etag: nil, last_modified: nil,
43
+ interval: @interval, next_refresh_at: nil, failures: 0)
44
+ @subscriptions << subscription
45
+ persist_subscriptions
46
+ subscription
47
+ end
48
+
49
+ def remove(id_or_url)
50
+ before = @subscriptions.length
51
+ @subscriptions.reject! { |subscription| subscription.id == id_or_url.to_s || subscription.url == id_or_url.to_s }
52
+ changed = before != @subscriptions.length
53
+ persist_subscriptions if changed
54
+ changed
55
+ end
56
+
57
+ def subscriptions = @subscriptions.dup
58
+
59
+ def refresh(force: false)
60
+ refreshed = []
61
+ now = @clock.call
62
+ @subscriptions = @subscriptions.map do |subscription|
63
+ next subscription if !force && subscription.next_refresh_at && subscription.next_refresh_at > now
64
+
65
+ begin
66
+ headers = {"If-None-Match" => subscription.etag, "If-Modified-Since" => subscription.last_modified}
67
+ response = @fetcher.get(subscription.url, headers: headers)
68
+ if response.status == 304
69
+ refreshed.concat(@store.entries(source_id: id).select { |entry| entry.updated_at && entry.updated_at >= now - subscription.interval })
70
+ next schedule(subscription, now: now, failures: 0)
71
+ end
72
+ raise Error, "feed returned HTTP #{response.status}" unless response.status.between?(200, 299)
73
+
74
+ feed = Iklil.parse(response.body, base_url: subscription.url)
75
+ entries = feed.entries.map do |raw_entry|
76
+ entry = convert_entry(subscription, raw_entry)
77
+ @raw_entries[entry.id] = raw_entry
78
+ entry
79
+ end
80
+ entries.each do |entry|
81
+ @store.save_entry(entry)
82
+ @store.save_body(entry.id, fetch_body(entry))
83
+ end
84
+ refreshed.concat(entries)
85
+ schedule(subscription, now: now, failures: 0, title: feed.title, site_url: feed.site_url,
86
+ etag: response.headers["etag"], last_modified: response.headers["last-modified"])
87
+ rescue StandardError => error
88
+ @last_errors[subscription.id] = error
89
+ schedule(subscription, now: now, failures: subscription.failures + 1)
90
+ end
91
+ end
92
+ persist_subscriptions
93
+ refreshed
94
+ end
95
+
96
+ def entries(filter: nil)
97
+ result = @store.entries(source_id: id)
98
+ return result unless filter
99
+
100
+ result.select { |entry| filter.call(entry) }
101
+ end
102
+
103
+ def fetch_body(entry)
104
+ source = @raw_entries[entry.id]
105
+ if source.nil?
106
+ stored = @store.body(entry.id)
107
+ return stored if stored
108
+ return Body.new(kind: :text, content: entry.summary.to_s, base_url: entry.url, attachments: [], headers: {})
109
+ end
110
+ content = source.content.to_s
111
+ if source.html?
112
+ result = @policy.apply(content, base_url: entry.url)
113
+ extracted = Jabbah::Extract.article(result.document)
114
+ content = extracted ? extracted[:content].to_html : result.html
115
+ Body.new(kind: :html, content: content, base_url: entry.url, attachments: source.enclosures, headers: {"blocked_count" => result.blocked_count})
116
+ else
117
+ Body.new(kind: :text, content: content, base_url: entry.url, attachments: source.enclosures, headers: {})
118
+ end
119
+ end
120
+
121
+ def supports?(capability)
122
+ %i[refresh search unread].include?(capability)
123
+ end
124
+
125
+ def import_opml(bytes)
126
+ Iklil.parse_opml(bytes).each { |subscription| add(subscription.xml_url, title: subscription.title, site_url: subscription.html_url) }
127
+ end
128
+
129
+ def export_opml(title: "Dubhe subscriptions")
130
+ Iklil.render_opml(@subscriptions.map { |subscription| {title: subscription.title, xml_url: subscription.url,
131
+ html_url: subscription.site_url, category: subscription.group} }, title: title)
132
+ end
133
+
134
+ def self.discover(html, base_url: nil)
135
+ document = html.is_a?(Jabbah::Document) ? html : Jabbah.parse(html)
136
+ document.search("link").filter_map do |link|
137
+ rel = link["rel"].to_s.split.map(&:downcase)
138
+ type = link["type"].to_s.downcase
139
+ next unless rel.include?("alternate") && %w[application/rss+xml application/atom+xml application/feed+json application/json].include?(type)
140
+
141
+ href = link["href"]
142
+ next unless href && !href.empty?
143
+ base_url ? URI.join(base_url, href).to_s : href
144
+ rescue URI::InvalidURIError
145
+ nil
146
+ end.uniq
147
+ end
148
+
149
+ private
150
+
151
+ def normalize_subscription(value)
152
+ return value if value.is_a?(Subscription)
153
+
154
+ url = value[:url] || value["url"]
155
+ title = value[:title] || value["title"] || url
156
+ Subscription.new(id: Digest::SHA256.hexdigest(url.to_s), title: title, url: url.to_s,
157
+ site_url: value[:site_url] || value["site_url"], group: value[:group] || value["group"],
158
+ etag: value[:etag] || value["etag"], last_modified: value[:last_modified] || value["last_modified"],
159
+ interval: value[:interval] || value["interval"] || @interval,
160
+ next_refresh_at: parse_time(value[:next_refresh_at] || value["next_refresh_at"]),
161
+ failures: value[:failures] || value["failures"] || 0)
162
+ end
163
+
164
+ def load_subscriptions
165
+ return [] unless File.file?(@subscriptions_path)
166
+
167
+ records = JSON.parse(File.read(@subscriptions_path))
168
+ records.is_a?(Array) ? records.select { |value| value.is_a?(Hash) } : []
169
+ rescue JSON::ParserError, SystemCallError
170
+ []
171
+ end
172
+
173
+ def persist_subscriptions
174
+ FileUtils.mkdir_p(File.dirname(@subscriptions_path))
175
+ temporary = "#{@subscriptions_path}.tmp-#{Process.pid}"
176
+ records = @subscriptions.map { |subscription| subscription.to_h.merge(next_refresh_at: subscription.next_refresh_at&.iso8601) }
177
+ File.write(temporary, JSON.pretty_generate(records) << "\n")
178
+ File.rename(temporary, @subscriptions_path)
179
+ ensure
180
+ File.delete(temporary) if temporary && File.file?(temporary)
181
+ end
182
+
183
+ def parse_time(value)
184
+ return value if value.nil? || value.is_a?(Time)
185
+
186
+ Time.parse(value.to_s)
187
+ rescue ArgumentError
188
+ nil
189
+ end
190
+
191
+ def convert_entry(subscription, entry)
192
+ Entry.new(id: "#{subscription.id}:#{entry.id}", source_id: id, title: entry.title.to_s,
193
+ author: Array(entry.authors).join(", "), published_at: entry.published_at, updated_at: entry.updated_at,
194
+ url: entry.url, summary: entry.summary.to_s, tags: Array(entry.categories), state: :unread, parent_id: nil)
195
+ end
196
+
197
+ def schedule(subscription, now:, failures:, title: subscription.title, site_url: subscription.site_url,
198
+ etag: subscription.etag, last_modified: subscription.last_modified)
199
+ delay = failures.zero? ? subscription.interval : [300 * (3**(failures - 1)), 21_600].min
200
+ Subscription.new(id: subscription.id, title: title || subscription.title, url: subscription.url,
201
+ site_url: site_url, group: subscription.group, etag: etag, last_modified: last_modified,
202
+ interval: subscription.interval, next_refresh_at: now + delay, failures: failures)
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest"
5
+ require "ukdah"
6
+
7
+ module Dubhe
8
+ module Sources
9
+ class Mail
10
+ include Source
11
+
12
+ def initialize(path:, store: Store.new, policy: Policy.new(profile: :mail))
13
+ @path = File.expand_path(path.to_s)
14
+ @store = store
15
+ @policy = policy
16
+ @messages = {}
17
+ end
18
+
19
+ def id = "mail:#{Digest::SHA256.hexdigest(@path)}"
20
+ def title = File.basename(@path)
21
+ def icon = "mail"
22
+
23
+ def refresh
24
+ messages = if File.extname(@path).downcase == ".mbox"
25
+ Ukdah::Mbox.each(File.binread(@path)).to_a
26
+ else
27
+ [Ukdah::Message.parse(File.binread(@path))]
28
+ end
29
+ entries = messages.map do |message|
30
+ message_id = message.message_id || Digest::SHA256.hexdigest(message.raw.to_s)
31
+ entry = Entry.new(id: "#{id}:#{message_id}", source_id: id, title: message.subject.to_s,
32
+ author: message.from.map { |address| address.respond_to?(:email) ? address.email : address.to_s }.join(", "),
33
+ published_at: message.date, updated_at: message.date, url: nil, summary: message.header("x-snippet"),
34
+ tags: [], state: :unread, parent_id: nil)
35
+ @messages[entry.id] = message
36
+ @store.save_entry(entry)
37
+ @store.save_body(entry.id, fetch_body(entry))
38
+ entry
39
+ end
40
+ entries
41
+ end
42
+
43
+ def entries(filter: nil)
44
+ result = @store.entries(source_id: id)
45
+ result = refresh if result.empty?
46
+ filter ? result.select { |entry| filter.call(entry) } : result
47
+ end
48
+
49
+ def fetch_body(entry)
50
+ message = @messages[entry.id]
51
+ return @store.body(entry.id) unless message
52
+
53
+ if (part = message.html_part)
54
+ html = message.decoded(part)
55
+ message.inline_parts.each do |content_id, inline_part|
56
+ next if content_id.start_with?("<")
57
+
58
+ mime = inline_part.content_type.to_s
59
+ data = Base64.strict_encode64(inline_part.body.to_s)
60
+ html = html.gsub("cid:#{content_id}", "data:#{mime};base64,#{data}")
61
+ end
62
+ result = @policy.apply(html)
63
+ Body.new(kind: :html, content: result.html, base_url: nil,
64
+ attachments: message.attachments.map(&:filename).compact, headers: {"blocked_count" => result.blocked_count})
65
+ elsif (part = message.text_part)
66
+ Body.new(kind: :text, content: message.decoded(part), base_url: nil,
67
+ attachments: message.attachments.map(&:filename).compact, headers: {})
68
+ else
69
+ Body.new(kind: :text, content: "", base_url: nil, attachments: [], headers: {})
70
+ end
71
+ end
72
+
73
+ def supports?(capability)
74
+ %i[refresh search unread].include?(capability)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "time"
7
+
8
+ module Dubhe
9
+ class Store
10
+ attr_reader :root
11
+
12
+ def initialize(root: nil)
13
+ data_root = root || ENV["XDG_DATA_HOME"] || File.join(Dir.home, ".local", "share")
14
+ @root = root ? File.expand_path(root.to_s) : File.join(data_root.to_s, "dubhe")
15
+ @index_root = File.join(@root, "index")
16
+ @body_root = File.join(@root, "bodies")
17
+ FileUtils.mkdir_p([@index_root, @body_root])
18
+ end
19
+
20
+ def save_entry(entry)
21
+ append("entries.jsonl", serialize_entry(entry))
22
+ entry
23
+ end
24
+
25
+ def save_entries(entries)
26
+ Array(entries).each { |entry| save_entry(entry) }
27
+ entries
28
+ end
29
+
30
+ def entries(source_id: nil)
31
+ records = read_jsonl("entries.jsonl")
32
+ latest = {}
33
+ records.each { |record| latest[record["id"].to_s] = record }
34
+ states = state_records
35
+ latest.values.filter_map do |record|
36
+ next if source_id && record["source_id"] != source_id
37
+
38
+ deserialize_entry(record, states[record["id"].to_s])
39
+ end
40
+ end
41
+
42
+ def entry(id)
43
+ entries.find { |item| item.id == id.to_s }
44
+ end
45
+
46
+ def update_state(id, state: nil, starred: nil, tags: nil)
47
+ current = state_records[id.to_s] || {}
48
+ record = {"id" => id.to_s, "state" => (state || current["state"] || "unread"),
49
+ "starred" => starred.nil? ? current["starred"] : starred,
50
+ "tags" => tags.nil? ? current["tags"] : tags}
51
+ append("state.jsonl", record)
52
+ record
53
+ end
54
+
55
+ def save_body(id, body)
56
+ digest = Digest::SHA256.hexdigest(id.to_s)
57
+ directory = File.join(@body_root, digest[0, 2], digest[2, 2])
58
+ FileUtils.mkdir_p(directory)
59
+ path = File.join(directory, "#{digest}.#{extension(body.kind)}")
60
+ temporary = "#{path}.tmp-#{Process.pid}-#{rand(1_000_000)}"
61
+ File.binwrite(temporary, body.content.to_s)
62
+ File.rename(temporary, path)
63
+ path
64
+ ensure
65
+ File.delete(temporary) if temporary && File.file?(temporary)
66
+ end
67
+
68
+ def body(id)
69
+ digest = Digest::SHA256.hexdigest(id.to_s)
70
+ directory = File.join(@body_root, digest[0, 2], digest[2, 2])
71
+ path = %w[html text markdown].map { |kind| File.join(directory, "#{digest}.#{kind}") }.find { |candidate| File.file?(candidate) }
72
+ return nil unless path
73
+
74
+ kind = File.extname(path).delete_prefix(".").to_sym
75
+ Body.new(kind: kind, content: File.binread(path), base_url: nil, attachments: [], headers: {})
76
+ end
77
+
78
+ def compact!
79
+ rewrite("entries.jsonl", entries.map { |entry| serialize_entry(entry) })
80
+ rewrite("state.jsonl", state_records.values)
81
+ self
82
+ end
83
+
84
+ private
85
+
86
+ def append(name, record)
87
+ File.open(File.join(@index_root, name), "ab") { |file| file.write(JSON.generate(record) << "\n") }
88
+ end
89
+
90
+ def read_jsonl(name)
91
+ path = File.join(@index_root, name)
92
+ return [] unless File.file?(path)
93
+
94
+ File.foreach(path).filter_map do |line|
95
+ JSON.parse(line)
96
+ rescue JSON::ParserError
97
+ nil
98
+ end
99
+ end
100
+
101
+ def state_records
102
+ read_jsonl("state.jsonl").each_with_object({}) { |record, result| result[record["id"].to_s] = record }
103
+ end
104
+
105
+ def serialize_entry(entry)
106
+ {"id" => entry.id.to_s, "source_id" => entry.source_id.to_s, "title" => entry.title,
107
+ "author" => entry.author, "published_at" => encode_time(entry.published_at),
108
+ "updated_at" => encode_time(entry.updated_at), "url" => entry.url, "summary" => entry.summary,
109
+ "tags" => Array(entry.tags), "state" => entry.state.to_s, "parent_id" => entry.parent_id}
110
+ end
111
+
112
+ def deserialize_entry(record, state)
113
+ merged = record.merge(state || {})
114
+ Entry.new(id: record["id"], source_id: record["source_id"], title: record["title"],
115
+ author: record["author"], published_at: decode_time(record["published_at"]),
116
+ updated_at: decode_time(record["updated_at"]), url: record["url"], summary: record["summary"],
117
+ tags: Array(merged["tags"]), state: (merged["state"] || "unread").to_sym, parent_id: record["parent_id"])
118
+ end
119
+
120
+ def encode_time(value)
121
+ value&.iso8601
122
+ end
123
+
124
+ def decode_time(value)
125
+ Time.parse(value.to_s) if value && !value.to_s.empty?
126
+ rescue ArgumentError
127
+ nil
128
+ end
129
+
130
+ def extension(kind)
131
+ %i[html text markdown].include?(kind.to_sym) ? kind.to_s : "text"
132
+ end
133
+
134
+ def rewrite(name, records)
135
+ path = File.join(@index_root, name)
136
+ temporary = "#{path}.tmp-#{Process.pid}"
137
+ File.open(temporary, "wb") { |file| records.each { |record| file.write(JSON.generate(record) << "\n") } }
138
+ File.rename(temporary, path)
139
+ ensure
140
+ File.delete(temporary) if temporary && File.file?(temporary)
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dubhe
4
+ VERSION = "0.1.0"
5
+ end
data/lib/dubhe.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dubhe/version"
4
+
5
+ module Dubhe
6
+ class Error < StandardError; end
7
+ end
8
+
9
+ require_relative "dubhe/models"
10
+ require_relative "dubhe/source"
11
+ require_relative "dubhe/policy"
12
+ require_relative "dubhe/store"
13
+ require_relative "dubhe/fetch"
14
+ require_relative "dubhe/render"
15
+ require_relative "dubhe/reader"
16
+ require_relative "dubhe/sources/feeds"
17
+ require_relative "dubhe/sources/docs"
18
+ require_relative "dubhe/sources/mail"
data/sig/dubhe.rbs ADDED
@@ -0,0 +1,39 @@
1
+ module Dubhe
2
+ VERSION: String
3
+
4
+ class Error < StandardError
5
+ end
6
+
7
+ class Store
8
+ def initialize: (?root: String?) -> void
9
+ def save_entry: (untyped entry) -> untyped
10
+ def entries: (?source_id: String?) -> Array[untyped]
11
+ def entry: (String id) -> untyped
12
+ def update_state: (String id, ?state: Symbol?, ?starred: bool?, ?tags: Array[String]?) -> Hash[String, untyped]
13
+ def save_body: (String id, untyped body) -> String
14
+ def body: (String id) -> untyped
15
+ end
16
+
17
+ class Policy
18
+ class Denied < Error
19
+ end
20
+ Result: untyped
21
+ def initialize: (?profile: Symbol) -> void
22
+ def apply: (String input, ?base_url: String?, ?allow_remote_images: bool) -> untyped
23
+ def resolve_local: (String root, String path) -> String
24
+ end
25
+
26
+ module Render
27
+ def self.call: (untyped node, ?theme: Hash[untyped, untyped]) -> String
28
+ end
29
+
30
+ class Reader
31
+ def initialize: (?sources: Array[untyped], ?store: Store) -> void
32
+ def refresh: () -> Array[untyped]
33
+ def entries: (?source_id: String?, ?filter: Proc?) -> Array[untyped]
34
+ def open: (untyped entry) -> untyped
35
+ def back: () -> untyped
36
+ def forward: () -> untyped
37
+ def search: (String query, ?source_id: String?) -> Array[untyped]
38
+ end
39
+ end
metadata ADDED
@@ -0,0 +1,148 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dubhe
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: iklil
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: jabbah
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: base64
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.2'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.2'
54
+ - !ruby/object:Gem::Dependency
55
+ name: cgi
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.4'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.4'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rexml
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.4'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.4'
82
+ - !ruby/object:Gem::Dependency
83
+ name: ukdah
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.1'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.1'
96
+ description: Dubhe provides file-backed reader storage, feed fetching, sanitization
97
+ policies, and source APIs for static documents and mail.
98
+ email:
99
+ - t.yudai92@gmail.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - CHANGELOG.md
105
+ - LICENSE.txt
106
+ - README.md
107
+ - docs/adr/001-reader-core-boundaries.md
108
+ - docs/supported-elements.md
109
+ - lib/dubhe.rb
110
+ - lib/dubhe/fetch.rb
111
+ - lib/dubhe/models.rb
112
+ - lib/dubhe/policy.rb
113
+ - lib/dubhe/reader.rb
114
+ - lib/dubhe/render.rb
115
+ - lib/dubhe/source.rb
116
+ - lib/dubhe/sources/docs.rb
117
+ - lib/dubhe/sources/feeds.rb
118
+ - lib/dubhe/sources/mail.rb
119
+ - lib/dubhe/store.rb
120
+ - lib/dubhe/version.rb
121
+ - sig/dubhe.rbs
122
+ homepage: https://github.com/noxdea/dubhe
123
+ licenses:
124
+ - MIT
125
+ metadata:
126
+ allowed_push_host: https://rubygems.org
127
+ homepage_uri: https://github.com/noxdea/dubhe
128
+ source_code_uri: https://github.com/noxdea/dubhe/tree/main
129
+ changelog_uri: https://github.com/noxdea/dubhe/blob/main/CHANGELOG.md
130
+ rubygems_mfa_required: 'true'
131
+ rdoc_options: []
132
+ require_paths:
133
+ - lib
134
+ required_ruby_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: 3.2.0
139
+ required_rubygems_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ requirements: []
145
+ rubygems_version: 4.0.16
146
+ specification_version: 4
147
+ summary: A safe feeds-first document reader core
148
+ test_files: []