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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +58 -0
- data/docs/adr/001-reader-core-boundaries.md +25 -0
- data/docs/supported-elements.md +9 -0
- data/lib/dubhe/fetch.rb +49 -0
- data/lib/dubhe/models.rb +15 -0
- data/lib/dubhe/policy.rb +58 -0
- data/lib/dubhe/reader.rb +85 -0
- data/lib/dubhe/render.rb +59 -0
- data/lib/dubhe/source.rb +13 -0
- data/lib/dubhe/sources/docs.rb +125 -0
- data/lib/dubhe/sources/feeds.rb +206 -0
- data/lib/dubhe/sources/mail.rb +78 -0
- data/lib/dubhe/store.rb +143 -0
- data/lib/dubhe/version.rb +5 -0
- data/lib/dubhe.rb +18 -0
- data/sig/dubhe.rbs +39 -0
- metadata +148 -0
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
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.
|
data/lib/dubhe/fetch.rb
ADDED
|
@@ -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
|
data/lib/dubhe/models.rb
ADDED
|
@@ -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
|
data/lib/dubhe/policy.rb
ADDED
|
@@ -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
|
data/lib/dubhe/reader.rb
ADDED
|
@@ -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
|
data/lib/dubhe/render.rb
ADDED
|
@@ -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
|
data/lib/dubhe/source.rb
ADDED
|
@@ -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
|
data/lib/dubhe/store.rb
ADDED
|
@@ -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
|
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: []
|