ad_localize 6.3.0 → 6.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e28db2bd871d27acd98fe88472fd383dbda178fdbc91d09dae21a8b644be60e
4
- data.tar.gz: 5baaebf8a8ce774f7285f70d9a522f5a57451caeaabbb6081a1cc4898587d317
3
+ metadata.gz: 0fe1c4ef7a0a7be9e6dc5396baac0d71f6fd2bfc4cba1cb076abba1200714dc3
4
+ data.tar.gz: 0ced5f8eb06b1edc0699bd9f976a31f51499397bd49e8380babfca7bb20b312a
5
5
  SHA512:
6
- metadata.gz: a7ae29824014dbd2c23d480f1d0441257a5c334e8bd0f7e3a7ddb0e98cdafcec06c44bd9ea32ec7f35471b415a6c05020c6391137ca0bd080d8de0440f8f9c0d
7
- data.tar.gz: 8c38d9753cc91399d1856bdaca191f32e304f85bd8d94d45bbe2beb8324c99aab6319ef7736cda2e2dd684782b7451b436333f1a72edd4d79118326d0b1d3ce1
6
+ metadata.gz: f3c982c48934e519fc58f739b73f4a36424e018f2c09ba30a414432990a0db3ec8a3fe057f5adfbbdf785dec348b4b416b8db23019bffd72fbf7613be758ffe7
7
+ data.tar.gz: 915907946b8f8c7fe8c596e6810c7d72acdbee3cdfb2bbe5d8462083cd4e3636c125bfd6b379f4285e8d12df9da918d185379b20356c82378204ea4b5b4e6bd8
@@ -7,7 +7,7 @@ jobs:
7
7
  runs-on: ubuntu-latest
8
8
  strategy:
9
9
  matrix:
10
- ruby: ['3.1.4', '3.2.0', '3.3.5']
10
+ ruby: ["3.2.0", "3.3.5", "3.4.10"]
11
11
 
12
12
  steps:
13
13
  - uses: actions/checkout@v2
@@ -23,11 +23,15 @@ jobs:
23
23
  - name: Run tests
24
24
  run: bundle exec rake test
25
25
  - run: 'echo "$GOOGLE_APPLICATION_CREDENTIALS" > account_secret.json'
26
+ # Disabled: only needed by the private google sheet tests below
27
+ if: false
26
28
  shell: bash
27
29
  env:
28
30
  GOOGLE_APPLICATION_CREDENTIALS: ${{secrets.GOOGLE_APPLICATION_CREDENTIALS}}
29
31
  - name: Run private google sheet tests
30
- if: matrix.ruby == '3.3.5' # Limit execution on latest ruby version to prevent reaching rate limits
32
+ # Disabled: the private google sheet used by these tests is missing
33
+ # if: matrix.ruby == '3.3.5' # Limit execution on latest ruby version to prevent reaching rate limits
34
+ if: false
31
35
  run: bundle exec rake test_private_google_sheet
32
36
  env:
33
- GOOGLE_APPLICATION_CREDENTIALS: 'account_secret.json'
37
+ GOOGLE_APPLICATION_CREDENTIALS: "account_secret.json"
data/.gitignore CHANGED
@@ -12,3 +12,5 @@ ad_localize/tmp/
12
12
  pkg/
13
13
  exports/
14
14
  account_secret.json
15
+ .DS_Store
16
+ __pycache__/
data/CHANGELOG.md CHANGED
@@ -7,11 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## [6.4.0] - 2026-10-02
11
+
12
+ ### Added
13
+
14
+ - Add `--excel-file` option to support XLSX file format in addition of CSV files.
15
+ - Add `scripts/download_with_navigator`, a temporary workaround script that downloads a file through the user browser session (`--source sharepoint` turns a SharePoint share link into a download link)
16
+
17
+ ### Removed
18
+
19
+ - Drop support for ruby 3.1
20
+
21
+ ### Fixed
22
+
23
+ - Fix plurals and adaptive wordings emptied when merging several sheets or csv files with the default `keep` merge policy
24
+ - Fix the key appended to the last value of a Google Sheet export without trailing newline, by requiring csv >= 3.2.7
25
+
10
26
  ## [6.3.0] - 2024-11-04
11
27
 
12
28
  ### Removed
13
29
 
14
- - Drop support for for ruby <3.1
30
+ - Drop support for ruby <3.1
15
31
 
16
32
  ## [6.2.0] - 2023-12-12
17
33
 
data/Gemfile CHANGED
@@ -4,6 +4,8 @@ source "https://rubygems.org"
4
4
  # Specify your gem's dependencies in ad_localize.gemspec
5
5
  gemspec
6
6
 
7
+ ruby ">= 3.2.0"
8
+
7
9
  gem 'bundler', '>= 1.12.0', '< 3.0.0'
8
10
  gem 'debug', ">= 1.0.0", '< 2.0'
9
11
  gem 'diffy', '~> 3.3'
data/Gemfile.lock CHANGED
@@ -1,102 +1,114 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- ad_localize (6.2.0)
4
+ ad_localize (6.4.0)
5
5
  activesupport (>= 6.1.3.2, < 8.0)
6
6
  colorize (~> 1.0)
7
+ csv (~> 3.2, >= 3.2.7)
7
8
  google-apis-drive_v3 (~> 0.33.0)
8
9
  google-apis-sheets_v4 (~> 0.9)
10
+ rubyXL (~> 3.4)
9
11
 
10
12
  GEM
11
13
  remote: https://rubygems.org/
12
14
  specs:
