natsukantou 0.2.2 → 0.2.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eeacff02b528faa718685f0538b9e81d953874f411a0966c3ab4340d9a0a0aee
4
- data.tar.gz: dd2b7056ab77874e2783f8e6f7f4e6691a5dfe37022ebc4f274461fa912c5536
3
+ metadata.gz: cad5c6f1f3972aa1ea7f4d793ff42bd3f08b96b292a3475ebc8be378d68e3e8b
4
+ data.tar.gz: cbcdce19fe10dcb3e6658959c9be61363a3ab30aa3096465c41a069811fb2cee
5
5
  SHA512:
6
- metadata.gz: ad4f341ac35371d2f63850c7d2f327770e25ba5e1b6af1e8b9bd2acf8bc0cd3c85db52b9bbf1fa7b4742b22df44d7351a16ebccd74f5dd87649f053932c4fa69
7
- data.tar.gz: 86c26ea7575ff81d8c1d57baa37c9f5b84d9b8ef4a0d7a91db581cc76c0ca710347644b5ebe8a18419477400d6360554744c36760f638c7e715aba6eca95abc4
6
+ metadata.gz: 04e7580ca7090cd615ce9e51432c97b7159824394dbc0cedea74c4426426b7e78101d97f121fda63d02ad023907df6e2c3cbfab9349b08789f3dc4346eecd50f
7
+ data.tar.gz: 2cd1b3c1d8456a8c586baddf78711f6a3e52a8c8e3cafe16eadbb49bf0306ca5af2fd47378aec514aab6aea2f6ae046b9a58f323cd5f31f3564658baee74e7c8
@@ -0,0 +1,84 @@
1
+ ---
2
+ name: add-translator-backend
3
+ description: Use when adding a new translation service backend (e.g. "add Google Translate", "add Claude as a translator") or a new middleware filter to Natsukantou. Covers the middleware contract, the lang-attribute invariant, wizard registration, gemspec, and the required specs.
4
+ ---
5
+
6
+ # Add a translator backend (or middleware filter)
7
+
8
+ A backend is just a middleware class; there is no base class to inherit. Full annotated
9
+ example (class + spec): [example-backend.md](example-backend.md). Closest real models:
10
+ `lib/natsukantou/open_ai.rb` (chunked translation), `lib/natsukantou/deep_l.rb`
11
+ (whole-document translation).
12
+
13
+ ## Procedure
14
+
15
+ 1. **Create `lib/natsukantou/<snake_name>.rb`** with class `Natsukantou::<CamelName>`:
16
+ - `include UtilityBase` (gives `dom`, `dom_node`, `logger`).
17
+ - `initialize(app, **keyword_args)` — `app` is the only positional arg. Store it as
18
+ `@app`. Every other arg must be a keyword arg.
19
+ - **Doc-comment rules are load-bearing** — the setup wizard YARD-parses this file at
20
+ runtime. Follow `.claude/skills/wizard-config-conventions/SKILL.md` (short version:
21
+ `@param` tag for every kwarg except `app`; string defaults in double quotes).
22
+ - `call(env)` must, in order:
23
+ a. Translate `env.dom.to_xml` whole, **or** if you support `translate_by_section:`
24
+ (a CSS selector), loop `env[:dom].css(selector)` skipping nodes with empty
25
+ `.text`, replacing each via `node.replace(dom_node(translated_xml))`.
26
+ b. Set `env[:dom].lang = env[:lang_to].code` — required invariant, spec-enforced.
27
+ c. End with `@app.call(env)`.
28
+ - Language codes: `env.lang_from.code` is lowercase `xx` or `xx-yy`; re-case for your
29
+ API if needed (DeepL upcases). Compare with `env[:lang_to].is?('zh-TW')` (asymmetric:
30
+ `en-gb`.is?(`en`) → true, reverse → false).
31
+ - Parse API responses with `dom(...)`/`dom_node(...)`, never `Oga.parse_xml`.
32
+ - Keep to the Ruby floor in the gemspec's `required_ruby_version`; `.gitlab-ci.yml`
33
+ lists the versions CI builds.
34
+
35
+ 2. **Register it** in `lib/natsukantou.rb` under the `# Translators` (or `# Middlewares`)
36
+ section:
37
+ ```ruby
38
+ autoload_and_register :translator, :CamelName, "natsukantou/snake_name"
39
+ ```
40
+
41
+ 3. **Add the API client gem** (if any) to `natsukantou.gemspec` as
42
+ `spec.add_dependency 'gem-name', '~> x.y'` with a comment naming the service
43
+ (match the existing `# DeepL` / `# Minhon` style), then `bundle install`.
44
+
45
+ 4. **Write the unit spec** `spec/natsukantou/<snake_name>_spec.rb` — see
46
+ [example-backend.md](example-backend.md). Minimum: `:middleware` metadata, stub the
47
+ private `translate` method (or the client), and `include_examples 'lang attribute'`
48
+ (this is what enforces invariant 1b). No network, no secrets, no VCR needed.
49
+
50
+ 5. **README.md**: add the service to the `### Translators` (or `### Middlewares`) list.
51
+
52
+ 6. Do NOT touch `CHANGELOG.md` / `version.rb` unless asked to cut a release.
53
+
54
+ ## Verify
55
+
56
+ ```sh
57
+ bundle exec rspec spec/natsukantou/<snake_name>_spec.rb # all green
58
+ bundle exec rspec # no failures beyond the baseline you saw before starting
59
+ bundle exec rubocop lib/natsukantou/<snake_name>.rb spec/natsukantou/<snake_name>_spec.rb
60
+ ```
61
+
62
+ Then confirm the wizard can parse your params (should print each kwarg with its default,
63
+ no exception):
64
+
65
+ ```sh
66
+ bundle exec ruby -Ilib -e '
67
+ require "natsukantou"
68
+ require "natsukantou/setup/config_prompt"
69
+ comp = Natsukantou::Setup::Component.new(:CamelName, "lib/natsukantou/snake_name.rb")
70
+ comp.initialize_method_param_tags.each do |t|
71
+ d = t.default_value_in_string
72
+ p [t.name, (d && d != "nil") ? t.default_value_in_tty : d]
73
+ end
74
+ '
75
+ ```
76
+
77
+ ## Gotchas
78
+
79
+ - Copying `open_ai.rb` as a template? Don't copy its trailing `ChatGPT = OpenAi` alias
80
+ line — the `:ChatGpt` registry entry does not match it.
81
+ - A `Hash`-typed param (like `request_params`) is fine — the wizard treats it as
82
+ raw-Ruby input automatically.
83
+ - If the service returns invalid XML on large documents (Minhon does), offer
84
+ `translate_by_section:` instead of trying to repair output.
@@ -0,0 +1,137 @@
1
+ # Reference: a complete backend + unit spec
2
+
3
+ Modeled on `lib/natsukantou/open_ai.rb` and `spec/natsukantou/open_ai_spec.rb`.
4
+ Replace `Acme` / `acme` throughout.
5
+
6
+ ## `lib/natsukantou/acme.rb`
7
+
8
+ ```ruby
9
+ # frozen_string_literal: true
10
+
11
+ require 'acme-client-gem' # the API client added to natsukantou.gemspec
12
+
13
+ # Machine translator
14
+ #
15
+ # Acme Translate
16
+ # https://acme.example.com
17
+ module Natsukantou
18
+ class Acme
19
+ include UtilityBase
20
+
21
+ # @param app [Hash]
22
+ # @param api_key [String] API key for Acme Translate
23
+ # @param host [String] URL of API endpoint
24
+ # @param request_params [Hash] other optional request parameters
25
+ #
26
+ # @param translate_by_section [String] CSS selector to translate matched elements
27
+ # one by one, e.g. "body>p", for services that choke on large documents.
28
+ def initialize(
29
+ app, api_key:,
30
+ host: "https://api.acme.example.com", # String defaults MUST be double-quoted
31
+ request_params: {},
32
+ translate_by_section: nil # nil default => optional in the wizard
33
+ )
34
+ @app = app
35
+ @api_key = api_key
36
+ @host = host
37
+ @request_params = request_params
38
+
39
+ ### Non request related setting
40
+ @translate_by_section = translate_by_section
41
+ end
42
+
43
+ attr_reader :env
44
+ attr_reader :api_key, :host, :request_params
45
+ attr_reader :translate_by_section
46
+
47
+ def call(env)
48
+ @env = env
49
+
50
+ if translate_by_section
51
+ env[:dom].css(translate_by_section).each do |node|
52
+ next if node.text.empty?
53
+
54
+ translated_xml = translate(node.to_xml)
55
+ node.replace(dom_node(translated_xml)) if translated_xml
56
+ end
57
+ else
58
+ translated_xml = translate(env[:dom].to_xml)
59
+ env[:dom] = dom(translated_xml)
60
+ end
61
+
62
+ # Required invariant — enforced by the 'lang attribute' shared example.
63
+ env[:dom].lang = env[:lang_to].code
64
+
65
+ @app.call(env)
66
+ end
67
+
68
+ private
69
+
70
+ # Keep the network call isolated in one private method named `translate`,
71
+ # so the unit spec can stub it (the existing specs rely on this shape).
72
+ def translate(text)
73
+ # env.lang_from.code / env.lang_to.code are lowercase "xx" or "xx-yy";
74
+ # upcase or map here if the API needs it.
75
+ client.translate(
76
+ text,
77
+ from: env.lang_from.code,
78
+ to: env.lang_to.code,
79
+ **request_params,
80
+ )
81
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
82
+ logger.error(e.message)
83
+ nil
84
+ end
85
+
86
+ def client
87
+ @client ||= ::AcmeClient.new(api_key: api_key, host: host)
88
+ end
89
+ end
90
+ end
91
+ ```
92
+
93
+ Registration line for `lib/natsukantou.rb` (under `# Translators`):
94
+
95
+ ```ruby
96
+ autoload_and_register :translator, :Acme, "natsukantou/acme"
97
+ ```
98
+
99
+ ## `spec/natsukantou/acme_spec.rb`
100
+
101
+ ```ruby
102
+ # frozen_string_literal: true
103
+
104
+ require 'rspec'
105
+
106
+ RSpec.describe Natsukantou::Acme, :middleware do
107
+ let(:dom) { Oga.parse_xml('<foo>surface</foo>') }
108
+ let(:app) { ->(env) { env } }
109
+
110
+ subject do
111
+ described_class.new(
112
+ app,
113
+ api_key: 'api_key',
114
+ )
115
+ end
116
+
117
+ context 'lang attribute' do
118
+ before do
119
+ allow(subject).to receive(:translate).and_return(html)
120
+ end
121
+
122
+ include_examples 'lang attribute'
123
+ end
124
+ end
125
+ ```
126
+
127
+ Notes on the spec machinery:
128
+
129
+ - `:middleware` metadata pulls in `spec/support/middleware_context.rb`
130
+ (default env `en → ja`, `expect_xml_to_eq` helper). The `let(:app)` /
131
+ `subject` above override its defaults where needed.
132
+ - `include_examples 'lang attribute'` comes from `spec/support/lang_attribute.rb`;
133
+ it defines `html` and asserts the `lang` attribute lands on the `<html>` node.
134
+ It requires the translation itself to be stubbed — hence the `allow(...)`.
135
+ - `RSpec.describe`, never bare `describe` — `disable_monkey_patching!` is on.
136
+ - No VCR/request spec is required for a new backend — the unit spec suffices; if you add
137
+ one, follow `.claude/skills/run-and-record-specs/SKILL.md`.
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: run-and-record-specs
3
+ description: Use when running the test suite, when a spec fails with VCR::Errors::UnhandledHTTPRequestError, when adding or re-recording a VCR cassette, or when specs complain about secrets. Covers VCR replay, re-recording, and the secret-handling rules.
4
+ ---
5
+
6
+ # Run the specs / work with VCR cassettes
7
+
8
+ ## Running
9
+
10
+ ```sh
11
+ bundle exec rspec
12
+ bundle exec rspec <files touched>
13
+ bundle exec rake # spec + rubocop, same as GitLab CI
14
+ ```
15
+
16
+ If `VCR::Errors::UnhandledHTTPRequestError` is raised,
17
+ do not loosen VCR matchers globally or delete the cassette.
18
+ Re-record it (below), and only when the user asks.
19
+
20
+ Rubocop: run `bundle exec rubocop <files touched>` rather than chasing the full-repo output.
21
+
22
+ ## Spec setup (spec/spec_helper.rb)
23
+
24
+ - Unit specs (`spec/natsukantou/`) stub all network — no secrets or cassettes needed.
25
+ - Request specs (`spec/requests/`) tag `:vcr`; the cassette path is derived from the
26
+ describe/context strings, e.g. `RSpec.describe 'DeepL'` + `it 'translates'` →
27
+ `spec/cassettes/DeepL/translates.yml`. Renaming an example orphans its cassette.
28
+ - Record mode is `:once`: cassette exists → replay only; missing → record (needs real
29
+ creds). webmock blocks all other real HTTP.
30
+ - Secrets: specs read the `SECRET` hash from `spec/secret.rb` (gitignored, real keys).
31
+ If absent, `spec/secret.example.rb` (dummy values) is loaded — replay still works
32
+ because requests are matched on method+URI, not credentials.
33
+ - Every value in `SECRET` is scrubbed from recorded cassettes via
34
+ `filter_sensitive_data` → placeholders like `<key>`, `<secret>`, `<url>`. Caveat:
35
+ `minhon` and `deepl` both use the key name `key`, so both values map to the same
36
+ `<key>` placeholder — fine for replay of existing specs, but rename the key in
37
+ `SECRET` if you add a section whose overlap would be ambiguous.
38
+
39
+ ## Recording cassette
40
+
41
+ 1. Ensure `spec/secret.rb` exists (based from `spec/secret.example.rb`) with real credentials. Ask the user for keys — never invent them).
42
+ 2. When re-recording, delete only the target cassette
43
+ 3. Run just that spec example, which performs a real, billable API call.
44
+ 4. **Scrub check** — the real secret must not appear in the new cassette, e.g.:
45
+ ```sh
46
+ grep -rF "$(ruby -e 'require_relative "spec/secret"; print SECRET.dig(:deepl, :key)')" spec/cassettes/ && echo LEAKED || echo clean
47
+ ```
48
+ 5. Re-run the spec to confirm green replay, then commit the cassette YAML only.
49
+
50
+ Never commit or print `spec/secret.rb`; never hardcode credentials in specs — always go through `SECRET.dig(...)`.
51
+
52
+ ## Verify
53
+
54
+ `bundle exec rspec` → nothing failing that was not already failing before you started.
55
+ A new failure is your change.
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: wizard-config-conventions
3
+ description: Use before editing the `initialize` method or its doc comment on any class under lib/natsukantou/ (translators/middlewares), and when authoring or editing a translator_config.rb. The setup wizard YARD-parses these doc comments at runtime — a wrong quote style or missing @param silently breaks the wizard and no test catches it.
4
+ ---
5
+
6
+ # Wizard schema rules (YARD doc comments are executable)
7
+
8
+ `Setup::ConfigPrompt` (`lib/natsukantou/setup/config_prompt.rb`) builds the interactive
9
+ setup wizard by YARD-parsing each registered component's **source file** and reading the
10
+ `@param` tags on `initialize`. The file path comes from `autoload_and_register` in
11
+ `lib/natsukantou.rb`. Consequences when touching any component's `initialize`:
12
+
13
+ - Every keyword argument except `app` needs a `@param name [Type] description` tag.
14
+ The description becomes the on-screen prompt text ("Enter 'auth_key', <description>").
15
+ - **String defaults must be double-quoted in the method signature**, e.g.
16
+ `host: "https://..."`. The wizard reads the raw source and `JSON.parse`s it
17
+ (`config_prompt.rb` `default_value_in_tty`) — a single-quoted default raises at
18
+ wizard runtime. RuboCop won't flag it (`Style/StringLiterals` is disabled).
19
+ - Default handling: no default → required answer; default `nil` → optional/skippable;
20
+ any other default → pre-filled, and unchanged answers are omitted from the generated
21
+ config (the code default applies).
22
+ - Types drive input handling: `Integer`/`Float` → converted; `Pathname` → file path
23
+ with existence validation; `Hash` → entered as a raw Ruby expression. To force
24
+ raw-Ruby entry for other params, list them in a `@fields_to_input_ruby name1 name2`
25
+ tag on `initialize` (custom tag defined in `config_prompt.rb`; `substitude_glossary.rb`
26
+ uses it). Raw-Ruby params are hidden from users who answer no to "Do you know Ruby?".
27
+ - Renaming a class or file requires updating its `autoload_and_register` line, or the
28
+ wizard crashes (`Component#klass` is nil).
29
+
30
+ ## translator_config.rb shape
31
+
32
+ Generated by `lib/natsukantou/setup/output.erb`; loaded by
33
+ `Setup::ConfigLoadOrPrompt` via plain Ruby `load`, then checked with
34
+ `defined?(NatsukantouTranslator)`. Real example: `spec/fixtures/translator_config.rb`.
35
+
36
+ ```ruby
37
+ ::NatsukantouTranslator = Middleware::Builder.new do
38
+ use Natsukantou::HandleRubyMarkup
39
+ use Natsukantou::SubstitudeGlossary, filepath: "glossary.tsv" # filters first
40
+
41
+ use Natsukantou::DeepL, auth_key: "..." # translator LAST
42
+ end
43
+ ```
44
+
45
+ Rules: assign to the top-level constant `::NatsukantouTranslator`; middleware filters
46
+ before the translator; kwargs only. Used as `natsukantou -c CONFIG_FILE XML_FILE`. The
47
+ file is gitignored at repo root (it contains API keys) — never commit one outside
48
+ `spec/fixtures/`, and never put real keys in the fixture.
49
+
50
+ ## Verify
51
+
52
+ After editing any `initialize` signature/doc comment, confirm the wizard can still parse
53
+ it (prints each kwarg + default, no exception) — substitute your class and path:
54
+
55
+ ```sh
56
+ bundle exec ruby -Ilib -e '
57
+ require "natsukantou"
58
+ require "natsukantou/setup/config_prompt"
59
+ comp = Natsukantou::Setup::Component.new(:DeepL, "lib/natsukantou/deep_l.rb")
60
+ comp.initialize_method_param_tags.each do |t|
61
+ d = t.default_value_in_string
62
+ p [t.name, (d && d != "nil") ? t.default_value_in_tty : d]
63
+ end
64
+ '
65
+ ```
66
+
67
+ A single-quoted string default fails this with "Please specify keyword argument default
68
+ value in double quotes"; a missing registration or renamed class fails with
69
+ `NoMethodError` on nil.
data/.rubocop.yml CHANGED
@@ -1,3 +1,7 @@
1
+ inherit_mode:
2
+ merge:
3
+ - Exclude
4
+
1
5
  AllCops:
