tsumanne 0.0.3 → 0.0.4

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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +8 -0
  3. data/.mdlrc +6 -0
  4. data/.rubocop.yml +20 -12
  5. data/Dockerfile +24 -0
  6. data/README.md +0 -4
  7. data/Rakefile +19 -16
  8. data/exe/tsumanne +2 -1
  9. data/lib/tsumanne/api.rb +22 -21
  10. data/lib/tsumanne/cli.rb +153 -2
  11. data/lib/tsumanne/models/get_threads.rb +8 -3
  12. data/lib/tsumanne/models/register_thread.rb +5 -1
  13. data/lib/tsumanne/models/search_indexes.rb +7 -2
  14. data/lib/tsumanne/models/search_thread_from_uri.rb +14 -2
  15. data/lib/tsumanne/version.rb +1 -1
  16. data/mise.toml +42 -0
  17. data/sorbet/rbi/gems/.gitattributes +1 -0
  18. data/sorbet/rbi/gems/{ast@2.4.2.rbi → ast@2.4.3.rbi} +45 -79
  19. data/sorbet/rbi/gems/benchmark@0.5.0.rbi +621 -0
  20. data/sorbet/rbi/gems/{chef-utils@18.2.7.rbi → chef-utils@18.8.54.rbi} +1 -0
  21. data/sorbet/rbi/gems/{concurrent-ruby@1.2.2.rbi → concurrent-ruby@1.3.5.rbi} +1 -0
  22. data/sorbet/rbi/gems/{diff-lcs@1.5.0.rbi → diff-lcs@1.6.2.rbi} +183 -199
  23. data/sorbet/rbi/gems/docile@1.4.0.rbi +147 -147
  24. data/sorbet/rbi/gems/{erubi@1.12.0.rbi → erubi@1.13.1.rbi} +36 -26
  25. data/sorbet/rbi/gems/{json@2.6.3.rbi → json@2.19.4.rbi} +920 -251
  26. data/sorbet/rbi/gems/kramdown-parser-gfm@1.1.0.rbi +32 -63
  27. data/sorbet/rbi/gems/{kramdown@2.4.0.rbi → kramdown@2.5.1.rbi} +896 -711
  28. data/sorbet/rbi/gems/language_server-protocol@3.17.0.5.rbi +9 -0
  29. data/sorbet/rbi/gems/lint_roller@1.1.0.rbi +189 -0
  30. data/sorbet/rbi/gems/logger@1.7.0.rbi +896 -0
  31. data/sorbet/rbi/gems/{mdl@0.12.0.rbi → mdl@0.15.0.rbi} +109 -113
  32. data/sorbet/rbi/gems/mixlib-cli@2.1.8.rbi +35 -41
  33. data/sorbet/rbi/gems/mixlib-config@3.0.27.rbi +147 -142
  34. data/sorbet/rbi/gems/{mixlib-shellout@3.2.7.rbi → mixlib-shellout@3.3.9.rbi} +127 -150
  35. data/sorbet/rbi/gems/netrc@0.11.0.rbi +45 -56
  36. data/sorbet/rbi/gems/optimist@3.2.1.rbi +895 -0
  37. data/sorbet/rbi/gems/parallel@1.28.0.rbi +270 -0
  38. data/sorbet/rbi/gems/parser@3.3.11.1.rbi +5229 -0
  39. data/sorbet/rbi/gems/prism@1.9.0.rbi +42224 -0
  40. data/sorbet/rbi/gems/{racc@1.7.1.rbi → racc@1.8.1.rbi} +48 -44
  41. data/sorbet/rbi/gems/rainbow@3.1.1.rbi +101 -141
  42. data/sorbet/rbi/gems/{rake@13.0.6.rbi → rake@13.4.2.rbi} +867 -761
  43. data/sorbet/rbi/gems/rbi@0.3.10.rbi +5520 -0
  44. data/sorbet/rbi/gems/rbs@4.0.2.rbi +6908 -0
  45. data/sorbet/rbi/gems/regexp_parser@2.12.0.rbi +3398 -0
  46. data/sorbet/rbi/gems/require-hooks@0.4.0.rbi +152 -0
  47. data/sorbet/rbi/gems/{rexml@3.2.6.rbi → rexml@3.4.4.rbi} +1032 -908
  48. data/sorbet/rbi/gems/{rspec-core@3.12.2.rbi → rspec-core@3.13.6.rbi} +2860 -4206
  49. data/sorbet/rbi/gems/rspec-expectations@3.13.5.rbi +6108 -0
  50. data/sorbet/rbi/gems/{rspec-mocks@3.12.6.rbi → rspec-mocks@3.13.7.rbi} +1264 -1787
  51. data/sorbet/rbi/gems/rspec-support@3.13.6.rbi +1274 -0
  52. data/sorbet/rbi/gems/rspec@3.13.2.rbi +15 -0
  53. data/sorbet/rbi/gems/rubocop-ast@1.49.1.rbi +7212 -0
  54. data/sorbet/rbi/gems/rubocop-performance@1.26.1.rbi +3403 -0
  55. data/sorbet/rbi/gems/{rubocop-rake@0.6.0.rbi → rubocop-rake@0.7.1.rbi} +89 -103
  56. data/sorbet/rbi/gems/rubocop-rspec@3.9.0.rbi +7606 -0
  57. data/sorbet/rbi/gems/rubocop-sorbet@0.12.0.rbi +2448 -0
  58. data/sorbet/rbi/gems/rubocop@1.86.1.rbi +62232 -0
  59. data/sorbet/rbi/gems/ruby-progressbar@1.13.0.rbi +326 -655
  60. data/sorbet/rbi/gems/rubydex@0.1.0.beta14.rbi +655 -0
  61. data/sorbet/rbi/gems/simplecov-html@0.12.3.rbi +24 -158
  62. data/sorbet/rbi/gems/simplecov@0.22.0.rbi +450 -606
  63. data/sorbet/rbi/gems/simplecov_json_formatter@0.1.4.rbi +32 -166
  64. data/sorbet/rbi/gems/spoom@1.7.13.rbi +6151 -0
  65. data/sorbet/rbi/gems/{tapioca@0.11.8.rbi → tapioca@0.19.1.rbi} +1609 -1379
  66. data/sorbet/rbi/gems/{thor@1.2.2.rbi → thor@1.5.0.rbi} +1075 -1170
  67. data/sorbet/rbi/gems/tomlrb@2.0.3.rbi +1 -0
  68. data/sorbet/rbi/gems/tsort@0.2.0.rbi +389 -0
  69. data/sorbet/rbi/gems/unicode-display_width@3.2.0.rbi +130 -0
  70. data/sorbet/rbi/gems/unicode-emoji@4.2.0.rbi +332 -0
  71. metadata +65 -62
  72. data/.ruby-version +0 -1
  73. data/sorbet/rbi/gems/base64@0.1.1.rbi +0 -172
  74. data/sorbet/rbi/gems/ffi-compiler@1.0.1.rbi +0 -206
  75. data/sorbet/rbi/gems/ffi@1.15.5.rbi +0 -1994
  76. data/sorbet/rbi/gems/http-parser@1.2.3.rbi +0 -422
  77. data/sorbet/rbi/gems/language_server-protocol@3.17.0.3.rbi +0 -14237
  78. data/sorbet/rbi/gems/mhtml@0.1.6.rbi +0 -353
  79. data/sorbet/rbi/gems/parallel@1.23.0.rbi +0 -273
  80. data/sorbet/rbi/gems/parser@3.2.2.3.rbi +0 -7253
  81. data/sorbet/rbi/gems/prettier_print@1.2.1.rbi +0 -951
  82. data/sorbet/rbi/gems/rbi@0.0.17.rbi +0 -2967
  83. data/sorbet/rbi/gems/regexp_parser@2.8.1.rbi +0 -3749
  84. data/sorbet/rbi/gems/rspec-expectations@3.12.3.rbi +0 -8100
  85. data/sorbet/rbi/gems/rspec-support@3.12.1.rbi +0 -1609
  86. data/sorbet/rbi/gems/rspec@3.12.0.rbi +0 -82
  87. data/sorbet/rbi/gems/rubocop-ast@1.29.0.rbi +0 -7144
  88. data/sorbet/rbi/gems/rubocop-capybara@2.18.0.rbi +0 -827
  89. data/sorbet/rbi/gems/rubocop-factory_bot@2.23.1.rbi +0 -812
  90. data/sorbet/rbi/gems/rubocop-performance@1.19.0.rbi +0 -3157
  91. data/sorbet/rbi/gems/rubocop-rspec@2.23.2.rbi +0 -7691
  92. data/sorbet/rbi/gems/rubocop-sorbet@0.7.3.rbi +0 -1226
  93. data/sorbet/rbi/gems/rubocop@1.56.2.rbi +0 -56541
  94. data/sorbet/rbi/gems/spoom@1.2.3.rbi +0 -3199
  95. data/sorbet/rbi/gems/syntax_tree@6.1.1.rbi +0 -22855
  96. data/sorbet/rbi/gems/unicode-display_width@2.4.2.rbi +0 -65
  97. data/sorbet/rbi/gems/unparser@0.6.8.rbi +0 -4525
  98. data/sorbet/rbi/gems/yard-sorbet@0.8.1.rbi +0 -425
  99. data/sorbet/rbi/gems/yard@0.9.34.rbi +0 -18219
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 354ad61001070464826ad804ca96878fe654058272d07b4b267fc71a6edbb952
4
- data.tar.gz: 9ad780cbc6869459adc4533709237166d1e6c5e750631e0d0f7209810d96cdd6
3
+ metadata.gz: 364315dfc400885784277475e8cfe19af407d5b1d27daee8adeb9bfc942d18cd
4
+ data.tar.gz: 4e4aba0c9982cb4b8c93979abba3a144c7a8eed87a7852d0d07050596200e0ca
5
5
  SHA512:
6
- metadata.gz: 372c56ad00aace2a151d3853eb9554810bd5cab30a1c0b3b7efc2b1f931f31649c8faee9cddabd8f2659bcabceffe1a770de80f096798e559125129d4049a31b
7
- data.tar.gz: 74cd78607206bb737c79527537d135712dcca8f5126297b566a92d6a341e8ad951aeda1a66bc96662670927615f9c1923feccdb8282dbaed91b2466599742a32
6
+ metadata.gz: d3fd6ad688af2126ed9f44cce4a03dd21c0448266326ce338117794bf285967d2d0f8fd671e6e26d2bb8d8d33cd86ff2cb80360d89bcff3873c8881d51a6c554
7
+ data.tar.gz: 9fb34ceda1df2f7f3af6d28b96811ff1326cba98c0d4de947d8221d58e66948237ee9dc76d6968d007c4f69f1958e27273fbcf67fe297f8fa9577f4ad5f34cd1
data/.dockerignore ADDED
@@ -0,0 +1,8 @@
1
+ # `.git` is deliberately kept: the gemspec lists the files to package with `git ls-files`.
2
+ # For the same reason, nothing tracked by git may be excluded here.
3
+ .bundle/
4
+ coverage/
5
+ pkg/
6
+ tmp/
7
+ .rspec_status
8
+ *.gem
data/.mdlrc ADDED
@@ -0,0 +1,6 @@
1
+ # This file is a Mixlib::Config file, not a style file: `all` / `exclude_rule` are
2
+ # only understood inside a style file passed with `style`. Prefix a rule with `~`
3
+ # to exclude it here.
4
+ #
5
+ # MD013 (line length): badge definitions and links do not wrap.
6
+ rules "~MD013"
data/.rubocop.yml CHANGED
@@ -1,14 +1,21 @@
1
- inherit_gem:
2
- rubocop-sorbet: config/rbi.yml
3
-
4
- require:
5
- - rubocop-rake
6
- - rubocop-rspec
7
- - rubocop-performance
1
+ plugins:
2
+ - rubocop-performance
3
+ - rubocop-rake
4
+ - rubocop-rspec
5
+ - rubocop-sorbet
8
6
 