13
- activesupport (7.2.2)
15
+ activesupport (7.2.4)
14
16
  base64
15
17
  benchmark (>= 0.3)
16
18
  bigdecimal
17
19
  concurrent-ruby (~> 1.0, >= 1.3.1)
18
- connection_pool (>= 2.2.5)
20
+ connection_pool (>= 2.2.5, < 3)
19
21
  drb
20
22
  i18n (>= 1.6, < 2)
21
23
  logger (>= 1.4.2)
22
- minitest (>= 5.1)
24
+ minitest (>= 5.1, < 6)
23
25
  securerandom (>= 0.3)
24
26
  tzinfo (~> 2.0, >= 2.0.5)
25
- addressable (2.8.7)
26
- public_suffix (>= 2.0.2, < 7.0)
27
+ addressable (2.9.0)
28
+ public_suffix (>= 2.0.2, < 8.0)
27
29
  ansi (1.5.0)
28
30
  ast (2.4.2)
29
- base64 (0.2.0)
30
- benchmark (0.3.0)
31
- bigdecimal (3.1.8)
31
+ base64 (0.3.0)
32
+ benchmark (0.5.0)
33
+ bigdecimal (4.1.3)
32
34
  builder (3.3.0)
33
35
  colorize (1.1.0)
34
- concurrent-ruby (1.3.4)
35
- connection_pool (2.4.1)
36
+ concurrent-ruby (1.3.8)
37
+ connection_pool (2.5.5)
38
+ csv (3.3.6)
36
39
  debug (1.9.2)
37
40
  irb (~> 1.10)
38
41
  reline (>= 0.3.8)
39
42
  declarative (0.0.20)
40
43
  diffy (3.4.3)
41
- drb (2.2.1)
42
- faraday (2.12.0)
43
- faraday-net_http (>= 2.0, < 3.4)
44
+ drb (2.2.3)
45
+ faraday (2.14.4)
46
+ faraday-net_http (>= 2.0, < 3.5)
44
47
  json
45
48
  logger
46
- faraday-net_http (3.3.0)
47
- net-http
48
- google-apis-core (0.15.1)
49
- addressable (~> 2.5, >= 2.5.1)
50
- googleauth (~> 1.9)
51
- httpclient (>= 2.8.3, < 3.a)
52
- mini_mime (~> 1.0)
53
- mutex_m
49
+ faraday-follow_redirects (0.5.0)
50
+ faraday (>= 1, < 3)
51
+ faraday-net_http (3.4.4)
52
+ net-http (~> 0.5)
53
+ google-apis-core (1.2.5)
54
+ addressable (~> 2.9)
55
+ faraday (~> 2.13)
56
+ faraday-follow_redirects (~> 0.3)
57
+ googleauth (~> 1.14)
58
+ mini_mime (~> 1.1)
59
+ multi_json (~> 1.11)
54
60
  representable (~> 3.0)
55
- retriable (>= 2.0, < 4.a)
61
+ retriable (>= 3.1, < 5.0)
56
62
  google-apis-drive_v3 (0.33.0)
57
63
  google-apis-core (>= 0.9.1, < 2.a)
58
- google-apis-sheets_v4 (0.38.0)
64
+ google-apis-sheets_v4 (0.48.0)
59
65
  google-apis-core (>= 0.15.0, < 2.a)
60
- google-cloud-env (2.2.1)
66
+ google-cloud-env (2.4.0)
67
+ base64 (~> 0.2)
61
68
  faraday (>= 1.0, < 3.a)
62
- googleauth (1.11.2)
69
+ google-logging-utils (0.2.0)
70
+ googleauth (1.17.4)
63
71
  faraday (>= 1.0, < 3.a)
64
- google-cloud-env (~> 2.1)
65
- jwt (>= 1.4, < 3.0)
66
- multi_json (~> 1.11)
72
+ google-cloud-env (~> 2.2)
73
+ google-logging-utils (~> 0.1)
74
+ jwt (>= 1.4, < 4.0)
67
75
  os (>= 0.9, < 2.0)
76
+ pstore (~> 0.1)
68
77
  signet (>= 0.16, < 2.a)
69
- httpclient (2.8.3)
70
- i18n (1.14.6)
78
+ i18n (1.15.2)
71
79
  concurrent-ruby (~> 1.0)
72
80
  io-console (0.7.2)
73
81
  irb (1.14.1)
74
82
  rdoc (>= 4.0.0)
75
83
  reline (>= 0.4.2)
76
84
  json (2.7.5)
77
- jwt (2.9.3)
85
+ jwt (3.3.0)
78
86
  base64
79
87
  language_server-protocol (3.17.0.3)
80
- logger (1.6.1)
88
+ logger (1.7.0)
81
89
  mini_mime (1.1.5)
90
+ mini_portile2 (2.8.9)
82
91
  minitest (5.25.1)
83
92
  minitest-reporters (1.7.1)
84
93
  ansi
85
94
  builder
86
95
  minitest (>= 5.0)
87
96
  ruby-progressbar
88
- multi_json (1.15.0)
89
- mutex_m (0.2.0)
90
- net-http (0.4.1)
91
- uri
97
+ multi_json (1.21.2)
98
+ net-http (0.9.1)
99
+ uri (>= 0.11.1)
100
+ nokogiri (1.19.4)
101
+ mini_portile2 (~> 2.8.2)
102
+ racc (~> 1.4)
92
103
  os (1.1.4)
93
104
  parallel (1.26.3)
94
105
  parser (3.3.5.1)