2
6
  TargetRubyVersion: 2.7
3
7
  Exclude:
@@ -60,11 +64,14 @@ Style/IfUnlessModifier:
60
64
  Style/Documentation:
61
65
  Enabled: false
62
66
 
67
+ Style/KeywordParametersOrder:
68
+ Enabled: false
69
+
63
70
  Layout/ArgumentAlignment:
64
71
  EnforcedStyle: with_fixed_indentation
65
72
 
66
73
  Layout/LineLength:
67
- Max: 120
74
+ Enabled: false
68
75
 
69
76
  Layout/MultilineMethodCallIndentation:
70
77
  EnforcedStyle: indented
data/CHANGELOG.md CHANGED
@@ -29,3 +29,14 @@
29
29
  ## [0.2.2] - 2023-10-10
30
30
 
31
31
  - Fix ChatGPT not able to distinguish content to be translated and our instructions.
32
+
33
+ ## [0.2.3] - 2026-09-08
34
+
35
+ - Upgrade to deepl-rb 3.0 (maintainership change only, no migration needed)
36
+ - Switch to ibsciss-middleware fork, fixing Ruby 3 keyword-argument errors
37
+ - ChatGPT: configurable `model` and `temperature` (defaults `gpt-3.5-turbo`, `0.5`)
38
+ - ChatGPT: report API and timeout errors and leave the node untranslated instead of aborting the run
39
+ - ChatGPT: raise request timeout to 500s for slow single-paragraph translations
40
+ - Accept extended language subtags such as `zh-hant`
41
+ - Setup wizard: validate language codes, verify config and glossary files exist,
42
+ support parameters with multiple declared types, and cast Integer/Float inputs correctly
data/CLAUDE.md ADDED
@@ -0,0 +1,67 @@
1
+ # Natsukantou — development guide
2
+
3
+ Ruby gem that translates XML/EPUB documents between human languages via pluggable
4
+ backends (DeepL, TexTra/Minhon, OpenAI) and middleware filters.
5
+
6
+ ## Architecture
7
+
8
+ - **Everything is a Rack-style middleware.** There is no Translator base class. A
9
+ component (backend or filter) is a class with `initialize(app, **keyword_args)` and
10
+ `call(env)` that ends with `@app.call(env)`. Backends mix in `UtilityBase`
11
+ (`lib/natsukantou/utility/utility_base.rb`) for `dom`/`dom_node`/`logger`.
12
+ - **`env`** is `Natsukantou::Env` (`lib/natsukantou/utility/env.rb`): a Hash delegator
13
+ requiring `:dom` (Oga document), `:lang_from`, `:lang_to` (coerced to `LanguageCode`).
14
+ - **Pipeline** = user config file: `::NatsukantouTranslator = Middleware::Builder.new do
15
+ use ... end` — filters first, translator last. `exe/natsukantou` parses the XML, runs
16
+ the stack, writes `file.<lang_to>.ext`.
17
+ - **Registration**: `lib/natsukantou.rb` `autoload_and_register :translator|:middleware,
18
+ :Constant, "path"`. This also records the source path so the setup wizard can
19
+ **YARD-parse the file at runtime** — doc comments on `initialize` are executable
20
+ schema, not documentation. See `.claude/skills/wizard-config-conventions/SKILL.md`.
21
+ - `lib/monkey_patch/oga_document.rb` adds `lang`/`lang=` to Oga elements/documents,
22
+ `Document#interlace` (bilingual output), and a UTF-8 serialization fix.
23
+
24
+ ## Load-bearing conventions
25
+
26
+ - `SubstitudeGlossary` — the misspelling ("substitude") is intentional public API
27
+ (README, registry, config files). Never rename or "fix" it.
28
+ - A translator's `call` MUST set `env[:dom].lang = env[:lang_to].code` after
29
+ translating — enforced by the shared example in `spec/support/lang_attribute.rb`.
30
+ - Parse XML with `dom(xml)` / `dom_node(xml)` from `UtilityBase`, never
31
+ `Oga.parse_xml` directly (they do strict parse → lenient fallback with logging).
32
+ - `<skip>` tags mark already-translated content: `SubstitudeGlossary` emits them,
33
+ DeepL is configured with `ignore_tags: 'skip'` (`lib/natsukantou/deep_l.rb`).
34
+ - `LanguageCode#is?` is asymmetric: `en-gb` is `en`, but `en` is not `en-gb`.
35
+ - `lib/` must stay compatible with the oldest Ruby the gemspec's
36
+ `required_ruby_version` allows and `.gitlab-ci.yml` builds. Read both rather than
37
+ assuming; local dev runs a newer Ruby than either.
38
+
39
+ ## Commands
40
+
41
+ - `bundle exec rspec` — run it before you change anything so you know the baseline; a
42
+ failure that was not there before is yours. Pre-existing failures are usually stale
43
+ VCR cassettes — see `.claude/skills/run-and-record-specs/SKILL.md`.
44
+ - `bundle exec rubocop <files-you-touched>` — the full run has pre-existing offenses;
45
+ only keep your own files clean.
46
+ - `bundle exec rake` — spec + rubocop; what GitLab CI runs (`.gitlab-ci.yml`).
47
+ - Release: bump `lib/natsukantou/version.rb`, append a CHANGELOG.md entry **at the
48
+ bottom** (this file is ordered oldest→newest), then `bundle exec rake release`
49
+ (git tag + irreversible push to rubygems.org — requires maintainer confirmation).
50
+
51
+ ## Known issues — do not trip on these
52
+
53
+ This section records the state at the time of writing and goes stale fastest; confirm
54
+ an item still holds before relying on it.
55
+
56
+ - `Natsukantou::ChatGpt` does not resolve: `lib/natsukantou.rb` registers `:ChatGpt`
57
+ while `open_ai.rb` names the alias `ChatGPT`. Use `Natsukantou::OpenAi` instead.
58
+ - The DeepL cassette (`spec/cassettes/DeepL/translates.yml`) predates deepl-rb 3.x's
59
+ request format; re-recording requires a real API key.
60
+ - `spec/secret.rb` (gitignored) holds real credentials — never commit or print it.
61
+
62
+ ## Skills
63
+
64
+ - `.claude/skills/add-translator-backend/` — add a new backend or middleware filter.
65
+ - `.claude/skills/run-and-record-specs/` — run the suite, handle VCR/secrets, re-record.
66
+ - `.claude/skills/wizard-config-conventions/` — edit `initialize` signatures/doc
67
+ comments or author a `translator_config.rb`.
data/exe/natsukantou CHANGED
@@ -40,8 +40,17 @@ Natsukantou::Setup::ConfigLoadOrPrompt.new.execute(
40
40
  config_path: options[:config_path],
41
41
  )
