datanorm 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +154 -0
- data/lib/datanorm/document.rb +55 -0
- data/lib/datanorm/documents/assemble.rb +43 -0
- data/lib/datanorm/documents/assembles/price.rb +85 -0
- data/lib/datanorm/documents/assembles/product.rb +176 -0
- data/lib/datanorm/documents/preprocess.rb +36 -0
- data/lib/datanorm/documents/preprocesses/cache.rb +76 -0
- data/lib/datanorm/documents/preprocesses/process.rb +80 -0
- data/lib/datanorm/file.rb +65 -0
- data/lib/datanorm/header.rb +46 -0
- data/lib/datanorm/headers/v4/date.rb +39 -0
- data/lib/datanorm/headers/v4/version.rb +36 -0
- data/lib/datanorm/headers/v5/date.rb +25 -0
- data/lib/datanorm/headers/v5/version.rb +36 -0
- data/lib/datanorm/helpers/filename.rb +20 -0
- data/lib/datanorm/helpers/utf8.rb +20 -0
- data/lib/datanorm/lines/base.rb +67 -0
- data/lib/datanorm/lines/parse.rb +33 -0
- data/lib/datanorm/lines/v4/dimension.rb +44 -0
- data/lib/datanorm/lines/v4/extra.rb +55 -0
- data/lib/datanorm/lines/v4/parse.rb +42 -0
- data/lib/datanorm/lines/v4/price.rb +120 -0
- data/lib/datanorm/lines/v4/priceset.rb +42 -0
- data/lib/datanorm/lines/v4/product.rb +90 -0
- data/lib/datanorm/lines/v4/text.rb +31 -0
- data/lib/datanorm/lines/v5/dimension.rb +22 -0
- data/lib/datanorm/lines/v5/parse.rb +29 -0
- data/lib/datanorm/lines/v5/price.rb +27 -0
- data/lib/datanorm/lines/v5/product.rb +42 -0
- data/lib/datanorm/lines/v5/text.rb +30 -0
- data/lib/datanorm/logger.rb +15 -0
- data/lib/datanorm/logging.rb +27 -0
- data/lib/datanorm/progress.rb +26 -0
- data/lib/datanorm/version.rb +5 -0
- data/lib/datanorm.rb +49 -0
- metadata +158 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: f44ab02c4041e106b7e120a1eae889c7d3e1384c4dbaedd6adea4bc86a0d1895
|
4
|
+
data.tar.gz: 338c389dded4de592d34d9730080bbf800052c32359a2294fea1aaa45d25b99b
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 342576af02ba6ae033e97090908944045896e2bd2df1e496ec83e08835945e737051a62d139300dd10f1d9711ac1e88901e94715e0d060024390975ed2e10e39
|
7
|
+
data.tar.gz: 535491f2403475fb0227e6e4e81db3b4c43bb512b70640d04852a110359c74816d1ec3b02ab959a7d83a9fed62269a54a21401efb7db1a24025e629d3c7f271b
|
data/README.md
ADDED
@@ -0,0 +1,154 @@
|
|
1
|
+
# About Datanorm
|
2
|
+
|
3
|
+
Datanorm is a German legacy file format for serialization of B2B product data (stock lists) optimized for floppy disks and monospace needle printers. Version 4 was published in 1994 and version 5 in 1999, since then there has been no change in the format. Earlier versions date back to 1986 and are practically extinct.
|
4
|
+
|
5
|
+
30 years later, it is still the de-facto standard used by German product suppliers (especially electricity and plumbing) to communicate to their business clients, which products can be bought at which price.
|
6
|
+
|
7
|
+
Disadvantages:
|
8
|
+
|
9
|
+
* One line of text (e.g. a product name or description) is limited to 40 characters. This is because of monospace fonts where a quotation or an invoice would have column limitations. There is no reliable way to convert this into flowing text (mostly because of bullet lists).
|
10
|
+
|
11
|
+
* One product can only have one price.
|
12
|
+
So the suppliers will typically export multiple Datanorm files to their clients: one with list prices, one with discounted buying prices and one with recommended selling prices. Or, they'll provide a second file that only contains various prices.
|
13
|
+
|
14
|
+
* The file encoding is not UTF-8 but `CP850`, common in DOS in Western Europe.
|
15
|
+
|
16
|
+
* There are cross-references between lines (and even parts of lines) within a single Datanorm file, that make parsing very complicated and inefficient (Datanorm files are commonly between 1 MB and 3 GB large).
|
17
|
+
|
18
|
+
* Version 4, still most commonly used, has special quirks, such as only allowing a product quantity unit of 1, 10, 100 or 1000. So you cannot have a product sold in packs of, say 25.
|
19
|
+
|
20
|
+
* One line in the file for prices "P" may actually be a set of prices for up to three same or different products. Thus data that belongs together separated by new lines at seemingly random places.
|
21
|
+
|
22
|
+
* Over the years, people started working around the standard to overcome its limitations. In other words, every company that exports Datanorm, uses data fields in different ways to communicate different things and many structure the content of the file differently. For example, one file for creating products, one for amending existing products and one for doing both. Another example is to use product descriptions and product category descriptions interchangeably, and using free text fields to override normalized values that are wrong.
|
23
|
+
|
24
|
+
* To my knowledge, there is no documentation publicly available on the Internet. Maybe old books in some library have it.
|
25
|
+
|
26
|
+
If you were ever wondering why Germany has so much bureaucracy, it's because they like to cling on to things.
|
27
|
+
|
28
|
+
### File types
|
29
|
+
|
30
|
+
There are various files with various extensions, but the main ones are those that are called `DATANORM.001`, `DATANORM.002`, etc. They have this convention because those files used to be on one floppy disk each. Nowadays you only have `DATANORM.001`, which can be several GB large.
|
31
|
+
|
32
|
+
Other file types are `.RAB` (for discount groups) and `.WRG` (for product categories), but we don't support them yet (not difficult to implement, though).
|
33
|
+
|
34
|
+
### Main Datanorm file format
|
35
|
+
|
36
|
+
In Datanorm, *one line* in the file represents one record. The most common ones are:
|
37
|
+
|
38
|
+
* The very first line in the file is the header that identifies the Datanorm version (4 or 5) and a date (indicating when the prices are in effect). In V4 the fields are separated by fixed-length and in V5 they are separated by semicolon.
|
39
|
+
|
40
|
+
* Sometimes there is a `V` record as second line of the file, it is additional information such as the number of a printed catalogue.
|
41
|
+
|
42
|
+
* One `A` record represent one product. It is identified by a product ID that is usually unique per one supplier (and thus, it is unique within one Datanorm file of a supplier). So there is only one `A` record with a given ID in one file. This is the most important kind of record and holds product name, short description and price. It can be located anywhere in the file and can reference multiple other records (that is, lines anywhere else in the file), such as records with long text descriptions.
|
43
|
+
|
44
|
+
* The `B` record is mostly relevant in V4 and holds additional product data, such as an EAN code. In V5 it is used as a DELETE statement for a product (so that the supplier can tell the business client that this product is now or soon deprecated), but we don't support really that feature. The `B` record has the same ID as the `A` record, so you know that they belong together. There is only one `B` record per product and within a Datanorm file, the `B` record is usually located one line below the `A` record.
|
45
|
+
|
46
|
+
* The `D` record represents one line of the description of one product. So you have multiple `D` records where each of them has the same ID as the product. Additionally, each record holds one line number, so you know which of those records represents which line of the product description. To complicate things further, each `D` records has two text fields (of course each limited to 40 characters), so most people put two lines of text into one `D` record (say, records for line 1, 3, and 5, which in reality represent the product description lines 1-6). The `D` records are usually located below or above the `A` record.
|
47
|
+
|
48
|
+
* `T` records were originally meant to be long texts that similar products could reference to. So, in addition to the description `D` for one single product, each product could additionally reference one `T` text that it might share with other products. But in practice they are not shared and `T` is often used instead of `D`. In other words, sometimes every single product has one single set of `T` records just for that one product. What makes `T` records complicated, in terms of file processing, is that each set of `T` records has a unique ID that is not related to any product. Rather, a product will reference that (made-up) text record set ID to indicate that those `T` records hold the long text description for a product. But the `T` records could be anywhere else in the file, so it's hard to parse.
|
49
|
+
|
50
|
+
* Usually, one `A` record holds one price for that product. But there also exist so called `P` records (one for *multiple* products) that hold up to three product IDs and corresponding prices for those products. All these `P` price records are delivered in a separate file called `DATPREIS.001`, because you need fewer floppy disks if you only regularly update the prices rather than the entirety of all product details (given you already have imported them before). Sometimes you're dependent on those `P` records from another file, because the `A` record may specify a recommended selling price whereas the corresponding `P` may have discount details that tell you the purchase price.
|
51
|
+
|
52
|
+
* Only in V5 there exists a `C` record that specifies additional data for one product, such as public tender descriptions and how much work time it normally takes to physically install a product.
|
53
|
+
|
54
|
+
# About this Rubygem
|
55
|
+
|
56
|
+
What you find here is the minimal Ruby code required to parse and loop over the content of a Datanorm file and works predominantly with version 4. Version 5 support is not fully implemented.
|
57
|
+
|
58
|
+
It does not cover all features that Datanorm has, but it supports everything to get you started with the most common data attributes.
|
59
|
+
|
60
|
+
If you encounter a parsing or price calculation bug, it's likely that I didn't encounter your Datanorm file yet. I appreciate your pull request in that case.
|
61
|
+
|
62
|
+
### Parsing technique
|
63
|
+
|
64
|
+
As explained above, there are cross-references within the Datanorm file, where one line may reference another, which is located at an arbitrary line position.
|
65
|
+
|
66
|
+
If you're lucky, the file only contains `A` and `D` records that are located closely to one another, which makes linear file parsing somewhat possible.
|
67
|
+
|
68
|
+
If you 're out of luck, there are a bunch of `T` records at the beginning of the file, then some `A` and `B` records, or the other way around.
|
69
|
+
|
70
|
+
If you have `P` records, it's really complex, because one `P` record can represent one price of one product, or two different prices for the same product, or three different prices for three entirely different products.
|
71
|
+
|
72
|
+
You, as a Ruby developer, would most likely do something like this:
|
73
|
+
|
74
|
+
```ruby
|
75
|
+
Datanorm::Document.new(path:).each do |item|
|
76
|
+
# Here you would have *one* Object that represents
|
77
|
+
# *one* product and *all* its attributes.
|
78
|
+
end
|
79
|
+
```
|
80
|
+
|
81
|
+
That's what we're doing. For that to work, however, we need to
|
82
|
+
|
83
|
+
* parse the entire file (may take many minutes for large files)
|
84
|
+
* remember, categorize and cache the data (on disk, because you don't want 3 GB in RAM)
|
85
|
+
* then enumerate over every product
|
86
|
+
* while doing so, gather the referenced attributes that belong to each product (sometimes referencing one part of one line, as in "P" record sets)
|
87
|
+
* wrap it all in a Ruby object and yield it as UTF-8 to you
|
88
|
+
|
89
|
+
I know there are smart ways to make this faster. But "being smart" was probably the reason that the file format grew in complexity in the first place.
|
90
|
+
|
91
|
+
I went for a parsing mechanism that works every time, with every file, at the expense of running a little bit longer than needed.
|
92
|
+
|
93
|
+
## Usage
|
94
|
+
|
95
|
+
If you have a `DATANORM.001` and also a `DATPREIS.001`, you must concatenate those two files into one file first (their versions need to be the same). The resulting, merged file is what you provide to this Rubygem.
|
96
|
+
|
97
|
+
If you want one product at a time, without having to deal with the complexities of Datanorm, you can use this:
|
98
|
+
|
99
|
+
```ruby
|
100
|
+
document = Datanorm::Document.new(path: 'datanorm.001')
|
101
|
+
|
102
|
+
puts document.header
|
103
|
+
puts document.version
|
104
|
+
|
105
|
+
document.each do |product, progress|
|
106
|
+
# Once pre-processing is complete, you'll start to get products here
|
107
|
+
puts product # <- can be nil in the beginning
|
108
|
+
|
109
|
+
# You can always look at the progress to see what's going on.
|
110
|
+
puts progress if progress.significant? # Throttling, so your STDOUT doesn't get spammed.
|
111
|
+
end
|
112
|
+
```
|
113
|
+
|
114
|
+
In case you only want the raw Datanorm file one line at a time as Ruby Objects, you can use this:
|
115
|
+
|
116
|
+
```ruby
|
117
|
+
file = Datanorm::File.new(path: 'datanorm.001')
|
118
|
+
|
119
|
+
puts file.header
|
120
|
+
puts file.version
|
121
|
+
puts file.lines_count
|
122
|
+
|
123
|
+
file.each do |record, line_number|
|
124
|
+
puts record
|
125
|
+
end
|
126
|
+
```
|
127
|
+
|
128
|
+
**Debugging**
|
129
|
+
|
130
|
+
You can set the ENV variable `DEBUG_DATANORM=1` for verbose logging output.
|
131
|
+
You can also inspect the denormalization cache located at `/tmp/datanorm_ruby`
|
132
|
+
(it won't be automatically deleted if you set the `DEBUG_DATANORM` flag).
|
133
|
+
|
134
|
+
## Development
|
135
|
+
|
136
|
+
Throughout the code, the following terms are used:
|
137
|
+
|
138
|
+
* `line` is one line of a Datanorm file in its raw format.
|
139
|
+
* `record` is one Ruby Object representing one of those `line`s.
|
140
|
+
* `product` is a product (article) and all its (immediate and referenced) attributes.
|
141
|
+
|
142
|
+
Run unit tests with `bin/tests`.
|
143
|
+
|
144
|
+
To get you started, you can run `bin/demo path/to/your/datanorm.001` to show its contents.
|
145
|
+
|
146
|
+
There are a few example Datanorm files in the test folder, but their characters don't always have the standard Datanorm CP850 encoding (through my tooling I often accidentally convert to UTF-8 or ASCII). Fĭx iẗ iƒ ȳou çaƞ.
|
147
|
+
|
148
|
+
## Open Source Maintenance
|
149
|
+
|
150
|
+
This software is release under the MIT license (see LICENSE.md).
|
151
|
+
|
152
|
+
I already anticipate people sending me their various Datanorm files, thinking that I can fix their problems, but I really don't want to 😂.
|
153
|
+
|
154
|
+
Let me be clear: Nobody should use this data format. It's from the digital stone age. If you have to parse it in Ruby and need more features, I'll gladly welcome a pull request with proper test coverage.
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Datanorm
|
4
|
+
# Loads and parses a datanorm file product by product.
|
5
|
+
class Document
|
6
|
+
include Datanorm::Logging
|
7
|
+
include Enumerable
|
8
|
+
|
9
|
+
attr_reader :path
|
10
|
+
|
11
|
+
def initialize(path:, timestamp: nil)
|
12
|
+
@path = path
|
13
|
+
|
14
|
+
if timestamp
|
15
|
+
# Re-use an existing workdir in case the preprocessing was already done earlier.
|
16
|
+
@timestamp = timestamp
|
17
|
+
@preprocessed = true
|
18
|
+
else
|
19
|
+
@timestamp = (Time.now.to_f * 1_000_000_000).to_i.to_s # Timestamp with nanoseconds
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def header
|
24
|
+
file.header
|
25
|
+
end
|
26
|
+
|
27
|
+
def version
|
28
|
+
file.version
|
29
|
+
end
|
30
|
+
|
31
|
+
def each(&)
|
32
|
+
unless @preprocessed
|
33
|
+
::Datanorm::Documents::Preprocess.call(file:, workdir:, &)
|
34
|
+
@preprocessed = true
|
35
|
+
end
|
36
|
+
|
37
|
+
::Datanorm::Documents::Assemble.call(workdir:, &)
|
38
|
+
ensure
|
39
|
+
# At this point all yields have gone through and we can clean up.
|
40
|
+
workdir.rmtree unless ENV['DEBUG_DATANORM']
|
41
|
+
end
|
42
|
+
|
43
|
+
def workdir
|
44
|
+
@workdir ||= Pathname.new('/tmp/datanorm_ruby').join(@timestamp)
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def file
|
50
|
+
return @file if defined?(@file)
|
51
|
+
|
52
|
+
@file = ::Datanorm::File.new(path:)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Datanorm
|
4
|
+
module Documents
|
5
|
+
# Yields every product found in the text files that the preprocessing generated.
|
6
|
+
class Assemble
|
7
|
+
include Calls
|
8
|
+
include ::Datanorm::Logging
|
9
|
+
|
10
|
+
option :workdir
|
11
|
+
|
12
|
+
def call
|
13
|
+
return unless products_file.file?
|
14
|
+
|
15
|
+
::File.foreach(products_file) do |json|
|
16
|
+
progress.increment!
|
17
|
+
yield ::Datanorm::Documents::Assembles::Product.new(json:, workdir:), progress
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def products_file
|
24
|
+
workdir.join('A.txt')
|
25
|
+
end
|
26
|
+
|
27
|
+
def products_count
|
28
|
+
return @products_count if defined?(@products_count)
|
29
|
+
|
30
|
+
@products_count = 0
|
31
|
+
::File.foreach(products_file) { @products_count += 1 }
|
32
|
+
@products_count
|
33
|
+
end
|
34
|
+
|
35
|
+
def progress
|
36
|
+
@progress ||= ::Datanorm::Progress.new.tap do |progress|
|
37
|
+
progress.current = 0
|
38
|
+
progress.total = products_count
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Datanorm
|
4
|
+
module Documents
|
5
|
+
module Assembles
|
6
|
+
# Object wrapper for a single Price (that belongs to a Priceset).
|
7
|
+
class Price
|
8
|
+
attr_reader :as_json
|
9
|
+
|
10
|
+
def initialize(json:)
|
11
|
+
@as_json = JSON.parse(json, symbolize_names: true)
|
12
|
+
end
|
13
|
+
|
14
|
+
# -----------------
|
15
|
+
# Native Attributes
|
16
|
+
# -----------------
|
17
|
+
|
18
|
+
def wholesale?
|
19
|
+
as_json[:is_wholesale]
|
20
|
+
end
|
21
|
+
|
22
|
+
def retail?
|
23
|
+
as_json[:is_retail]
|
24
|
+
end
|
25
|
+
|
26
|
+
def no_discount?
|
27
|
+
as_json[:is_no_discount]
|
28
|
+
end
|
29
|
+
|
30
|
+
def percentage_discount?
|
31
|
+
as_json[:is_percentage_discount]
|
32
|
+
end
|
33
|
+
|
34
|
+
def discount_percentage_integer
|
35
|
+
as_json[:discount_percentage]
|
36
|
+
end
|
37
|
+
|
38
|
+
def cents
|
39
|
+
as_json[:cents].to_i
|
40
|
+
end
|
41
|
+
|
42
|
+
# ---------------------
|
43
|
+
# Calculated Attributes
|
44
|
+
# ---------------------
|
45
|
+
|
46
|
+
def price
|
47
|
+
BigDecimal(cents) / 100
|
48
|
+
end
|
49
|
+
|
50
|
+
def discount_percentage
|
51
|
+
return unless percentage_discount?
|
52
|
+
|
53
|
+
# 3700 == 37% == 0.37
|
54
|
+
BigDecimal(discount_percentage_integer) / 100 / 100
|
55
|
+
end
|
56
|
+
|
57
|
+
# What is the final price after the discount?
|
58
|
+
def price_after_discount
|
59
|
+
return price if no_discount?
|
60
|
+
return unless discount_percentage
|
61
|
+
|
62
|
+
price * (1 - discount_percentage)
|
63
|
+
end
|
64
|
+
|
65
|
+
# Helpers
|
66
|
+
|
67
|
+
def <=>(other)
|
68
|
+
precedence <=> other.precedence
|
69
|
+
end
|
70
|
+
|
71
|
+
# So we can distinguish between multiple conflicting prices.
|
72
|
+
def precedence
|
73
|
+
return 2 if no_discount?
|
74
|
+
return 1 if percentage_discount?
|
75
|
+
|
76
|
+
0
|
77
|
+
end
|
78
|
+
|
79
|
+
def to_s
|
80
|
+
"<Price #{as_json}>"
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,176 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Datanorm
|
4
|
+
module Documents
|
5
|
+
module Assembles
|
6
|
+
# Object wrapper for a single Product with all its attributes.
|
7
|
+
class Product
|
8
|
+
include ::Datanorm::Logging
|
9
|
+
|
10
|
+
def initialize(json:, workdir:)
|
11
|
+
@json = JSON.parse(json, symbolize_names: true)
|
12
|
+
@workdir = workdir
|
13
|
+
|
14
|
+
load_files!
|
15
|
+
end
|
16
|
+
|
17
|
+
# ------
|
18
|
+
# Basics
|
19
|
+
# ------
|
20
|
+
|
21
|
+
def id
|
22
|
+
json[:id]
|
23
|
+
end
|
24
|
+
|
25
|
+
def quantity
|
26
|
+
json[:quantity]
|
27
|
+
end
|
28
|
+
|
29
|
+
def quantity_unit
|
30
|
+
json[:quantity_unit]
|
31
|
+
end
|
32
|
+
|
33
|
+
def discount_group
|
34
|
+
json[:discount_group]
|
35
|
+
end
|
36
|
+
|
37
|
+
# -------
|
38
|
+
# Textual
|
39
|
+
# -------
|
40
|
+
|
41
|
+
def title
|
42
|
+
json[:title]
|
43
|
+
end
|
44
|
+
|
45
|
+
def text_id
|
46
|
+
json[:text_id]
|
47
|
+
end
|
48
|
+
|
49
|
+
def description
|
50
|
+
# In theory, the dimension is for this product only
|
51
|
+
# and the text shared by several products of the same kind.
|
52
|
+
# In practice, those two are not intended for stacking.
|
53
|
+
# Instead, we choose one or the other.
|
54
|
+
return dimension_content if dimension_content && !dimension_content.strip.empty?
|
55
|
+
|
56
|
+
text_content
|
57
|
+
end
|
58
|
+
|
59
|
+
# -----------------------
|
60
|
+
# Immediate Price details
|
61
|
+
# -----------------------
|
62
|
+
|
63
|
+
def retail_price?
|
64
|
+
json[:is_retail_price]
|
65
|
+
end
|
66
|
+
|
67
|
+
def wholesale_price?
|
68
|
+
json[:is_wholesale_price]
|
69
|
+
end
|
70
|
+
|
71
|
+
def cents
|
72
|
+
json[:cents]
|
73
|
+
end
|
74
|
+
|
75
|
+
# Convenience shortcut.
|
76
|
+
def price
|
77
|
+
BigDecimal(cents) / 100
|
78
|
+
end
|
79
|
+
|
80
|
+
# ------------------------
|
81
|
+
# Referenced Price details
|
82
|
+
# ------------------------
|
83
|
+
|
84
|
+
def prices
|
85
|
+
return @prices if defined?(@prices)
|
86
|
+
|
87
|
+
@prices = prices_content&.split("\n")&.map do |json|
|
88
|
+
::Datanorm::Documents::Assembles::Price.new(json:)
|
89
|
+
end || []
|
90
|
+
end
|
91
|
+
|
92
|
+
# -----------------
|
93
|
+
# Referenced Extras
|
94
|
+
# -----------------
|
95
|
+
|
96
|
+
def matchcode
|
97
|
+
extra_json[:matchcode]
|
98
|
+
end
|
99
|
+
|
100
|
+
def alternative_id
|
101
|
+
extra_json[:alternative_id]
|
102
|
+
end
|
103
|
+
|
104
|
+
def ean
|
105
|
+
extra_json[:ean]
|
106
|
+
end
|
107
|
+
|
108
|
+
def category_id
|
109
|
+
extra_json[:category_id]
|
110
|
+
end
|
111
|
+
|
112
|
+
# -------
|
113
|
+
# Helpers
|
114
|
+
# -------
|
115
|
+
|
116
|
+
def to_s
|
117
|
+
"<Product #{as_json}>"
|
118
|
+
end
|
119
|
+
|
120
|
+
def as_json
|
121
|
+
# Adding referenced attributes that were cached to disk during preprocessing.
|
122
|
+
json.merge(description:, prices: prices.map(&:as_json)).merge(extra_json)
|
123
|
+
end
|
124
|
+
|
125
|
+
def to_json(...)
|
126
|
+
as_json.to_json(...)
|
127
|
+
end
|
128
|
+
|
129
|
+
private
|
130
|
+
|
131
|
+
attr_reader :json, :workdir
|
132
|
+
|
133
|
+
# The temporary cached files may be deleted quickly, so let's fetch what we need.
|
134
|
+
# effectively populates all data we need
|
135
|
+
alias load_files! as_json
|
136
|
+
|
137
|
+
def dimension_content
|
138
|
+
return @dimension_content if defined?(@dimension_content)
|
139
|
+
|
140
|
+
@dimension_content = begin
|
141
|
+
path = workdir.join('D', ::Datanorm::Helpers::Filename.call(id))
|
142
|
+
path.read if path.file?
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
def text_content
|
147
|
+
return unless text_id
|
148
|
+
return @text_content if defined?(@text_content)
|
149
|
+
|
150
|
+
@text_content = begin
|
151
|
+
path = workdir.join('T', ::Datanorm::Helpers::Filename.call(text_id))
|
152
|
+
path.read if path.file?
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
def extra_json
|
157
|
+
return @extra_json if defined?(@extra_json)
|
158
|
+
|
159
|
+
@extra_json = begin
|
160
|
+
path = workdir.join('B', ::Datanorm::Helpers::Filename.call(id))
|
161
|
+
JSON.parse(path.read, symbolize_names: true) if path.file?
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
def prices_content
|
166
|
+
return @prices_content if defined?(@prices_content)
|
167
|
+
|
168
|
+
@prices_content = begin
|
169
|
+
path = workdir.join('P', ::Datanorm::Helpers::Filename.call(id))
|
170
|
+
path.read if path.file?
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Datanorm
|
4
|
+
module Documents
|
5
|
+
# Takes an entire Datanorm file and writes many small text files from it
|
6
|
+
# so that the content can later be iterated over in an efficient way.
|
7
|
+
class Preprocess
|
8
|
+
include Calls
|
9
|
+
include ::Datanorm::Logging
|
10
|
+
|
11
|
+
option :file
|
12
|
+
option :workdir
|
13
|
+
|
14
|
+
def call
|
15
|
+
FileUtils.mkdir_p(workdir)
|
16
|
+
|
17
|
+
file.each do |record|
|
18
|
+
::Datanorm::Documents::Preprocesses::Process.call(workdir:, record:)
|
19
|
+
|
20
|
+
progress.increment!
|
21
|
+
yield nil, progress # No items to yield during preprocess.
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def progress
|
28
|
+
@progress ||= ::Datanorm::Progress.new.tap do |progress|
|
29
|
+
progress.title = 'Preprocessing'
|
30
|
+
progress.current = 0
|
31
|
+
progress.total = file.lines_count
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Datanorm
|
4
|
+
module Documents
|
5
|
+
module Preprocesses
|
6
|
+
# Writes a Datanorm record to disk for later retrieval.
|
7
|
+
class Cache
|
8
|
+
include Calls
|
9
|
+
include ::Datanorm::Logging
|
10
|
+
|
11
|
+
option :workdir, as: :parent_workdir
|
12
|
+
option :namespace
|
13
|
+
option :id
|
14
|
+
option :target_line_number, default: -> { 1 }
|
15
|
+
option :content, as: :raw_content # Encoding::CP850
|
16
|
+
|
17
|
+
def call
|
18
|
+
do_ensure_workdir
|
19
|
+
.on_success { do_read_currently_cached_lines }
|
20
|
+
.on_success { do_amend_content }
|
21
|
+
.on_success { do_write_to_file }
|
22
|
+
end
|
23
|
+
|
24
|
+
def do_ensure_workdir
|
25
|
+
return Tron.success :workdir_exists if workdir.directory?
|
26
|
+
|
27
|
+
log { "Creating working dir `#{workdir}`" }
|
28
|
+
FileUtils.mkdir_p(workdir)
|
29
|
+
Tron.success(:workdir_created)
|
30
|
+
end
|
31
|
+
|
32
|
+
def do_read_currently_cached_lines
|
33
|
+
if filepath.exist?
|
34
|
+
@lines = filepath.readlines(chomp: true)
|
35
|
+
Tron.success(:loaded_current_file_content)
|
36
|
+
else
|
37
|
+
@lines = []
|
38
|
+
Tron.success(:nothing_cached_yet)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def do_amend_content
|
43
|
+
# Populate every line up until the wanted line.
|
44
|
+
@lines[target_line_number - 1] ||= ''
|
45
|
+
|
46
|
+
# Insert the content
|
47
|
+
@lines[target_line_number - 1] = content
|
48
|
+
|
49
|
+
Tron.success(:inserted_content)
|
50
|
+
end
|
51
|
+
|
52
|
+
def do_write_to_file
|
53
|
+
log { "Writing line(s) at position #{target_line_number} to #{filepath}" }
|
54
|
+
filepath.write @lines.join("\n")
|
55
|
+
|
56
|
+
Tron.success :wrote_to_cache
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def content
|
62
|
+
raw_content.encode 'UTF-8'
|
63
|
+
end
|
64
|
+
|
65
|
+
def filepath
|
66
|
+
workdir.join(::Datanorm::Helpers::Filename.call(id))
|
67
|
+
end
|
68
|
+
|
69
|
+
def workdir
|
70
|
+
namespace_parts = Array(namespace)
|
71
|
+
parent_workdir.join(*namespace_parts)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|