9
7
  AllCops:
10
- TargetRubyVersion: 3.2
8
+ TargetRubyVersion: 3.3
11
9
  NewCops: enable
10
+ Exclude:
11
+ # Generated by `tapioca gem`; linting them only creates churn on regeneration.
12
+ - 'sorbet/rbi/gems/**/*.rbi'
13
+ # Bundler binstub.
14
+ - 'bin/tapioca'
15
+
16
+ # This gem keeps its development dependencies in the gemspec, not the Gemfile.
17
+ Gemspec/DevelopmentDependencies:
18
+ EnforcedStyle: gemspec
12
19
 
13
20
  Style/StringLiterals:
14
21
  Enabled: true
@@ -24,9 +31,10 @@ Layout/LineLength:
24
31
  Metrics/MethodLength:
25
32
  Max: 15
26
33
 
27
- Lint/EmptyFile:
28
- Exclude:
29
- - 'sorbet/rbi/gems/*.rbi'
30
-
31
34
  Gemspec:
32
35
  Enabled: true
36
+
37
+ # `CLI` is a flat dispatcher: one short method per subcommand, which reads best in a single class.
38
+ Metrics/ClassLength:
39
+ Exclude:
40
+ - 'lib/tsumanne/cli.rb'
data/Dockerfile ADDED
@@ -0,0 +1,24 @@
1
+ FROM --platform=$BUILDPLATFORM ruby:4.0@sha256:c400c1e41e8ad2276c82529ff9fd552cdb339a84e761c9d57d2d7f582122fa6e AS builder
2
+
3
+ WORKDIR /src
4
+ COPY . .
5
+ RUN gem build tsumanne.gemspec --output /tmp/tsumanne.gem
6
+
7
+ FROM ruby:4.0-slim@sha256:607bf92fa7ecebb4a0c6654b62cb44c48d94b36b6f5a754611ddbbe3dc5b6135
8
+
9
+ LABEL org.opencontainers.image.title="tsumanne" \
10
+ org.opencontainers.image.description="Unofficial API wrapper and CLI for tsumanne.net" \
11
+ org.opencontainers.image.source="https://github.com/eggplants/tsumanne" \
12
+ org.opencontainers.image.licenses="MIT"
13
+
14
+ RUN --mount=type=bind,from=builder,source=/tmp/tsumanne.gem,target=/tmp/tsumanne.gem \
15
+ gem install /tmp/tsumanne.gem --no-document
16
+
17
+ RUN tsumanne --version
18
+
19
+ RUN useradd --create-home --user-group tsumanne
20
+ USER tsumanne
21
+ WORKDIR /home/tsumanne
22
+
23
+ ENTRYPOINT ["tsumanne"]
24
+ CMD ["--help"]
data/README.md CHANGED
@@ -3,14 +3,10 @@
3
3
  [![Rake]](https://github.com/eggplants/tsumanne/actions/workflows/rake.yml)
4
4
  [![Release Gem]](https://github.com/eggplants/tsumanne/actions/workflows/release.yml)
5
5
  [![Gem Version]](https://badge.fury.io/rb/tsumanne)
6
- [![Maintainability]](https://codeclimate.com/github/eggplants/tsumanne/maintainability)
7
- [![Test Coverage]](https://codeclimate.com/github/eggplants/tsumanne/test_coverage)
8
6
 
9
7
  [Rake]: <https://github.com/eggplants/tsumanne/actions/workflows/rake.yml/badge.svg>
10
8
  [Release Gem]: <https://github.com/eggplants/tsumanne/actions/workflows/release.yml/badge.svg>
11
9
  [Gem Version]: <https://badge.fury.io/rb/tsumanne.svg>
12
- [Maintainability]: <https://api.codeclimate.com/v1/badges/673df4b2c0e15b80f06c/maintainability>
13
- [Test Coverage]: <https://api.codeclimate.com/v1/badges/673df4b2c0e15b80f06c/test_coverage>
14
10
 
15
11
  API Wrapper for tsumanne.net
16
12
 
data/Rakefile CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  # frozen_string_literal: true
4
4
 
5
+ require "English"
6
+
5
7
  require "bundler/gem_tasks"
6
8
  require "rspec/core/rake_task"
7
9
  require "rubocop/rake_task"
@@ -12,48 +14,49 @@ require "rubocop/rake_task"
12
14
  RSpec::Core::RakeTask.new(:spec)
13
15
 
14
16
  RuboCop::RakeTask.new do |task|
15
- task.requires << 'rubocop-sorbet'
17
+ task.requires << "rubocop-sorbet"
16
18
  end
17
19
 
18
20
  namespace :mdl do
19
- desc 'Format markdown files with markdownlint'
21
+ desc "Format markdown files with markdownlint"
20
22
  task :format do
21
- system("bundle exec mdl #{Dir.glob("**/*.md") * ?\s}")
22
- puts "#{$?.success? ? :no : :some} errors found"
23
+ system("bundle exec mdl -c .mdlrc #{Dir.glob("**/*.md") * "\s"}")
24
+ puts "#{$CHILD_STATUS.success? ? :no : :some} errors found"
23
25
  end
24
26
  end
25
27
 
26
28
  namespace :sorbet do
27
- desc 'Type-checking with sorbet'
29
+ desc "Type-checking with sorbet"
28
30
  task :check do
29
31
  system("bundle exec srb tc")
30
32
  end
31
33
 
32
- desc 'Type-checking and auto-fix correctable errors with sorbet'
34
+ desc "Type-checking and auto-fix correctable errors with sorbet"
33
35
  task :autocorrect do
34
36
  system("bundle exec srb tc --autocorrect")
35
37
  end
36
38
  end
37
39
 
38
40
  namespace :tapioca do
39
- desc 'Generate RBI from gems with tapioca'
41
+ desc "Generate RBI from gems with tapioca"
40
42
  task :gen do
41
43
  system("bundle exec tapioca gem --all")
42
44
  end
43
45
 
44
- desc 'Check duplicated definitions exists in shim RBIs with tapioca'
46
+ desc "Check duplicated definitions exists in shim RBIs with tapioca"
45
47
  task :check_shims do
46
48
  system("bundle exec tapioca check-shims")
47
49
  end
48
50
  end
49
51
 
50
52
  task :default do
51
- %i[mdl:format rubocop:autocorrect_all sorbet:autocorrect tapioca:check_shims spec].each do
52
- puts <<~EOS
53
- .。*゚+.*.。  #{padding = ?= * _1.size}  ゚+..。*゚+
54
- .。*゚+.*.。  #{_1}  ゚+..。*゚+
55
- .。*゚+.*.。  #{padding}  ゚+..。*゚+
56
- EOS
57
- Rake::Task[_1].execute
58
- end
53
+ %i[mdl:format rubocop:autocorrect_all sorbet:autocorrect tapioca:check_shims spec].each do |task_name|
54
+ padding = "=" * task_name.size
55
+ puts <<~BANNER
56
+ .。*゚+.*.。  #{padding}  ゚+..。*゚+
57
+ .。*゚+.*.。  #{task_name}  ゚+..。*゚+
58
+ .。*゚+.*.。  #{padding}  ゚+..。*゚+
59
+ BANNER
60
+ Rake::Task[task_name].execute
61
+ end
59
62
  end
data/exe/tsumanne CHANGED
@@ -1,8 +1,9 @@
1
1
  #! /usr/bin/env ruby
2
2
  # typed: strict
3
+ # frozen_string_literal: true
3
4
 
4
5
  require "sorbet-runtime"
5
6
 
6
7
  require_relative "../lib/tsumanne/cli"
7
8
 
8
- Tsumanne::CLI.start(ARGV)
9
+ exit(Tsumanne::CLI.start(ARGV))
data/lib/tsumanne/api.rb CHANGED
@@ -5,9 +5,7 @@
5
5
  require "net/http"
6
6
  require "uri"
7
7
  require "json"
8
- require "zlib"
9
8
 
10
- require "mhtml"
11
9
  require "sorbet-runtime"
12
10
 
13
11
  require_relative "models/get_threads"
@@ -17,10 +15,11 @@ require_relative "models/register_thread"
17
15
 
18
16
  # API module for tsumanne.net includes knowledge as const.
19
17
  module Tsumanne
18
+ # Client for the tsumanne.net endpoints of a single board.
20
19
  class API
21
20
  extend T::Sig
22
21
 
23
- sig{ returns(String) }
22
+ sig { returns(String) }
24
23
  attr_reader :board_id
25
24
 
26
25
  sig { params(board_id: Symbol).void }
@@ -35,25 +34,27 @@ module Tsumanne
35
34
  GetThreadsResponse.from_hash(fetch_json(paths: [index, page.to_s]))
36
35
  end
37
36
 
38
- sig { params(thread_id: String).returns(Mhtml::RootDocument) }
37
+ # Returns the merged HTML export of an archived thread, as served by the site (Shift_JIS encoded).
38
+ #
39
+ # `mht.php` used to answer with JSON pointing at a gzipped MHTML file; it now redirects straight
40
+ # to a single self-contained HTML document, so no MHTML parsing is involved anymore.
41
+ sig { params(thread_id: String).returns(String) }
39
42
  def get_thread_mht(thread_id)
40
- # https://tsumanne.net/si/mht.php?id=129691
41
- res = fetch_json(paths: ["mht.php"], query: { id: thread_id }, method: :get_response)
42
- mht_gz = fetch(paths: [T.let(res["path"], String)])
43
- # https://ksef-3go.hatenadiary.org/entry/20070924/1190563143
44
- # https://docs.ruby-lang.org/ja/latest/method/Zlib=3a=3aInflate/s/new.html
45
- zstream = Zlib::Inflate.new(Zlib::MAX_WBITS + 32)
46
- buf = zstream.inflate(mht_gz)
47
- zstream.finish
48
- zstream.close
49
- Mhtml::RootDocument.new(buf)
43
+ # https://tsumanne.net/si/mht.php?id=129691 -> 302 ./mhttmp/86279902.html
44
+ uri = join_paths(BASE_URL, [@board_id, "mht.php"])
45
+ uri.query = URI.encode_www_form({ id: thread_id })
46
+ res = Net::HTTP.get_response(uri)
47
+ raise Error, "thread #{thread_id} is not archived (HTTP #{res.code})" unless res.is_a?(Net::HTTPRedirection)
48
+
49
+ T.must(Net::HTTP.get(URI.join(uri, res["location"])))
50
50
  end
51
51
 
52
- sig { params(thread_path: String).returns(T.nilable(Mhtml::RootDocument)) }
52
+ sig { params(thread_path: String).returns(T.nilable(String)) }
53
53
  def get_thread_from_path(thread_path)
54
54
  # https://tsumanne.net/si/data/2023/08/30/8883354/
55
55
  match_data = %r{^\d{4}/\d{2}/\d{2}/(?<thread_id>\d+)$}.match(thread_path)
56
56
  return if match_data.nil?
57
+
57
58
  get_thread_mht(T.must(match_data[:thread_id]))
58
59
  end
59
60
 
@@ -78,7 +79,8 @@ module Tsumanne
78
79
  def register_thread(uri, indexes: nil)
79
80
  # post, https://tsumanne.net/si/input.php?format=json&url=...&category=...
80
81
  RegisterThreadResponse.from_hash(
81
- fetch_json(paths: ["input.php?format=json"], query: { url: uri, category: (indexes || []).join(",") }, method: :post)
82
+ fetch_json(paths: ["input.php?format=json"], query: { url: uri, category: (indexes || []).join(",") },
83
+ method: :post)
82
84
  )
83
85
  end
84
86
 
@@ -97,11 +99,10 @@ module Tsumanne
97
99
  when :get
98
100
  uri.query = query
99
101
  Net::HTTP.get(uri)
100
- when :get_response
101
- uri.query = query
102
- Net::HTTP.get_response(uri).body
103
102
  when :post
104
- Net::HTTP.post(uri, query).body
103
+ # Without an explicit form content type the server does not populate `$_POST`,
104
+ # and answers with a PHP warning followed by a "not permitted URL" error.
105
+ Net::HTTP.post(uri, query, "Content-Type" => "application/x-www-form-urlencoded").body
105
106
  end
106
107
  end
107
108
 
@@ -118,7 +119,7 @@ module Tsumanne
118
119
 
119
120
  sig { params(base: String, paths: T::Array[String]).returns(URI::Generic) }
120
121
  def join_paths(base, *paths)
121
- URI.parse(T.must(([base] + paths).reduce { File.join(_1, _2) }))
122
+ URI.parse(T.must(([base] + paths).reduce { |joined, path| File.join(joined, path) }))
122
123
  end
123
124
  end
124
125
  end
data/lib/tsumanne/cli.rb CHANGED
@@ -2,12 +2,163 @@
2
2
 
3
3
  # frozen_string_literal: true
4
4
 
5
+ require "json"
6
+ require "uri"
7
+
8
+ require "optimist"
9
+ require "sorbet-runtime"
10
+
11
+ require_relative "../tsumanne"
12
+
5
13
  module Tsumanne
14
+ # Command line entry point for the `tsumanne` executable.
6
15
  class CLI
7
16
  extend T::Sig
8
- sig { params(args: BasicObject).returns(NilClass) }
17
+
18
+ # Subcommands, in the order `--help` lists them.
19
+ COMMANDS = T.let({
20
+ "threads" => "List the archived threads of a board",
21
+ "thread" => "Print an archived thread, by id or by archive path",
22
+ "search" => "Find the archived thread of a 2chan.net thread URI",
23
+ "indexes" => "List the indexes, or search them by keyword",
24
+ "register" => "Ask the site to archive a 2chan.net thread"
25
+ }.freeze, T::Hash[String, String])
26
+
27
+ # Runs `args` as a command line and returns the exit status the executable should exit with.
28
+ sig { params(args: T::Array[String]).returns(Integer) }
9
29
  def self.start(args)
10
- p args
30
+ new(args).run
31
+ end
32
+
33
+ sig { params(args: T::Array[String]).void }
34
+ def initialize(args)
35
+ @args = T.let(args.dup, T::Array[String])
36
+ # Reassigned to the subcommand parser, so that `--help` educates about the command at hand.
37
+ @parser = T.let(global_parser, Optimist::Parser)
38
+ end
39
+
40
+ sig { returns(Integer) }
41
+ def run
42
+ execute
43
+ 0
44
+ rescue Optimist::HelpNeeded
45
+ @parser.educate($stdout)
46
+ 0
47
+ rescue Optimist::VersionNeeded
48
+ $stdout.puts(@parser.version)
49
+ 0
50
+ rescue Optimist::CommandlineError, URI::InvalidURIError, Error => e
51
+ warn("Error: #{e.message}")
52
+ 1
53
+ end
54
+
55
+ private
56
+
57
+ sig { void }
58
+ def execute
59
+ options = @parser.parse(@args)
60
+ # `stop_on` leaves the command and everything after it in `@args`.
61
+ command = @args.shift
62
+ raise Optimist::HelpNeeded if command.nil?
63
+ raise Error, "unknown command `#{command}`" unless COMMANDS.key?(command)
64
+
65
+ run_command(command, API.new(board_id: options[:board].to_sym))
66
+ end
67
+
68
+ sig { params(command: String, api: API).void }
69
+ def run_command(command, api)
70
+ case command
71
+ when "threads" then threads(api)
72
+ when "thread" then thread(api)
73
+ when "search" then search(api)
74
+ when "indexes" then indexes(api)
75
+ else register(api)
76
+ end
77
+ end
78
+
79
+ sig { params(api: API).void }
80
+ def threads(api)
81
+ options = parse_command("threads [options]") do |parser|
82
+ parser.opt(:index, "Index (category) to list", type: :string, default: "all")
83
+ parser.opt(:page, "Page to list", type: :integer, default: 1)
84
+ end
85
+ print_json(api.get_threads(index: options[:index], page: options[:page]))
86
+ end
87
+
88
+ sig { params(api: API).void }
89
+ def thread(api)
90
+ parse_command("thread [options] <thread id | archive path>")
91
+ target = operand("`thread` needs a thread id or an archive path")
92
+ $stdout.puts(/\A\d+\z/.match?(target) ? api.get_thread_mht(target) : thread_from_path(api, target))
93
+ end
94
+
95
+ sig { params(api: API, target: String).returns(String) }
96
+ def thread_from_path(api, target)
97
+ api.get_thread_from_path(target) ||
98
+ raise(Error, "`#{target}` is not an archive path (expected `YYYY/MM/DD/<thread id>`)")
99
+ end
100
+
101
+ sig { params(api: API).void }
102
+ def search(api)
103
+ parse_command("search [options] <thread uri>")
104
+ print_json(api.search_thread_from_uri(URI.parse(operand("`search` needs a 2chan.net thread URI"))))
105
+ end
106
+
107
+ sig { params(api: API).void }
108
+ def indexes(api)
109
+ options = parse_command("indexes [options]") do |parser|
110
+ parser.opt(:keyword, "Keyword to search the indexes for", type: :string)
111
+ parser.opt(:order, "Order to list the indexes in",
112
+ type: :string, default: "newer", permitted: INDEXES_ORDERS.keys.map(&:to_s))
113
+ parser.opt(:page, "Page to list", type: :integer, default: 1)
114
+ end
115
+ print_json(api.search_indexes(keyword: options[:keyword], order: options[:order].to_sym, page: options[:page]))
116
+ end
117
+
118
+ sig { params(api: API).void }
119
+ def register(api)
120
+ options = parse_command("register [options] <thread uri>") do |parser|
121
+ parser.opt(:index, "Index (category) to file the thread under, repeatable", type: :string, multi: true)
122
+ end
123
+ uri = URI.parse(operand("`register` needs a 2chan.net thread URI"))
124
+ print_json(api.register_thread(uri, indexes: options[:index]))
125
+ end
126
+
127
+ sig { returns(Optimist::Parser) }
128
+ def global_parser
129
+ parser = Optimist::Parser.new
130
+ parser.usage("[global options] <command> [options] [arguments]")
131
+ parser.version("tsumanne #{VERSION}")
132
+ parser.opt(:board, "Board to talk to",
133
+ type: :string, default: "img", permitted: BOARD_IDS.keys.map(&:to_s))
134
+ # Declared here so that `--help` lists them above the command list, instead of below it.
135
+ parser.opt(:version, "Print version and exit")
136
+ parser.opt(:help, "Show this message")
137
+ parser.stop_on(COMMANDS.keys)
138
+ parser.text("")
139
+ parser.text("Commands:")
140
+ COMMANDS.each { |name, description| parser.text(format(" %-8<name>s %<description>s", name:, description:)) }
141
+ parser
142
+ end
143
+
144
+ # Parses the options of the command at hand, leaving its operands in `@args`.
145
+ sig { params(usage: String, blk: T.nilable(T.proc.params(parser: Optimist::Parser).void)).returns(T.untyped) }
146
+ def parse_command(usage, &blk)
147
+ parser = Optimist::Parser.new
148
+ parser.usage("[global options] #{usage}")
149
+ blk&.call(parser)
150
+ @parser = parser
151
+ parser.parse(@args)
152
+ end
153
+
154
+ sig { params(message: String).returns(String) }
155
+ def operand(message)
156
+ @args.shift || raise(Error, message)
157
+ end
158
+
159
+ sig { params(response: T::Struct).void }
160
+ def print_json(response)
161
+ $stdout.puts(JSON.generate(response.serialize))
11
162
  end
12
163
  end
13
164
  end
@@ -1,10 +1,13 @@
1
1
  # typed: strict
2
+ # frozen_string_literal: true
2
3
 
3
4
  require "sorbet-runtime"
4
5
 
6
+ # Response of `GET /<board_id>/<index>/<page>`, a page of archived threads.
5
7
  class GetThreadsResponse < T::Struct
6
8
  extend T::Sig
7
9
 
10
+ # A single archived thread listed on the page.
8
11
  class Log < T::Struct
9
12
  extend T::Sig
10
13
 
@@ -14,11 +17,13 @@ class GetThreadsResponse < T::Struct
14
17
  const :close, T::Boolean
15
18
  const :res, Integer
16
19
  const :files, Integer
17
- const :access, Integer
18
- const :public, T::Boolean
20
+ # Only some boards report this one.
21
+ const :access, T.nilable(Integer)
19
22
  const :text, String
20
23
  const :thumb, String
21
- const :del, Integer
24
+ # `public` and `del` are no longer part of the response; kept for older archives.
25
+ const :public, T.nilable(T::Boolean)
26
+ const :del, T.nilable(Integer)
22
27
  const :atid, T::Boolean
23
28
  const :last, String # Time
24
29
  const :path, String
@@ -1,10 +1,13 @@
1
1
  # typed: strict
2
+ # frozen_string_literal: true
2
3
 
3
4
  require "sorbet-runtime"
4
5
 
6
+ # Response of `POST /<board_id>/input.php`, the thread registration endpoint.
5
7
  class RegisterThreadResponse < T::Struct
6
8
  extend T::Sig
7
9
 
10
+ # Result of attaching the requested categories to the registered thread.
8
11
  class Tags < T::Struct
9
12
  extend T::Sig
10
13
 
@@ -14,5 +17,6 @@ class RegisterThreadResponse < T::Struct
14
17
 
15
18
  const :success, T::Boolean
16
19
  const :messages, T::Array[String]
17
- const :tags, Tags
20
+ # Only returned when the request actually attached categories.
21
+ const :tags, T.nilable(Tags)
18
22
  end
@@ -1,10 +1,13 @@
1
1
  # typed: strict
2
+ # frozen_string_literal: true
2
3
 
3
4
  require "sorbet-runtime"
4
5
 
6
+ # Response of `GET /<board_id>/indexes.php`, a keyword search over the indexes.
5
7
  class SearchIndexesResponse < T::Struct
6
8
  extend T::Sig
7
9
 
10
+ # A single index (category) tag matched by the search.
8
11
  class Tag < T::Struct
9
12
  extend T::Sig
10
13
 
@@ -12,7 +15,9 @@ class SearchIndexesResponse < T::Struct
12
15
  end
13
16
 
14
17
  const :success, T::Boolean
15
- const :messages, T::Array[String]
16
- const :count, Integer
18
+ const :messages, T.nilable(T::Array[String])
19
+ # `null` when listing every index instead of searching for one.
20
+ const :count, T.nilable(Integer)
21
+ const :lastpage, Integer
17
22
  const :tags, T::Array[Tag]
18
23
  end
@@ -1,11 +1,23 @@
1
1
  # typed: strict
2
+ # frozen_string_literal: true
2
3
 
3
4
  require "sorbet-runtime"
4
5
 
6
+ # Response of `GET /<board_id>/indexes.php?sbmt=URL`, a lookup of archived threads by their source URI.
5
7
  class SearchThreadFromUriResponse < T::Struct
6
8
  extend T::Sig
7
9
 
10
+ # An archived thread whose source URI matched the query.
11
+ class Log < T::Struct
12
+ extend T::Sig
13
+
14
+ const :id, Integer
15
+ const :url, String # URI
16
+ const :path, String
17
+ const :category, T::Array[String]
18
+ end
19
+
8
20
  const :success, T::Boolean
9
- const :messages, T::Array[String]
10
- const :path, String
21
+ const :logs, T::Array[Log]
22
+ const :messages, T.nilable(T::Array[String])
11
23
  end
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  module Tsumanne
6
- VERSION = "0.0.3"
6
+ VERSION = "0.0.4"
7
7
  end
data/mise.toml ADDED
@@ -0,0 +1,42 @@
1
+ [tools]
2
+ "aqua:azu/dockerfile-pin" = "latest"
3
+ claude = "latest"
4
+ pinact = "latest"
5
+ ruby = "latest"
6
+
7
+ [tasks.install]
8
+ description = "Install gem dependencies"
9
+ run = "bundle install"
10
+
11
+ [tasks.lint]
12
+ description = "Run all linters and the type checker (no auto-fix)"
13
+ depends = ["install"]
14
+ run = [
15
+ "bundle exec rubocop",
16
+ "bundle exec mdl -c .mdlrc $(git ls-files '*.md')",
17
+ "bundle exec srb tc",
18
+ "bundle exec tapioca check-shims",
19
+ ]
20
+
21
+ [tasks.test]
22
+ description = "Run the RSpec suite"
23
+ depends = ["install"]
24
+ run = "bundle exec rspec"
25
+
26
+ [tasks."lint:fix"]
27
+ description = "Auto-fix what the linters and the type checker can fix"
28
+ depends = ["install"]
29
+ run = [
30
+ "bundle exec rubocop --autocorrect-all",
31
+ "bundle exec srb tc --autocorrect",
32
+ ]
33
+
34
+ [tasks.pre-commit]
35
+ description = "Auto-fix, then verify everything before committing"
36
+ depends = ["lint:fix"]
37
+ run = ["mise run lint", "mise run test"]
38
+
39
+ [tasks.release]
40
+ description = "Build and push the gem to RubyGems"
41
+ depends = ["install"]
42
+ run = "bundle exec rake release"
@@ -0,0 +1 @@
1
+ **/*.rbi linguist-generated=true