42
42
 
43
- lang_from = prompt.ask("Language code to translate from?")
44
- lang_to = prompt.ask("Language code to translate to?")
43
+ def prompt.ask_lang(target)
44
+ ask("Language code to translate #{target}?") do |q|
45
+ q.validate(
46
+ /\A([[:alpha:]]{2}|[[:alpha:]]{2}-[[:alpha:]]*)\z/,
47
+ "Language code should be in the form of xx or xx-yy"
48
+ )
49
+ end
50
+ end
51
+
52
+ lang_from = prompt.ask_lang('from')
53
+ lang_to = prompt.ask_lang('to')
45
54
 
46
55
  env = Natsukantou::Env.new(
47
56
  dom: dom, lang_from: lang_from, lang_to: lang_to
@@ -18,15 +18,21 @@ module Natsukantou
18
18
 
19
19
  # @param app [Hash]
20
20
  # @param access_token [String] API access token
21
+ # @param model [String] LLM model name
22
+ # @param temperature [Float] Between 0 and 1. Higher temperatures introduce randomness, which is beneficial for creative writing. In contrast, a temperature of zero ensures consistent responses.
21
23
  #
22
24
  # @param translate_by_section [String] CSS selector to translate matched elements one by one.
23
25
  # Specify a css path (e.g. "body>p") to translate in smaller chunks.
24
26
  def initialize(
25
27
  app, access_token:,
28
+ model: "gpt-3.5-turbo",
29
+ temperature: 0.5,
26
30
  translate_by_section:
27
31
  )
28
32
  @app = app
29
33
  @access_token = access_token
34
+ @model = model
35
+ @temperature = temperature
30
36
 
31
37
  ### Non request related setting
32
38
  @translate_by_section = translate_by_section
@@ -34,6 +40,8 @@ module Natsukantou
34
40
 
35
41
  attr_reader :env
36
42
  attr_reader :access_token
43
+ attr_reader :model
44
+ attr_reader :temperature
37
45
  attr_reader :translate_by_section
38
46
 
39
47
  def call(env)
@@ -44,7 +52,11 @@ module Natsukantou
44
52
  next if node.text.empty?
45
53
 
46
54
  translated_xml = translate(node.to_xml)
47
- node.replace(dom_node(translated_xml))
55
+ next unless translated_xml
56
+
57
+ new_node = dom_node(translated_xml)
58
+ new_node.lang = env[:lang_to].code
59
+ node.replace(new_node)
48
60
  end
49
61
  else
50
62
  # TODO
@@ -63,28 +75,36 @@ module Natsukantou
63
75
  )
