packwizard-parser 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1698bc9057932aac887cd13f6915783b0e6145889a88a7aff87edcf063f26ca1
4
+ data.tar.gz: ca8d40daa24f1cfff4d53d344b5297612697613e021df223f27147c56d4893e7
5
+ SHA512:
6
+ metadata.gz: '028bcb01a18923c2f3f1a2203ed5c534c7c9cbeb742c6b61b1b8decefd1d7261689bbfff75f1de2401c31535376ba812c61fc0ecd4db98e0505cb25f0690972a'
7
+ data.tar.gz: ec0976146390b00f5d8c9c99be876c4720fd0373e84947d38c452d579f80ce03cd4d7f572fe319852941dd4373dcbd8033e4db96b433589dbcf4c83c6461a198
data/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # PackWizard Parser
2
+
3
+ A Ruby gem for parsing PackWizard lists from URLs.
4
+
5
+ Used by [Goulight](https://goulight.com). Source: [github.com/goulight/packwizard-parser](https://github.com/goulight/packwizard-parser).
6
+
7
+ ## Installation
8
+
9
+ Add to your `Gemfile`:
10
+
11
+ ```ruby
12
+ gem 'packwizard-parser', git: 'https://github.com/goulight/packwizard-parser.git'
13
+ ```
14
+
15
+ Then:
16
+
17
+ ```bash
18
+ bundle install
19
+ ```
20
+
21
+ Or install directly:
22
+
23
+ ```bash
24
+ gem install packwizard-parser
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ `PackwizardParser::Parser#parse` (and `PackwizardParser.parse_url`) return a **`PackwizardParser::List`** object with readers `name`, `description`, and `categories`. Each category is a **`PackwizardParser::Category`**; each item is a **`PackwizardParser::Item`**.
30
+
31
+ Call **`#to_h`** on the list (or on categories/items) if you need nested hashes (for example JSON APIs).
32
+
33
+ ### Parse from URL or ID
34
+
35
+ ```ruby
36
+ require 'packwizard_parser'
37
+
38
+ # With full URL
39
+ list = PackwizardParser::Parser.new(shareable_id: 'https://www.packwizard.com/s/Dv5388R').parse
40
+
41
+ # With ID
42
+ list = PackwizardParser::Parser.new(shareable_id: 'Dv5388R').parse
43
+
44
+ # Or using the convenience method
45
+ list = PackwizardParser.parse_url('https://www.packwizard.com/s/Dv5388R')
46
+ list = PackwizardParser.parse_url('Dv5388R')
47
+
48
+ list.name # => "List Name"
49
+ list.description # => "List description" or nil
50
+ list.categories.first.name # => "Category Name"
51
+
52
+ item = list.categories.first.items.first
53
+ item.name # => "Item Name"
54
+ item.description # => "Item description" or nil
55
+ item.weight # => 476.0 # grams per unit
56
+ item.total_weight # => 476.0 # weight * quantity (grams)
57
+ item.quantity # => 1
58
+ item.image_url # => "https://..." or nil
59
+ item.consumable # => false
60
+ item.total_consumable_weight # => nil (or total grams if consumable)
61
+ item.worn # => false
62
+ item.worn_quantity # => 0 (or 1 if worn)
63
+ item.total_worn_weight # => 0.0 (or grams worn, weight × 1)
64
+
65
+ # Hash shape (matches nested #to_h):
66
+ list.to_h
67
+ # => {
68
+ # name: "List Name",
69
+ # description: nil,
70
+ # categories: [
71
+ # {
72
+ # name: "Category Name",
73
+ # description: nil,
74
+ # items: [
75
+ # {
76
+ # name: "Item Name",
77
+ # description: "Item description",
78
+ # weight: 476.0,
79
+ # total_weight: 476.0,
80
+ # quantity: 1,
81
+ # image_url: "https://...",
82
+ # consumable: false,
83
+ # total_consumable_weight: nil,
84
+ # worn: false,
85
+ # worn_quantity: 0,
86
+ # total_worn_weight: 0.0
87
+ # }
88
+ # ]
89
+ # }
90
+ # ]
91
+ # }
92
+ ```
93
+
94
+ ## Running Tests
95
+
96
+ To run the test suite:
97
+
98
+ ```bash
99
+ rspec
100
+ ```
101
+
102
+ ## Test Fixtures
103
+
104
+ Test fixtures are stored in `spec/fixtures/` and contain JSON from example PackWizard lists:
105
+
106
+ - `negative.json` - This data with negative / empty values
107
+ - `tUE6BJs.json` - A copy from Ultralight Gear List 2023 (Dv5388R)
108
+
109
+ To update fixtures, download fresh JSON:
110
+
111
+ ```bash
112
+ curl -s 'https://www.packwizard.com/api/packs/getSharedPackWithId?shareableId=Dv5388R' > spec/fixtures/Dv5388R.json
113
+ ```
114
+
115
+ ## Features
116
+
117
+ - Parses list name and description
118
+ - Extracts categories with descriptions
119
+ - Extracts items with:
120
+ - Name and description
121
+ - Weight per unit and total weight (automatically converted to grams)
122
+ - Quantity
123
+ - Image URLs
124
+ - Consumable flag and total consumable weight when applicable
125
+ - Worn flag, worn quantity, and total worn weight when applicable
126
+ - Supports weight units: oz, lb, g, kg (all converted to grams)
127
+ - Handles URL's
128
+
129
+ ## Weight Conversion
130
+
131
+ The parser automatically converts all weights to grams:
132
+
133
+ - `oz` → multiply by 28.3495
134
+ - `lb` → multiply by 453.592
135
+ - `g` → use as-is
136
+ - `kg` → multiply by 1000
137
+
138
+ ## Development
139
+
140
+ To install dependencies locally:
141
+
142
+ ```bash
143
+ bundle install
144
+ ```
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackwizardParser
4
+ # Represents a Category from a PackWizard list
5
+ #
6
+ # @attr_reader [String] name The name of the category
7
+ # @attr_reader [String, nil] description Optional description of the category
8
+ # @attr_reader [Array<Item>] items Array of items in this category
9
+ class Category
10
+ attr_reader :name, :description, :items
11
+
12
+ # @param [String] name The name of the category
13
+ # @param [String, nil] description Optional description of the category
14
+ # @param [Array<Item>] items Array of items in this category
15
+ def initialize(name:, description: nil, items: [])
16
+ @name = name
17
+ @description = description
18
+ @items = items
19
+ end
20
+
21
+ # Convert to hash for backward compatibility
22
+ # @return [Hash] Hash representation of the category
23
+ def to_h
24
+ {
25
+ name: name,
26
+ description: description,
27
+ items: items.map(&:to_h)
28
+ }
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackwizardParser
4
+ # Parser for extracting category data from PackWizard JSON
5
+ class CategoryParser
6
+ # Parse all categories from a PackWizard JSON
7
+ #
8
+ # @param data [Hash] The parsed JSON response
9
+ # @param item_parser [ItemParser] The parser used for extracting items from each category
10
+ # @return [Array<Category>] Array of extracted categories
11
+ def parse_all(data, item_parser:)
12
+ categories = []
13
+
14
+ # tableData contains the arrays of each category
15
+ data['tableData'].each do |category_data|
16
+ category = parse(category_data, item_parser: item_parser)
17
+ categories << category if category
18
+ end
19
+ categories
20
+ end
21
+
22
+ private
23
+
24
+ # Parse a single category row
25
+ #
26
+ # @param category_data [Hash] A single entry from data['tableData']
27
+ def parse(category_data, item_parser:)
28
+ name = extract_name(category_data)
29
+
30
+ description = extract_description(category_data)
31
+
32
+ items = extract_items(category_data, item_parser: item_parser)
33
+
34
+ Category.new(name: name, description: description, items: items)
35
+ end
36
+
37
+ def extract_name(category_data)
38
+ name = category_data['title']
39
+ return name unless name.nil? || name.empty?
40
+
41
+ 'Untitled category' # Fallback if name is not set
42
+ end
43
+
44
+ def extract_description(category_data)
45
+ description = category_data['subtitle']
46
+ return description unless description.nil? || description.empty?
47
+
48
+ nil
49
+ end
50
+
51
+ def extract_items(category_data, item_parser:)
52
+ rows = category_data['rows']
53
+ return [] unless rows
54
+
55
+ rows.values.map do |row|
56
+ item_parser.parse(row)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackwizardParser
4
+
5
+ module GramConverter
6
+ # Conversion factors for each unit to grams
7
+ FACTORS = {
8
+ 'g' => 1.0,
9
+ 'kg' => 1000.0,
10
+ 'oz' => 28.35,
11
+ 'lb' => 453.59
12
+ }.freeze
13
+
14
+ # Convert the value to grams
15
+ #
16
+ # @param value [Float] The value to convert
17
+ # @param unit [String] The unit extracted from the JSON
18
+ # @return [Float] The converted value in grams
19
+ def self.convert(value:, unit:)
20
+ factor = FACTORS.fetch(unit.to_s.downcase, 1.0)
21
+ (value * factor)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackwizardParser
4
+ # Represents a single item in a PackWizard list
5
+ #
6
+ # @attr_reader [String] name The name of the item
7
+ # @attr_reader [String, nil] description Optional description of the item
8
+ # @attr_reader [Float] weight Weight per item in grams
9
+ # @attr_reader [Float] total_weight Total weight (weight * quantity) in grams
10
+ # @attr_reader [Integer] quantity Number of items
11
+ # @attr_reader [String, nil] image_url Optional URL to item image
12
+ # @attr_reader [Boolean] consumable Whether the item is consumable
13
+ # @attr_reader [Float, nil] total_consumable_weight Total consumable weight
14
+ # (weight * quantity) if consumable, nil otherwise
15
+ # @attr_reader [Boolean] worn Whether the item is worn
16
+ # @attr_reader [Integer] worn_quantity Number of worn items (always 1 if worn, 0 otherwise)
17
+ # @attr_reader [Float] total_worn_weight Total worn weight (weight * 1) if worn, 0.0 otherwise
18
+ class Item
19
+ attr_reader :name, :description, :weight, :total_weight, :quantity, :price, :total_price, :image_url,
20
+ :consumable, :total_consumable_weight, :worn, :worn_quantity, :total_worn_weight
21
+ # @param name [String] The name of the item
22
+ # @param description [String, nil] Optional description
23
+ # @param weight [Float] Weight per item in grams
24
+ # @param total_weight [Float] Total weight (weight * quantity) in grams
25
+ # @param quantity [Integer] Number of items
26
+ # @param image_url [String, nil] Optional URL to item image
27
+ # @param consumable [Boolean] Whether the item is consumable
28
+ # @param total_consumable_weight [Float, nil] Total consumable weight if consumable
29
+ # @param worn [Boolean] Whether the item is worn
30
+ # @param worn_quantity [Integer] Number of worn items (1 if worn)
31
+ # @param total_worn_weight [Float] Total worn weight if worn
32
+ def initialize(name:, weight:, total_weight:, quantity:, description: nil,
33
+ image_url: nil, consumable: false, total_consumable_weight: nil,
34
+ worn: false, worn_quantity:, total_worn_weight:)
35
+ @name = name
36
+ @weight = weight
37
+ @description = description
38
+ @total_weight = total_weight
39
+ @quantity = quantity
40
+ @image_url = image_url
41
+ @consumable = consumable
42
+ @total_consumable_weight = total_consumable_weight
43
+ @worn = worn
44
+ @worn_quantity = worn_quantity
45
+ @total_worn_weight = total_worn_weight
46
+ end
47
+
48
+ alias worn? worn
49
+
50
+ alias consumable? consumable
51
+
52
+ # Convert to hash
53
+ #
54
+ # @return [Hash] Hash representation of the item
55
+ def to_h
56
+ {
57
+ name: name,
58
+ description: description,
59
+ weight: weight,
60
+ total_weight: total_weight,
61
+ quantity: quantity,
62
+ image_url: image_url,
63
+ consumable: consumable,
64
+ total_consumable_weight: total_consumable_weight,
65
+ worn: worn,
66
+ worn_quantity: worn_quantity,
67
+ total_worn_weight: total_worn_weight
68
+ }
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackwizardParser
4
+ class ItemParser
5
+ # Parser for extracting a single item row from PackWizard JSON
6
+ #
7
+ # @param row [Hash] A single row hash from category['rows']
8
+ # @return [Item] The parsed item
9
+ def parse(row)
10
+ name = extract_name(row)
11
+ description = extract_description(row)
12
+
13
+ weight_per_item = extract_weight(row)
14
+ quantity = extract_quantity(row)
15
+
16
+ image_url = extract_image_url(row)
17
+
18
+ consumable = extract_consumable_flag(row)
19
+ total_consumable_weight = consumable ? weight_per_item * quantity : nil
20
+
21
+ # In PackWizard, the returned worn weight is a single items weight regardless of quantity
22
+ worn = extract_worn_flag(row)
23
+ worn_quantity = worn ? 1 : 0
24
+ total_worn_weight = weight_per_item * worn_quantity
25
+
26
+ total_weight = weight_per_item * quantity
27
+
28
+ Item.new(
29
+ name: name,
30
+ description: description,
31
+ weight: weight_per_item,
32
+ total_weight: total_weight,
33
+ quantity: quantity,
34
+ image_url: image_url,
35
+ consumable: consumable,
36
+ total_consumable_weight: total_consumable_weight,
37
+ worn: worn,
38
+ worn_quantity: worn_quantity,
39
+ total_worn_weight: total_worn_weight
40
+ )
41
+ end
42
+
43
+ private
44
+
45
+ def extract_name(row)
46
+ item_name = row['item']
47
+ return item_name unless item_name.nil? || item_name.empty?
48
+
49
+ 'Untitled item'
50
+ end
51
+
52
+ def extract_weight(row)
53
+ # Extract unit and weight from JSON
54
+ # If there is a negative value, cancel early and set it to 0.0
55
+ value = row['weight'].to_f
56
+ return 0.0 if value <= 0
57
+
58
+ unit = row['unit'].to_s.downcase
59
+
60
+ # Use module GramConverter to convert the value to grams
61
+ converted_weight = GramConverter.convert(value: value, unit: unit)
62
+ return converted_weight if converted_weight
63
+
64
+ 0.0 # Default value as a fallback
65
+ end
66
+
67
+ def extract_quantity(row)
68
+ quantity = row['quantity'].to_i
69
+ return 0 if quantity.negative?
70
+
71
+ quantity
72
+ end
73
+
74
+ def extract_worn_flag(row)
75
+ # weightType 1 for worn items
76
+ row['weightType'] == 1
77
+ end
78
+
79
+ def extract_consumable_flag(row)
80
+ # weightType 2 for consumables
81
+ row['weightType'] == 2
82
+ end
83
+
84
+ def extract_description(row)
85
+ description = row['description']
86
+ return description unless description.nil? || description.empty?
87
+
88
+ nil
89
+ end
90
+
91
+ def extract_image_url(row)
92
+ image_url = row['imageUrl']
93
+ return image_url unless image_url.nil? || image_url.empty?
94
+
95
+ nil
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackwizardParser
4
+ # Represents a PackWizard list.
5
+ #
6
+ # @attr_reader [String] name The name of the list
7
+ # @attr_reader [String, nil] description Optional description of the list
8
+ # @attr_reader [Array<Category>] categories Array of categories in this list
9
+ class List
10
+ attr_reader :name, :description, :categories
11
+
12
+ # @param name [String] The name of the list
13
+ # @param description [String, nil] Optional description
14
+ # @param categories [Array<Category>] Array of categories in this list
15
+ def initialize(name:, description: nil, categories: [])
16
+ @name = name
17
+ @description = description
18
+ @categories = categories
19
+ end
20
+
21
+ # Convert to hash for backward compatibility
22
+ # @return [Hash] Hash representation of the list
23
+ def to_h
24
+ {
25
+ name: name,
26
+ description: description,
27
+ categories: categories.map(&:to_h)
28
+ }
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackwizardParser
4
+ # Parser for extracting PackWizard list data from JSON.
5
+ class ListParser
6
+ # Extract the lists name and description.
7
+ # Extract the categories and their items
8
+ #
9
+ # @param data [Hash] The fully parsed JSON response.
10
+ # @param category_parser [CategoryParser] Parser for extracting the categories
11
+ # @param item_parser [ItemParser] Parser for extracting the items
12
+ # @return [List] The parsed list
13
+ def parse(data, category_parser:, item_parser:)
14
+ List.new(
15
+ name: extract_name(data),
16
+ description: extract_description(data),
17
+ categories: category_parser.parse_all(data, item_parser: item_parser)
18
+ )
19
+ end
20
+
21
+ private
22
+
23
+ def extract_name(data)
24
+ name = data['packName']
25
+ return name unless name.nil? || name.empty?
26
+
27
+ 'Untitled list' # fallback if name is not set
28
+ end
29
+
30
+ def extract_description(data)
31
+ description = data['packDescription']
32
+ return description unless description.nil? || description.empty?
33
+
34
+ nil
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+ require 'json'
5
+
6
+ module PackwizardParser
7
+ # Parser for extracting data from PackWizard JSON
8
+ #
9
+ # Parser fetches data from the PackWizard API by shareable ID or URL
10
+ # Accepts full URL or just the ID. In case of full URL insert, we extract the ID and add it to the API_URL.
11
+ # Delegate parsing to ListParser, CategoryParser, and ItemParser
12
+ class Parser
13
+ API_URL = 'https://www.packwizard.com/api/packs/getSharedPackWithId'
14
+ def initialize(shareable_id:)
15
+ @shareable_id = extract_id(shareable_id)
16
+
17
+ @item_parser = ItemParser.new
18
+ @category_parser = CategoryParser.new
19
+ @list_parser = ListParser.new
20
+ end
21
+
22
+ def parse
23
+ response = HTTParty.get(API_URL, query: { shareableId: @shareable_id }, timeout: 30)
24
+ raise "Failed to fetch #{response.code}" unless response.success?
25
+
26
+ data = JSON.parse(response.body)
27
+ @list_parser.parse(data, category_parser: @category_parser, item_parser: @item_parser)
28
+ end
29
+
30
+ private
31
+
32
+ def extract_id(input)
33
+ # If the user only inserts the id of the list
34
+ return input unless input.include?('/')
35
+
36
+ # If the user inserts the full url
37
+ input.split('/').last
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PackwizardParser
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'packwizard_parser/version'
4
+ require_relative 'packwizard_parser/gram_converter'
5
+ require_relative 'packwizard_parser/item'
6
+ require_relative 'packwizard_parser/category'
7
+ require_relative 'packwizard_parser/list'
8
+ require_relative 'packwizard_parser/item_parser'
9
+ require_relative 'packwizard_parser/category_parser'
10
+ require_relative 'packwizard_parser/list_parser'
11
+ require_relative 'packwizard_parser/parser'
12
+
13
+ # Parser for extracting data from PackWizard list
14
+ #
15
+ # Provides classes and methods to parse PackWizard list JSON and extract
16
+ # structured data including list information, categories, and items with their
17
+ # properties (weight, quantity, consumable status, etc.).
18
+ module PackwizardParser
19
+
20
+ # Convenience method to parse a PackWizard URL
21
+ def self.parse_url(shareable_id)
22
+ Parser.new(shareable_id: shareable_id).parse
23
+ end
24
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/packwizard_parser/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'packwizard-parser'
7
+ spec.version = PackwizardParser::VERSION
8
+ spec.authors = ['Aross AB / Goulight']
9
+ spec.email = ['hello@goulight.com']
10
+
11
+ spec.summary = 'Parser for PackWizard lists'
12
+ spec.description = 'Parse PackWizard to extract list data including categories, items, weights, and metadata'
13
+ spec.homepage = 'https://github.com/goulight/packwizard-parser'
14
+ spec.license = 'MIT'
15
+
16
+ spec.metadata = {
17
+ 'source_code_uri' => 'https://github.com/goulight/packwizard-parser'
18
+ }
19
+
20
+ spec.required_ruby_version = '>= 3.0'
21
+
22
+ spec.files = Dir['lib/**/*', 'spec/**/*', '*.md', '*.gemspec']
23
+ spec.require_paths = ['lib']
24
+
25
+ spec.add_dependency 'httparty', '~> 0.24'
26
+
27
+ spec.add_development_dependency 'rspec', '~> 3.13'
28
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe PackwizardParser::CategoryParser do
4
+ let(:fixture_json) { File.read(File.join(__dir__, 'fixtures', 'tUE6BJs.json')) }
5
+ let(:data) { JSON.parse(fixture_json) }
6
+ let(:parser) { described_class.new }
7
+ let(:item_parser) { PackwizardParser::ItemParser.new }
8
+
9
+ describe '#parse_all' do
10
+ let(:list) { parser.parse_all(data, item_parser: item_parser) }
11
+
12
+ it 'extracts the first category' do
13
+ first_category = list.first
14
+ expect(first_category.description).to eq('Pack, Tent, Sleep System')
15
+ expect(first_category.name).to eq('Big 3')
16
+ expect(first_category.items).to be_a(Array)
17
+ expect(first_category.items.length).to be > 0
18
+ end
19
+ end
20
+
21
+ describe 'negative testcases' do
22
+ let(:fixture_json) { File.read(File.join(__dir__, 'fixtures', 'negative.json')) }
23
+ let(:data) { JSON.parse(fixture_json) }
24
+ let(:parser) { described_class.new }
25
+ let(:item_parser) { PackwizardParser::ItemParser.new }
26
+ let(:list) { parser.parse_all(data, item_parser: item_parser) }
27
+
28
+ it 'extract empty category name' do
29
+ first_category = list.first
30
+ expect(first_category.name).to eq('Untitled category')
31
+ end
32
+
33
+ it 'extract empty description' do
34
+ first_category = list.first
35
+ expect(first_category.description).to be_nil
36
+ end
37
+ end
38
+ end