95
106
  ast (~> 2.4.1)
96
107
  racc
108
+ pstore (0.2.1)
97
109
  psych (5.1.2)
98
110
  stringio
99
- public_suffix (6.0.1)
111
+ public_suffix (7.0.5)
100
112
  racc (1.8.1)
101
113
  rainbow (3.1.1)
102
114
  rake (13.2.1)
@@ -105,11 +117,11 @@ GEM
105
117
  regexp_parser (2.9.2)
106
118
  reline (0.5.10)
107
119
  io-console (~> 0.5)
108
- representable (3.2.0)
120
+ representable (3.3.1)
109
121
  declarative (< 0.1.0)
110
122
  trailblazer-option (>= 0.1.1, < 0.2.0)
111
123
  uber (< 0.2.0)
112
- retriable (3.1.2)
124
+ retriable (4.2.0)
113
125
  rubocop (1.68.0)
114
126
  json (~> 2.3)
115
127
  language_server-protocol (>= 3.17.0)
@@ -123,19 +135,22 @@ GEM
123
135
  rubocop-ast (1.33.1)
124
136
  parser (>= 3.3.1.0)
125
137
  ruby-progressbar (1.13.0)
126
- securerandom (0.3.1)
127
- signet (0.19.0)
138
+ rubyXL (3.4.38)
139
+ nokogiri (>= 1.10.8)
140
+ rubyzip (>= 3.2.2)
141
+ rubyzip (3.7.0)
142
+ securerandom (0.4.1)
143
+ signet (0.22.0)
128
144
  addressable (~> 2.8)
129
145
  faraday (>= 0.17.5, < 3.a)
130
- jwt (>= 1.5, < 3.0)
131
- multi_json (~> 1.10)
146
+ jwt (>= 1.5, < 4.0)
132
147
  stringio (3.1.1)
133
148
  trailblazer-option (0.1.2)
134
149
  tzinfo (2.0.6)
135
150
  concurrent-ruby (~> 1.0)
136
151
  uber (0.1.0)
137
152
  unicode-display_width (2.6.0)
138
- uri (0.13.1)
153
+ uri (1.1.1)
139
154
 
140
155
  PLATFORMS
141
156
  ruby
@@ -150,5 +165,8 @@ DEPENDENCIES
150
165
  rake (>= 12.3.0, < 14.0.0)
151
166
  rubocop (~> 1.0)
152
167
 
168
+ RUBY VERSION
169
+ ruby 3.2.2p53
170
+
153
171
  BUNDLED WITH
154
172
  2.3.10
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AdLocalize
2
2
 
3
- The purpose of this gem is to automatically generate wording files from a CSV input (CSV file or Google Spreadsheet).
3
+ The purpose of this gem is to automatically generate wording files from a CSV or Excel input (CSV file, `.xlsx` file or Google Spreadsheet).
4
4
  It supports iOS, Android, JSON, YAML and Java Properties.
5
5
  It is a useful tool when working on a mobile application or a SPA.
6
6
 
@@ -46,6 +46,23 @@ $ ad_localize -k <your-spreadsheet-drive-key>
46
46
  $ ad_localize -k <your-spreadsheet-drive-key> -s <comma-separated-sheet-id-list>
47
47
  ```
48
48
 
49
+ Google is the default download source. Use `--excel-file` to convert Microsoft Excel `.xlsx` sources before export.
50
+
51
+ * Export wording from a local Excel file. Only `.xlsx` files are supported. This can be used as an offline fallback.
52
+ ```
53
+ $ ad_localize --excel-file wordings.xlsx
54
+ ```
55
+
56
+ * Export wording from an Excel sharing URL. Remote Excel files are downloaded through Microsoft Graph API and require a `MICROSOFT_GRAPH_ACCESS_TOKEN` environment variable.
57
+ ```
58
+ $ MICROSOFT_GRAPH_ACCESS_TOKEN=<token> ad_localize --excel-file https://example.sharepoint.com/.../wordings.xlsx
59
+ ```
60
+
61
+ * Export wording from specific Excel sheets.
62
+ ```
63
+ $ ad_localize --excel-file wordings.xlsx -s <comma-separated-sheet-name-list>
64
+ ```
65
+
49
66
  * Export wording from a private google spreadsheet. It requires a [Google Cloud Service Account](#using-a-google-cloud-service-account).
50
67
  ```
51
68
  $ GOOGLE_APPLICATION_CREDENTIALS=<path-to-client-secret.json> ad_localize -k <your-spreadsheet-drive-key>
@@ -91,6 +108,32 @@ $ ad_localize -l fr,en
91
108
  $ ad_localize --skip-value-stripping