64
76
 
65
77
  parameters = {
66
- model: "gpt-3.5-turbo",
78
+ model: model,
67
79
  messages: [
68
80
  { role: "system", content: system_message },
69
81
  { role: "user", content: text },
70
82
  ],
71
- temperature: 0.7,
83
+ temperature: temperature,
72
84
  }
73
85
 
74
86
  response = client.chat(parameters: parameters)
75
87
 
88
+ raise response['error'].to_s if response.key?('error')
89
+
76
90
  translated_xml = response.dig("choices", 0, "message", "content")
77
91
 
78
92
  translated_xml.strip!
79
93
 
80
94
  translated_xml
81
- rescue Net::OpenTimeout
82
- sleep 10
83
- retry
95
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
96
+ puts e.message
97
+ nil
98
+ rescue StandardError => e
99
+ puts e.message
100
+ nil
84
101
  end
85
102
 
86
103
  def client
87
- @client ||= ::OpenAI::Client.new(access_token: access_token)
104
+ @client ||= ::OpenAI::Client.new(
105
+ access_token: access_token,
106
+ request_timeout: 500
107
+ )
88
108
  end
89
109
  end
90
110
  end
@@ -18,7 +18,7 @@ module Natsukantou
18
18
  puts "Saved as translator_config.rb, which you can specify with -c flag next time.\n\n"
19
19
  end
20
20
 
21
- Kernel.eval(config_content)
21
+ Kernel.eval(config_content) # rubocop:disable Security/Eval -- safe code generated by us
22
22
  end
23
23
 
24
24
  NatsukantouTranslator
@@ -38,7 +38,7 @@ module Natsukantou
38
38
  end
39
39
  end
40
40
 
41
- # Both a decorator to Yard tag object,
41
+ # Both a decorator to Yard @param tag object,
42
42
  # and where the user input is stored.
43
43
  # Assumption: only dealing with keyword argument
44
44
  class MethodParamTagDecorator < SimpleDelegator
@@ -49,7 +49,7 @@ module Natsukantou
49
49
  end
50
50
 
51
51
  def enter_ruby?
52
- return true if type == 'Hash'
52
+ return true if primary_type?('Hash')
53
53
 
54
54
  list = initialize_method.tag('fields_to_input_ruby')
55
55
 
@@ -59,23 +59,28 @@ module Natsukantou
59
59
  end
60
60
 
61
61
  def convert
62
- case type
63
- when Integer
62
+ if primary_type? 'Integer'
64
63
  :integer
65
- when Float
64
+ elsif primary_type? 'Float'
66
65
  :float
67
66
  else
68
67
  nil # do not convert and assume Ruby code