92
109
  ```
93
110
 
111
+ ### Download from an external source with the browser
112
+
113
+ When the wording file must first be downloaded from an external source (for example a SharePoint share link) and no API access is available, `scripts/download_with_navigator` downloads it through your browser, so that your browser session and cookies are used.
114
+
115
+ The script opens the URL in the default browser, waits for the download to complete in `~/Downloads`, moves the file to a temporary directory (or to the `-o` path) and prints its path on stdout.
116
+
117
+ If you download the script on its own (outside of this repository), make it executable with `chmod +x download_with_navigator`, or run it with `python3 download_with_navigator ...`.
118
+
119
+ * Download a file from its raw URL
120
+ ```
121
+ $ scripts/download_with_navigator <url>
122
+ ```
123
+
124
+ * Download a file from a SharePoint share link
125
+ ```
126
+ $ scripts/download_with_navigator <sharepoint share url> --source sharepoint
127
+ ```
128
+
129
+ * Download a file then export its wording with ad_localize
130
+ ```
131
+ $ XLSX_FILE=$(scripts/download_with_navigator <sharepoint share url> --source sharepoint)
132
+ $ ad_localize --excel-file "$XLSX_FILE"
133
+ ```
134
+
135
+ Run `scripts/download_with_navigator -h` for all options, and `scripts/download_with_navigator --version` to check the version of a downloaded copy. The `DOWNLOADS_DIR` (default `~/Downloads`) and `DOWNLOAD_TIMEOUT` (default `180` seconds) environment variables can be used to change the watched directory and the wait duration.
136
+
94
137
  ### In a Ruby program
95
138
  There are many possibilities when using AdLocalize in a ruby program. You can add support to your own wording format, support other platforms, select which locales you want to export, generate wording file content without writing on the disk and many more.
96
139
 
@@ -105,7 +148,8 @@ If you want more examples, please open a documentation issue.
105
148
  export_request.sheet_ids = %w[first second]
106
149
  export_request.verbose = true
107
150
  begin
108
- # download files - be sure that GOOGLE_APPLICATION_CREDENTIALS is set if you use service account
151
+ # download Google spreadsheets and convert Excel files to CSV tempfiles
152
+ # be sure that GOOGLE_APPLICATION_CREDENTIALS is set if you use service account
109
153
  export_request.downloaded_csvs = AdLocalize::Interactors::DownloadSpreadsheets.new.call(export_request: export_request)
110
154
  # execute request
111
155
  AdLocalize::Interactors::ProcessExportRequest.new.call(export_request: export_request)
data/ad_localize.gemspec CHANGED
@@ -27,7 +27,7 @@ Gem::Specification.new do |spec|
27
27
 
28
28
  spec.summary = %q(AdLocalize helps with mobile and web applications wording)
29
29
  spec.description = %q(AdLocalize produces localization files from platform agnostic wording.
30
- Supported wording format : CSV. Supported export format: iOS, Android, JSON and YAML)
30
+ Supported wording format : CSV, XLSX. Supported export format: iOS, Android, JSON and YAML)
31
31
  spec.homepage = 'https://github.com/applidium/ad_localize'
32
32
 
33
33
  # Specify which files should be added to the gem when it is released.
@@ -41,9 +41,11 @@ Gem::Specification.new do |spec|
41
41
 
42
42
  spec.add_dependency 'activesupport', '>= 6.1.3.2', '< 8.0'
43
43
  spec.add_dependency 'colorize', '~> 1.0'
44
+ spec.add_dependency 'csv', '~> 3.2', '>= 3.2.7'
44
45
  spec.add_dependency 'google-apis-sheets_v4', '~> 0.9'
45
46
  spec.add_dependency 'google-apis-drive_v3', '~> 0.33.0'
47
+ spec.add_dependency 'rubyXL', '~> 3.4'
46
48
 
47
- spec.required_ruby_version = '>= 3.1', '< 4.0'
49
+ spec.required_ruby_version = '>= 3.2', '< 4.0'
48
50
  spec.metadata['rubygems_mfa_required'] = 'true'
49
51
  end
@@ -5,7 +5,7 @@ module AdLocalize
5
5
  options = OptionHandler.parse!(args)
6
6
  export_request = Mappers::OptionsToExportRequest.new.map(options: options)
7
7
  LOGGER.debug("Export request options : #{export_request}")
8
- export_request.downloaded_csvs = Interactors::DownloadSpreadsheets.new.call(export_request: export_request) if export_request.has_sheets?
8
+ export_request.downloaded_csvs = Interactors::DownloadSpreadsheets.new.call(export_request: export_request)
9
9
  Interactors::ProcessExportRequest.new.call(export_request: export_request)
10
10
  ensure
11
11
  export_request&.downloaded_csvs&.each do |file|
@@ -4,9 +4,19 @@ module AdLocalize
4
4
  class DownloadSpreadsheets
5
5
  def initialize
6
6
  @drive_repository = Repositories::DriveRepository.new
7
+ @office_repository = Repositories::OfficeRepository.new
7
8
  end
8
9
 
9
10
  def call(export_request:)
11
+ return download_google_spreadsheets(export_request: export_request) if export_request.has_sheets?
12
+ return download_office_spreadsheets(export_request: export_request) if export_request.has_excel_file?
13
+
14
+ []
15
+ end
16
+
17
+ private
18
+
19
+ def download_google_spreadsheets(export_request:)
10
20
  LOGGER.debug("Downloading spreadsheets")
11
21
  if export_request.export_all
12
22
  @drive_repository.download_all_sheets(spreadsheet_id: export_request.spreadsheet_id)
@@ -15,6 +25,12 @@ module AdLocalize
15
25
  sheet_ids: export_request.sheet_ids)
16
26
  end
17
27
  end
28
+
29
+ def download_office_spreadsheets(export_request:)
30
+ @office_repository.convert_to_csvs(paths: [export_request.excel_file],
31
+ sheet_ids: export_request.sheet_ids,
32
+ export_all: export_request.export_all)
33
+ end
18
34
  end
19
35
  end
20
36
  end
@@ -32,7 +32,8 @@ module AdLocalize
32
32
 
33
33
  def merge_compound_wordings(reference_hash:, new_hash:, merge_policy:)
34
34
  new_hash.each do |new_label, new_list|
35
- if reference_hash[new_label].nil?
35
+ # key? instead of [] : plurals and adaptives hashes create an empty entry on read
36
+ if !reference_hash.key?(new_label)
36
37
  reference_hash[new_label] = new_list
37
38
  elsif merge_policy == REPLACE_MERGE_POLICY
38
39
  merge_simple_wordings(reference_list: reference_hash[new_label], new_list: new_list, merge_policy: merge_policy)
@@ -9,6 +9,7 @@ module AdLocalize
9
9
  request.auto_escape_percent = options[:'auto-escape-percent']
10
10
  request.skip_value_stripping = options[:'skip-value-stripping']
11
11
  request.csv_paths = options[:csv_paths]
12
+ request.excel_file = options[:'excel-file']
12
13
  request.merge_policy = options[:'merge-policy']
13
14
  request.output_path = options[:'target-dir']
14
15
  request.platforms = options[:only]
@@ -7,6 +7,7 @@ module AdLocalize
7
7
  :'auto-escape-percent' => Requests::ExportRequest::DEFAULTS[:auto_escape_percent],
8
8
  :'skip-value-stripping' => Requests::ExportRequest::DEFAULTS[:skip_value_stripping],
9
9
  csv_paths: Requests::ExportRequest::DEFAULTS[:csv_paths],
10
+ :'excel-file' => Requests::ExportRequest::DEFAULTS[:excel_file],
10
11
  :'merge-policy' => Requests::ExportRequest::DEFAULTS[:merge_policy],
11
12
  :'target-dir' => Requests::ExportRequest::DEFAULTS[:output_path],
12
13
  :'drive-key' => Requests::ExportRequest::DEFAULTS[:spreadsheet_id],
@@ -17,7 +18,7 @@ module AdLocalize
17
18
  }
18
19
 
19
20
  def self.parse!(options)
20
- args = DEFAULT_OPTIONS
21
+ args = DEFAULT_OPTIONS.deep_dup
21
22
  OptionParser.new do |parser|
22
23
  parser.banner = 'Usage: exe/ad_localize [options] file(s)'
23
24
  parser.on("-d", "--debug", TrueClass, 'Run in debug mode')
@@ -31,8 +32,7 @@ module AdLocalize
31
32
  'LOCALES is a comma separated list. Only generate localisation files for the specified locales')
32
33
  merge_policy_option(parser)
33
34
  platforms_option(parser)
34
- parser.on("-s", "--sheets SHEET_IDS", Array,
35
- 'SHEET_IDS is a comma separated list. Use a specific sheet id for Google Drive spreadsheets with several sheets')
35
+ spreadsheet_options(parser)
36
36
  parser.on("-t", "--target-dir PATH", String, 'Path to the target directory')
37
37
  parser.on("-v", "--version", 'Prints current version') do
38
38
  puts AdLocalize::VERSION
@@ -43,15 +43,16 @@ module AdLocalize
43
43
  parser.on("--skip-value-stripping", TrueClass, 'Disable the removal of leading and trailing whitespaces on wording values')
44
44
  end.parse!(options, into: args)
45
45
 
46
+ validate_download_source!(args)
46
47
  args[:csv_paths] = options
47
48
  return args
48
49
  end
49
50
 
50
51
  def self.export_all_option(parser)
51
52
  export_all_option = <<~DOC
52
- Export all sheets from spreadsheet specified by --drive-key option.
53
- \tBy default, generates one export directory per sheet (see -m|--merge-sheets option to merge them).
54
- \tAn GCLOUD_CLIENT_SECRET environment variable containing the client_secret.json content is needed.
53
+ Export all sheets from spreadsheet specified by --drive-key or --excel-file option.
54
+ \tSheets are merged into a single export (see -m|--merge-policy option to handle duplicated keys).
55
+ \tWith --drive-key, an GCLOUD_CLIENT_SECRET environment variable containing the client_secret.json content is needed.
55
56
  DOC
56
57
  parser.on("-e", "--export-all-sheets", TrueClass, export_all_option)
57
58
  end
@@ -65,6 +66,18 @@ module AdLocalize
65
66
  parser.on("-m", "--merge-policy POLICY", Interactors::MergeWordings::MERGE_POLICIES, merge_policy_option)
66
67
  end
67
68
 
69
+ def self.spreadsheet_options(parser)
70
+ parser.on("-f", "--excel-file XLSX_PATH_OR_URL", String, 'Use a Microsoft Excel .xlsx file path or sharing URL')
71
+ parser.on("-s", "--sheets SHEET_IDS", Array,
72
+ 'SHEET_IDS is a comma separated list. Use specific sheet ids for Google Drive spreadsheets or sheet names for Excel files')
73
+ end
74
+
75
+ def self.validate_download_source!(args)
76
+ return unless args[:'drive-key'].present? && args[:'excel-file'].present?
77
+
78
+ raise OptionParser::InvalidOption.new('Use either --drive-key or --excel-file, not both')
79
+ end
80
+
68
81
  def self.platforms_option(parser)
69
82
  platforms_option = <<~DOC
70
83
  PLATFORMS is a comma separated list.
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+ module AdLocalize
3
+ module Repositories
4
+ class OfficeRepository
5
+ EXCEL_EXTENSION = '.xlsx'
6
+ GRAPH_ACCESS_TOKEN_ENV = 'MICROSOFT_GRAPH_ACCESS_TOKEN'
7
+ GRAPH_URL = 'https://graph.microsoft.com/v1.0'
8
+
9
+ def office_source?(path:)
10
+ File.extname(URI(path.to_s).path) == EXCEL_EXTENSION
11
+ rescue URI::InvalidURIError
12
+ false
13
+ end
14
+
15
+ def convert_to_csvs(paths:, sheet_ids:, export_all: false)
16
+ paths.flat_map do |path|
17
+ convert_to_csv(path: path, sheet_ids: sheet_ids, export_all: export_all)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def convert_to_csv(path:, sheet_ids:, export_all:)
24
+ source_file = download(path: path) if remote_source?(path)
25
+ workbook = RubyXL::Parser.parse(source_file&.path || path.to_s)
26
+ worksheets = worksheets_for(workbook: workbook, sheet_ids: sheet_ids, path: path, export_all: export_all)
27
+ worksheets.map { |worksheet| write_csv(worksheet: worksheet) }
28
+ rescue StandardError => e
29
+ LOGGER.error("Cannot convert Excel file #{path}. Error: #{e.message}")
30
+ []
31
+ ensure
32
+ source_file&.close
33
+ source_file&.unlink
34
+ end
35
+
36
+ def download(path:)
37
+ file = Tempfile.new(['ad_localize', EXCEL_EXTENSION])
38
+ file.binmode
39
+ file.write(download_body(uri: graph_content_uri(path: path)))
40
+ file.rewind
41
+ file
42
+ end
43
+
44
+ def download_body(uri:, limit: 5)
45
+ raise 'Too many redirects' if limit.zero?
46
+
47
+ response = get(uri: uri)
48
+ case response.code.to_i
49
+ when 200..299
50
+ response.body
51
+ when 300..399
52
+ redirect_uri = URI.join(uri, response['location'])
53
+ raise "Refusing to follow non-https redirect to #{redirect_uri.hostname}" unless redirect_uri.scheme == 'https'
54
+
55
+ download_body(uri: redirect_uri, limit: limit - 1)
56
+ else
57
+ raise "HTTP #{response.code}: #{response.body}"
58
+ end
59
+ end
60
+
61
+ def get(uri:)
62
+ request = Net::HTTP::Get.new(uri)
63
+ # Graph redirects to a pre-authenticated download URL: never leak the token to another host
64
+ request['Authorization'] = "Bearer #{graph_access_token}" if uri.hostname == URI(GRAPH_URL).hostname
65
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
66
+ http.request(request)
67
+ end
68
+ end
69
+
70
+ def graph_content_uri(path:)
71
+ URI("#{GRAPH_URL}/shares/#{share_id(url: path.to_s)}/driveItem/content")
72
+ end
73
+
74
+ def share_id(url:)
75
+ encoded_url = Base64.urlsafe_encode64(url, padding: false)
76
+ "u!#{encoded_url}"
77
+ end
78
+
79
+ def graph_access_token
80
+ ENV.fetch(GRAPH_ACCESS_TOKEN_ENV) do
81
+ raise "Missing #{GRAPH_ACCESS_TOKEN_ENV} environment variable"
82
+ end
83
+ end
84
+
85
+ def worksheets_for(workbook:, sheet_ids:, path:, export_all:)
86
+ return workbook.worksheets if export_all
87
+ return [workbook.worksheets.first] if sheet_ids == Requests::ExportRequest::DEFAULTS[:sheet_ids]
88
+
89
+ sheet_ids.filter_map do |sheet_id|
90
+ worksheet = workbook.worksheets.find { |sheet| sheet.sheet_name == sheet_id }
91
+ LOGGER.error("Cannot find Excel sheet named #{sheet_id} in #{path}") unless worksheet
92
+ worksheet
93
+ end
94
+ end
95
+
96
+ def write_csv(worksheet:)
97
+ file = Tempfile.new(['ad_localize', '.csv'])
98
+ CSV.open(file.path, 'w') do |csv|
99
+ worksheet.each do |row|
100
+ csv << (row&.cells || []).map { |cell| cell&.value }
101
+ end
102
+ end
103
+ file.rewind
104
+ file
105
+ end
106
+
107
+ def remote_source?(path)
108
+ raise "Only https URLs are supported for Excel files: #{path}" if path.to_s.start_with?('http://')
109
+
110
+ path.to_s.start_with?('https://')
111
+ end
112
+ end
113
+ end
114
+ end
@@ -8,6 +8,7 @@ module AdLocalize
8
8
  auto_escape_percent: false,
9
9
  skip_value_stripping: false,
10
10
  csv_paths: [],
11
+ excel_file: nil,
11
12
  merge_policy: Interactors::MergeWordings::DEFAULT_POLICY,
12
13
  output_path: Pathname.new('exports'),
13
14
  spreadsheet_id: nil,
@@ -26,6 +27,7 @@ module AdLocalize
26
27
  :auto_escape_percent,
27
28
  :skip_value_stripping,
28
29
  :csv_paths,
30
+ :excel_file,
29
31
  :merge_policy,
30
32
  :output_path,
31
33
  :platforms,
@@ -42,6 +44,7 @@ module AdLocalize
42
44
  @auto_escape_percent = DEFAULTS[:auto_escape_percent]
43
45
  @skip_value_stripping = DEFAULTS[:skip_value_stripping]
44
46
  @csv_paths = DEFAULTS[:csv_paths]
47
+ @excel_file = DEFAULTS[:excel_file]
45
48
  @merge_policy = DEFAULTS[:merge_policy]
46
49
  @output_path = DEFAULTS[:output_path]
47
50
  @platforms = DEFAULTS[:platforms]
@@ -76,6 +79,10 @@ module AdLocalize
76
79
  @csv_paths = value.compact.map { |path| Pathname.new(path) }
77
80
  end
78
81
 
82
+ def excel_file=(value)
83
+ @excel_file = Pathname.new(value) unless value.blank?
84
+ end
85
+
79
86
  def merge_policy=(value)
80
87
  @merge_policy = value unless value.blank?
81
88
  end
@@ -118,6 +125,10 @@ module AdLocalize
118
125
  spreadsheet_id.present?
119
126
  end
120
127
 
128
+ def has_excel_file?
129
+ excel_file.present?
130
+ end
131
+
121
132
  def has_csv_paths?
122
133
  all_csv_paths.present?
123
134
  end
@@ -136,6 +147,7 @@ module AdLocalize
136
147
  "auto_escape_percent: #{auto_escape_percent}, " \
137
148
  "skip_value_stripping: #{skip_value_stripping}, " \
138
149
  "csv_paths: #{csv_paths}, " \
150
+ "excel_file: #{excel_file}, " \
139
151
  "merge_policy: #{merge_policy}, " \
140
152
  "output_path: #{output_path}, " \
141
153
  "spreadsheet_id: #{spreadsheet_id}, " \
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module AdLocalize
3
- VERSION = "6.3.0"
3
+ VERSION = "6.4.0"
4
4
  end
data/lib/ad_localize.rb CHANGED
@@ -1,21 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
  require 'active_support'
3
3
  require 'active_support/core_ext'
4
+ require 'base64'
4
5
  require 'fileutils'
5
6
  require 'pathname'
6
7
  require 'yaml'
7
8
  require 'json'
8
9
  require 'csv'
9
10
  require 'logger'
11
+ require 'net/http'
12
+ require 'uri'
10
13
  require 'colorize'
11
14
  require 'optparse'
12
15
  require 'erb'
16
+ require 'tempfile'
13
17
  require 'googleauth'
14
18
  require 'google/apis/sheets_v4'
15
19
  require 'google/apis/drive_v3'
20
+ require 'rubyXL'
16
21
 
17
22
  require 'ad_localize/repositories/drive_repository'
18
23
  require 'ad_localize/repositories/file_system_repository'
24
+ require 'ad_localize/repositories/office_repository'
19
25
 
20
26
  require 'ad_localize/view_models/simple_wording_view_model'
21
27
  require 'ad_localize/view_models/compound_wording_view_model'
data/mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ ruby = "3.2"
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Download a file through the user browser, so that the download uses the browser session and cookies.
4
+ # The URL is opened as is, unless a --source option tells how to turn it into a download URL :
5
+ #
6
+ # --source sharepoint : the URL is a SharePoint share link, its query is replaced with download=1
7
+ #
8
+ # This script can be used with ad_localize like this :
9
+ #
10
+ # XLSX_FILE=$(./download_with_navigator <sharepoint share url> --source sharepoint)
11
+ # ad_localize --excel-file "$XLSX_FILE"
12
+ #
13
+
14
+ import argparse
15
+ import os
16
+ import shutil
17
+ import sys
18
+ import tempfile
19
+ import time
20
+ import webbrowser
21
+ from pathlib import Path
22
+ from urllib.parse import urlencode, urlparse, urlunparse
23
+
24
+ VERSION = "1.0.0"
25
+
26
+ # Browsers write to a temporary name and rename it when the download is complete
27
+ PARTIAL_SUFFIXES = (".crdownload", ".part", ".download", ".tmp")
28
+ STABLE_CHECKS = 3
29
+
30
+
31
+ def sharepoint_url(url):
32
+ parsed = urlparse(url)
33
+
34
+ return urlunparse(parsed._replace(query=urlencode({"download": "1"}), fragment=""))
35
+
36
+
37
+ SOURCES = {
38
+ "sharepoint": sharepoint_url,
39
+ }
40
+
41
+
42
+ def download_url(url, source):
43
+ parsed = urlparse(url)
44
+
45
+ if parsed.scheme not in ("http", "https") or not parsed.hostname:
46
+ raise ValueError(f"http(s) URL expected: {url}")
47
+
48
+ return SOURCES[source](url) if source else url
49
+
50
+
51
+ def completed_files(downloads_dir):
52
+ return [
53
+ p for p in Path(downloads_dir).iterdir()
54
+ if p.is_file()
55
+ and not p.name.startswith(".")
56
+ and p.suffix.lower() not in PARTIAL_SUFFIXES
57
+ ]
58
+
59
+
60
+ def snapshot(downloads_dir):
61
+ return {p.name: p.stat().st_mtime_ns for p in completed_files(downloads_dir)}
62
+
63
+
64
+ def wait_for_download(downloads_dir, baseline, timeout):
65
+ deadline = time.monotonic() + timeout
66
+ last_seen = None
67
+ stable_count = 0
68
+
69
+ while time.monotonic() < deadline:
70
+ candidates = [
71
+ p for p in completed_files(downloads_dir)
72
+ if baseline.get(p.name) != p.stat().st_mtime_ns
73
+ ]
74
+
75
+ if candidates:
76
+ newest = max(candidates, key=lambda p: p.stat().st_mtime_ns)
77
+ current = (newest, newest.stat().st_size)
78
+
79
+ stable_count = stable_count + 1 if current == last_seen else 1
80
+ last_seen = current
81
+
82
+ if current[1] > 0 and stable_count >= STABLE_CHECKS:
83
+ return newest
84
+
85
+ time.sleep(1)
86
+
87
+ raise TimeoutError(f"No file downloaded in {downloads_dir}")
88
+
89
+
90
+ def main():
91
+ parser = argparse.ArgumentParser(
92
+ description="Download a file through the browser, using the user session"
93
+ )
94
+ parser.add_argument("url", help="URL of the file to download")
95
+ parser.add_argument(
96
+ "--source",
97
+ choices=sorted(SOURCES),
98
+ help="Origin of the URL, used to turn it into a download URL. If missing, the URL is opened as is.",
99
+ )
100
+ parser.add_argument(
101
+ "-o",
102
+ "--output",
103
+ help="Output file. If missing, a temporary file is used.",
104
+ )
105
+ parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {VERSION}")
106
+ args = parser.parse_args()
107
+
108
+ downloads_dir = os.environ.get("DOWNLOADS_DIR", str(Path.home() / "Downloads"))
109
+ timeout = int(os.environ.get("DOWNLOAD_TIMEOUT", "180"))
110
+
111
+ try:
112
+ url = download_url(args.url, args.source)
113
+ baseline = snapshot(downloads_dir)
114
+
115
+ print("Opening browser...", file=sys.stderr)
116
+ webbrowser.open(url)
117
+
118
+ downloaded_file = wait_for_download(downloads_dir, baseline, timeout)
119
+ except (ValueError, TimeoutError, OSError) as e:
120
+ print(f"Error: {e}", file=sys.stderr)
121
+ sys.exit(1)
122
+
123
+ if args.output:
124
+ final_file = Path(args.output).resolve()
125
+ final_file.parent.mkdir(parents=True, exist_ok=True)
126
+ else:
127
+ final_file = Path(tempfile.mkdtemp(prefix="navigator-download-")) / downloaded_file.name
128
+
129
+ shutil.move(downloaded_file, final_file)
130
+
131
+ print("Download succeeded:", final_file, file=sys.stderr)
132
+ print(final_file)
133
+
134
+
135
+ if __name__ == "__main__":
136
+ main()
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ad_localize
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.3.0
4
+ version: 6.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Edouard Siegel
@@ -21,7 +21,7 @@ authors:
21
21
  autorequire:
22
22
  bindir: exe
23
23
  cert_chain: []
24
- date: 2024-11-04 00:00:00.000000000 Z
24
+ date: 2026-10-02 00:00:00.000000000 Z
25
25
  dependencies:
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: activesupport
@@ -57,6 +57,26 @@ dependencies:
57
57
  - - "~>"
58
58
  - !ruby/object:Gem::Version
59
59
  version: '1.0'
60
+ - !ruby/object:Gem::Dependency
61
+ name: csv
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '3.2'
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 3.2.7
70
+ type: :runtime
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - "~>"
75
+ - !ruby/object:Gem::Version
76
+ version: '3.2'
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: 3.2.7
60
80
  - !ruby/object:Gem::Dependency
61
81
  name: google-apis-sheets_v4
62
82
  requirement: !ruby/object:Gem::Requirement
@@ -85,9 +105,23 @@ dependencies:
85
105
  - - "~>"
86
106
  - !ruby/object:Gem::Version
87
107
  version: 0.33.0
108
+ - !ruby/object:Gem::Dependency
109
+ name: rubyXL
110
+ requirement: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - "~>"
113
+ - !ruby/object:Gem::Version
114
+ version: '3.4'
115
+ type: :runtime
116
+ prerelease: false
117
+ version_requirements: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - "~>"
120
+ - !ruby/object:Gem::Version
121
+ version: '3.4'
88
122
  description: |-
89
123
  AdLocalize produces localization files from platform agnostic wording.
90
- Supported wording format : CSV. Supported export format: iOS, Android, JSON and YAML
124
+ Supported wording format : CSV, XLSX. Supported export format: iOS, Android, JSON and YAML
91
125
  email:
92
126
  - joanna.vigne@fabernovel.com
93
127
  - pierre.felgines@fabernovel.com
@@ -144,6 +178,7 @@ files:
144
178
  - lib/ad_localize/parsers/key_parser.rb
145
179
  - lib/ad_localize/repositories/drive_repository.rb
146
180
  - lib/ad_localize/repositories/file_system_repository.rb
181
+ - lib/ad_localize/repositories/office_repository.rb
147
182
  - lib/ad_localize/requests/export_request.rb
148
183
  - lib/ad_localize/sanitizers/ios_sanitizer.rb
149
184
  - lib/ad_localize/sanitizers/ios_to_android_sanitizer.rb
@@ -164,6 +199,8 @@ files:
164
199
  - lib/ad_localize/version.rb
165
200
  - lib/ad_localize/view_models/compound_wording_view_model.rb
166
201
  - lib/ad_localize/view_models/simple_wording_view_model.rb
202
+ - mise.toml
203
+ - scripts/download_with_navigator
167
204
  homepage: https://github.com/applidium/ad_localize
168
205
  licenses:
169
206
  - MIT
@@ -177,7 +214,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
177
214
  requirements:
178
215
  - - ">="
179
216
  - !ruby/object:Gem::Version
180
- version: '3.1'
217
+ version: '3.2'
181
218
  - - "<"
182
219
  - !ruby/object:Gem::Version
183
220
  version: '4.0'
@@ -187,7 +224,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
187
224
  - !ruby/object:Gem::Version
188
225
  version: '0'
189
226
  requirements: []
190
- rubygems_version: 3.5.16
227
+ rubygems_version: 3.4.10
191
228
  signing_key:
192
229
  specification_version: 4
193
230
  summary: AdLocalize helps with mobile and web applications wording