69
68
  end
70
69
  end
71
70
 
71
+ # @return [String] keyword argument default value **in Ruby syntax** as is.
72
+ # This means for string args, the quote characters (' or ") will also be included.
72
73
  def default_value_in_string
73
74
  initialize_method.parameters.find { |p| p.first == "#{name}:" }&.last
74
75
  end
75
76
 
76
77
  # tty-prompt's `value` field requires String
77
78
  def default_value_in_tty
78
- if type == 'String'
79
+ if primary_type? 'String'
80
+ if !default_value_in_string.start_with?('"')
81
+ raise "Please specify keyword argument default value in double quotes for the wizard parsing to work."
82
+ end
83
+
79
84
  JSON.parse(default_value_in_string)
80
85
  else
81
86
  default_value_in_string
@@ -89,6 +94,12 @@ module Natsukantou
89
94
  user_input.inspect
90
95
  end
91
96
  end
97
+
98
+ # @param type_string [String] whether @param type has type_string as primary type
99
+ # TODO: use internal YARD parser to obtain list of types
100
+ def primary_type?(type_string)
101
+ type.start_with?(type_string)
102
+ end
92
103
  end
93
104
 
94
105
  class ConfigPrompt
@@ -167,6 +178,7 @@ module Natsukantou
167
178
  printf "Enter '#{tag.name}', #{tag.text}"
168
179
 
169
180
  ask_params[:convert] = tag.convert if tag.convert
181
+ set_file_path!(tag, ask_params)
170
182
 
171
183
  default = tag.default_value_in_string
172
184
  if default == 'nil'
@@ -199,6 +211,22 @@ module Natsukantou
199
211
 
200
212
  answers
201
213
  end
214
+
215
+ # @param params [Hash] ask params, this will be modified
216
+ def set_file_path!(tag, params)
217
+ return if !tag.primary_type?('Pathname')
218
+
219
+ params[:convert] = :filepath
220
+ params[:validate] = proc do |input|
221
+ if File.exist?(input)
222
+ true
223
+ else
224
+ # TODO: https://github.com/piotrmurach/tty-prompt/issues/197
225
+ puts 'File does not exist, please input again: '
226
+ false
227
+ end
228
+ end
229
+ end
202
230
  end
203
231
  end
204
232
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'tsv'
4
+ require 'pathname'
4
5
 
5
6
  # Glossary isn't supported in some language
6
7
  # combinations (e.g. DeepL ja to zh).
@@ -16,14 +17,16 @@ module Natsukantou
16
17
  ja zh th lo
17
18
  }.freeze
18
19
 
19
- # @param filepath [String] path to TSV glossary file.
20
+ # @param filepath [Pathname, String] path to TSV glossary file.
20
21
  # @param glossary [Array(Array(String, String))] array representing glossary, e.g. [['book', '本']]
21
22
  # @fields_to_input_ruby glossary
22
23
  def initialize(app, filepath: nil, glossary: [])
23
24
  @app = app
24
25
 
25
26
  @glossary = glossary
26
- @glossary.concat(parse_tsv(filepath)) if filepath
27
+ if filepath
28
+ @glossary.concat(parse_tsv(Pathname(filepath)))
29
+ end
27
30
 
28
31
  @glossary.uniq! { |row| row[0] }
29
32
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Natsukantou
4
- VERSION = "0.2.2"
4
+ VERSION = "0.2.3"
5
5
  end
data/lib/natsukantou.rb CHANGED
@@ -2,10 +2,8 @@
2
2
 
3
3
  require 'logger'
4
4
  require 'middleware'
5
- require_relative "monkey_patch/builder"
6
5
  require 'oga'
7
6
  require_relative "monkey_patch/oga_document"
8
- require_relative "monkey_patch/runner"
9
7
 
10
8
  require_relative "natsukantou/version"
11
9
  require_relative "natsukantou/setup/registry"
data/natsukantou.gemspec CHANGED
@@ -29,7 +29,7 @@ Gem::Specification.new do |spec|
29
29
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
30
30
  spec.require_paths = ["lib"]
31
31
 
32
- spec.add_dependency "middleware", "~> 0.1.0"
32
+ spec.add_dependency "ibsciss-middleware", "~> 0.4.3"
33
33
  spec.add_dependency "oga", "~> 3.4"
34
34
  spec.add_dependency "tsv", "~> 1.0"
35
35
 
@@ -37,7 +37,7 @@ Gem::Specification.new do |spec|
37
37
  spec.add_dependency "yard", "~> 0.9"
38
38
 
39
39
  # DeepL
40
- spec.add_dependency "deepl-rb", "~> 2.5"
40
+ spec.add_dependency "deepl-rb", "~> 3.0"
41
41
 
42
42
  # Minhon
43
43
  spec.add_dependency 'oauth', '~> 0.5.1'
metadata CHANGED
@@ -1,29 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: natsukantou
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - lulalala
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2023-10-10 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
- name: middleware
13
+ name: ibsciss-middleware
15
14
  requirement: !ruby/object:Gem::Requirement
16
15
  requirements:
17
16
  - - "~>"
18
17
  - !ruby/object:Gem::Version
19
- version: 0.1.0
18
+ version: 0.4.3
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - "~>"
25
24
  - !ruby/object:Gem::Version
26
- version: 0.1.0
25
+ version: 0.4.3
27
26
  - !ruby/object:Gem::Dependency
28
27
  name: oga
29
28
  requirement: !ruby/object:Gem::Requirement
@@ -86,14 +85,14 @@ dependencies:
86
85
  requirements:
87
86
  - - "~>"
88
87
  - !ruby/object:Gem::Version
89
- version: '2.5'
88
+ version: '3.0'
90
89
  type: :runtime
91
90
  prerelease: false
92
91
  version_requirements: !ruby/object:Gem::Requirement
93
92
  requirements:
94
93
  - - "~>"
95
94
  - !ruby/object:Gem::Version
96
- version: '2.5'
95
+ version: '3.0'
97
96
  - !ruby/object:Gem::Dependency
98
97
  name: oauth
99
98
  requirement: !ruby/object:Gem::Requirement
@@ -131,17 +130,20 @@ executables:
131
130
  extensions: []
132
131
  extra_rdoc_files: []
133
132
  files:
133
+ - ".claude/skills/add-translator-backend/SKILL.md"
134
+ - ".claude/skills/add-translator-backend/example-backend.md"
135
+ - ".claude/skills/run-and-record-specs/SKILL.md"
136
+ - ".claude/skills/wizard-config-conventions/SKILL.md"
134
137
  - ".rspec"
135
138
  - ".rubocop.yml"
136
139
  - CHANGELOG.md
140
+ - CLAUDE.md
137
141
  - Gemfile
138
142
  - LICENSE.txt
139
143
  - README.md
140
144
  - Rakefile
141
145
  - exe/natsukantou
142
- - lib/monkey_patch/builder.rb
143
146
  - lib/monkey_patch/oga_document.rb
144
- - lib/monkey_patch/runner.rb
145
147
  - lib/natsukantou.rb
146
148
  - lib/natsukantou/chat_gpt.rb
147
149
  - lib/natsukantou/deep_l.rb
@@ -168,7 +170,6 @@ metadata:
168
170
  homepage_uri: https://gitlab.com/lulalala/natsukantou
169
171
  source_code_uri: https://gitlab.com/lulalala/natsukantou
170
172
  changelog_uri: https://gitlab.com/lulalala/natsukantou/-/releases
171
- post_install_message:
172
173
  rdoc_options: []
173
174
  require_paths:
174
175
  - lib
@@ -183,8 +184,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
183
184
  - !ruby/object:Gem::Version
184
185
  version: '0'
185
186
  requirements: []
186
- rubygems_version: 3.4.10
187
- signing_key:
187
+ rubygems_version: 3.6.9
188
188
  specification_version: 4
189
189
  summary: human language translation library for XML documents
190
190
  test_files: []
@@ -1,14 +0,0 @@
1
- module Middleware
2
- class Builder
3
- def use(middleware, *args, **kwargs, &block)
4
- if middleware.kind_of?(Builder)
5
- # Merge in the other builder's stack into our own
6
- self.stack.concat(middleware.stack)
7
- else
8
- self.stack << [middleware, args, kwargs, block]
9
- end
10
-
11
- self
12
- end
13
- end
14
- end
@@ -1,32 +0,0 @@
1
- module Middleware
2
- class Runner
3
- protected
4
-
5
- def build_call_chain(stack)
6
- stack.reverse.inject(EMPTY_MIDDLEWARE) do |next_middleware, current_middleware|
7
- # Unpack the actual item
8
- klass, args, kwargs, block = current_middleware
9
-
10
- # Default the arguments to an empty array. Otherwise in Ruby 1.8
11
- # a `nil` args will actually pass `nil` into the class. Not what
12
- # we want!
13
- args ||= []
14
-
15
- if klass.is_a?(Class)
16
- # If the klass actually is a class, then instantiate it with
17
- # the app and any other arguments given.
18
- klass.new(next_middleware, *args, **kwargs, &block)
19
- elsif klass.respond_to?(:call)
20
- # Make it a lambda which calls the item then forwards up
21
- # the chain.
22
- lambda do |env|
23
- klass.call(env)
24
- next_middleware.call(env)
25
- end
26
- else
27
- raise "Invalid middleware, doesn't respond to `call`: #{klass.inspect}"
28
- end
29
- end
30
- end
31
- end
32
- end