gopromotional-packlist 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: 9ec908cd28c90f92b3afb8f031eb66bff55605d65aed959a00c971bd61912b08
4
+ data.tar.gz: 954362a4df57b747f43a824296491e6683f77fd22d4e05417b406b2cd0100f2d
5
+ SHA512:
6
+ metadata.gz: 1a1e0cdac8cba2c9c1a02e5689462574f51b50d2f7489ec7550649bb5730fc4226dc54b8b73e8b456a68956ac00ee8e152098d8ff8aa88fde668e436e8da9dc9
7
+ data.tar.gz: 78d9460426c688ee27539c57601adba3be1c99579ca097c4dc08f6e62a53f29c262192905337a594b9a52c3dc24c17b83f84a28a6dd6a03fcbde387857b969f7
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GoPromotional
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # gopromotional-packlist
2
+
3
+ Build a packing checklist from templates (work day, travel, trade show, gym), scale clothes to the number of nights, add your own items, tick things off and see what is left. Lists are stored as plain Markdown checklists you can also edit by hand.
4
+
5
+ - **4 templates**: work day (10 items), travel (19), trade show (16), gym (9). Combine them; duplicates are merged.
6
+ - **Per-night quantities**: T-shirts, underwear and socks scale to nights + 1.
7
+ - **Tick off** items by a unique part of their name; see **progress** and what is **still to pack**.
8
+ - **Markdown storage**: `- [x] 3 × T-shirts` round-trips, so the list works in any editor, GitHub or Obsidian.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ gem install gopromotional-packlist
14
+ # or in a Gemfile:
15
+ gem "gopromotional-packlist"
16
+ ```
17
+
18
+ ## Command line
19
+
20
+ ```console
21
+ $ gopromotional-packlist templates
22
+ work-day (10 items)
23
+ travel (19 items)
24
+ trade-show (16 items)
25
+ gym (9 items)
26
+
27
+ $ gopromotional-packlist new --template trade-show --extra "Tote bags" --title Expo --out expo.md
28
+ Wrote 17 items to expo.md
29
+
30
+ $ gopromotional-packlist check expo.md gaffer
31
+ 1/17 packed (6%)
32
+
33
+ $ gopromotional-packlist status expo.md
34
+ Expo: 1/17 packed (6%)
35
+ Still to pack: Pop-up banner / stand, Table cloth, Branded giveaways, Business cards, Brochures / flyers, Lead capture sheet or scanner, Extension lead, Laptop / tablet, Phone charger, Scissors, Cable ties, Pens, Water bottles, Snacks, First-aid kit, Tote bags
36
+ ```
37
+
38
+ Every command accepts `--json` for machine-readable output. Run `gopromotional-packlist --help` for all options.
39
+
40
+ ## Library
41
+
42
+ ```ruby
43
+ require "gopromotional/packlist"
44
+ P = GoPromotional::Packlist
45
+
46
+ items = P.build_list(templates: %w[travel work-day], nights: 3, extras: ["Umbrella"])
47
+ P.mark(items, "passport", true)
48
+ P.progress(items) # => {packed: 1, total: 28, percent: 4}
49
+ title, back = P.parse_markdown(P.to_markdown(items, "Lisbon"))
50
+ ```
51
+
52
+ Pure Ruby, no runtime dependencies, Ruby 3.0+. `require "gopromotional-packlist"` also works.
53
+
54
+ ## Links
55
+
56
+ - Website: [branded tote bags](https://www.gopromotional.co.uk/branded-products/tote-bags)
57
+ - Source code: [https://github.com/nbkdevs/GoPromotional](https://github.com/nbkdevs/GoPromotional) (`packages/tote-bags/rubygems`)
58
+ - Licence: MIT
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/gopromotional/packlist/cli"
5
+ exit GoPromotional::Packlist::CLI.run(ARGV)
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "../packlist"
6
+
7
+ module GoPromotional
8
+ module Packlist
9
+ # Command line interface: templates | new | check | uncheck | add | status
10
+ module CLI
11
+ USAGE = <<~TXT
12
+ Usage: gopromotional-packlist <command> [options]
13
+
14
+ Commands:
15
+ templates list built-in templates
16
+ new [--template travel[,gym,...]] [--nights N] [--extra ITEM]... [--title T] [--out FILE]
17
+ check <file> <item> mark packed (unique partial names work)
18
+ uncheck <file> <item>
19
+ add <file> <item>
20
+ status <file> progress and what is still left
21
+
22
+ Options: --json, -v/--version, -h/--help
23
+ TXT
24
+
25
+ module_function
26
+
27
+ def run(argv, out: $stdout, err: $stderr)
28
+ o = { json: false, template: "travel", nights: 0, extras: [], title: "Packing list" }
29
+ parser = OptionParser.new do |p|
30
+ p.banner = USAGE
31
+ p.on("--json") { o[:json] = true }
32
+ p.on("--template NAMES") { |v| o[:template] = v }
33
+ p.on("--nights N", Integer) { |v| o[:nights] = v }
34
+ p.on("--extra ITEM") { |v| o[:extras] << v }
35
+ p.on("--title TITLE") { |v| o[:title] = v }
36
+ p.on("--out FILE") { |v| o[:out] = v }
37
+ p.on("-v", "--version") { out.puts VERSION; return 0 }
38
+ p.on("-h", "--help") { out.puts USAGE; return 0 }
39
+ end
40
+ args = parser.parse(argv)
41
+ cmd = args.shift
42
+ case cmd
43
+ when nil
44
+ out.puts USAGE
45
+ when "templates"
46
+ out.puts(o[:json] ? JSON.pretty_generate(TEMPLATES.transform_values { |v| v.map(&:to_h) }) : TEMPLATES.map { |k, v| "#{k} (#{v.size} items)" })
47
+ when "new"
48
+ names = o[:template].split(",").map(&:strip).reject(&:empty?)
49
+ items = Packlist.build_list(templates: names, nights: o[:nights], extras: o[:extras])
50
+ md = Packlist.to_markdown(items, o[:title])
51
+ if o[:out]
52
+ File.write(o[:out], md)
53
+ out.puts "Wrote #{items.size} items to #{o[:out]}"
54
+ else
55
+ out.puts(o[:json] ? JSON.pretty_generate(items.map(&:to_h)) : md)
56
+ end
57
+ when "check", "uncheck", "add"
58
+ file = args.shift
59
+ name = args.join(" ")
60
+ raise ArgumentError, "#{cmd} needs a file and an item name" if file.nil? || name.empty?
61
+ title, items = Packlist.parse_markdown(File.read(file, encoding: "utf-8"))
62
+ if cmd == "add"
63
+ raise ArgumentError, %("#{name}" is already on the list) if items.any? { |i| i.name.downcase == name.downcase }
64
+ items << Item.new(name, "Extras")
65
+ else
66
+ Packlist.mark(items, name, cmd == "check")
67
+ end
68
+ File.write(file, Packlist.to_markdown(items, title))
69
+ pr = Packlist.progress(items)
70
+ out.puts "#{pr[:packed]}/#{pr[:total]} packed (#{pr[:percent]}%)"
71
+ when "status"
72
+ file = args.shift or raise ArgumentError, "status needs a file"
73
+ title, items = Packlist.parse_markdown(File.read(file, encoding: "utf-8"))
74
+ pr = Packlist.progress(items)
75
+ left = Packlist.remaining(items)
76
+ if o[:json]
77
+ out.puts JSON.pretty_generate(pr.merge(remaining: left.map(&:to_h)))
78
+ else
79
+ out.puts "#{title}: #{pr[:packed]}/#{pr[:total]} packed (#{pr[:percent]}%)"
80
+ out.puts(left.empty? ? "All packed." : "Still to pack: #{left.map(&:name).join(', ')}")
81
+ end
82
+ else
83
+ raise ArgumentError, %(Unknown command "#{cmd}". Run with --help.)
84
+ end
85
+ 0
86
+ rescue ArgumentError, OptionParser::ParseError, SystemCallError => e
87
+ err.puts "Error: #{e.message}"
88
+ 2
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoPromotional
4
+ module Packlist
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "packlist/version"
4
+
5
+ module GoPromotional
6
+ # Packing checklists with templates, per-night quantities and a round-trip Markdown format.
7
+ module Packlist
8
+ TemplateItem = Struct.new(:name, :category, :per_night) do
9
+ def to_h = { name: name, category: category, per_night: per_night }
10
+ end
11
+
12
+ def self.t(rows) = rows.map { |n, c, p| TemplateItem.new(n, c, p || false).freeze }.freeze
13
+
14
+ TEMPLATES = {
15
+ "work-day" => t([
16
+ ["Laptop", "Work"], ["Laptop charger", "Work"], ["Phone charger", "Work"], ["Badge / ID", "Work"],
17
+ ["Notebook", "Work"], ["Pen", "Work"], ["Headphones", "Work"], ["Water bottle", "Work"],
18
+ ["Reusable coffee cup", "Work"], ["Lunch / snacks", "Work"]
19
+ ]),
20
+ "travel" => t([
21
+ ["Passport / ID", "Documents"], ["Tickets / boarding pass", "Documents"], ["Bank cards", "Documents"],
22
+ ["T-shirts", "Clothes", true], ["Underwear", "Clothes", true], ["Socks", "Clothes", true],
23
+ ["Trousers / skirt", "Clothes"], ["Jumper", "Clothes"], ["Sleepwear", "Clothes"],
24
+ ["Toothbrush", "Toiletries"], ["Toothpaste", "Toiletries"], ["Deodorant", "Toiletries"], ["Medications", "Toiletries"],
25
+ ["Phone charger", "Tech"], ["Power bank", "Tech"], ["Plug adapter", "Tech"], ["Headphones", "Tech"],
26
+ ["Reusable water bottle", "Other"], ["Tote bag", "Other"]
27
+ ]),
28
+ "trade-show" => t([
29
+ ["Pop-up banner / stand", "Stand"], ["Table cloth", "Stand"], ["Branded giveaways", "Stand"],
30
+ ["Business cards", "Stand"], ["Brochures / flyers", "Stand"], ["Lead capture sheet or scanner", "Stand"],
31
+ ["Extension lead", "Kit"], ["Laptop / tablet", "Kit"], ["Phone charger", "Kit"], ["Gaffer tape", "Kit"],
32
+ ["Scissors", "Kit"], ["Cable ties", "Kit"], ["Pens", "Kit"],
33
+ ["Water bottles", "Team"], ["Snacks", "Team"], ["First-aid kit", "Team"]
34
+ ]),
35
+ "gym" => t([
36
+ ["Gym clothes", "Gym"], ["Trainers", "Gym"], ["Towel", "Gym"], ["Water bottle", "Gym"], ["Padlock", "Gym"],
37
+ ["Headphones", "Gym"], ["Shower gel", "Gym"], ["Deodorant", "Gym"], ["Snack", "Gym"]
38
+ ])
39
+ }.freeze
40
+
41
+ Item = Struct.new(:name, :category, :qty, :packed) do
42
+ def initialize(name, category, qty = 1, packed = false) = super
43
+ def label = qty > 1 ? "#{qty} × #{name}" : name
44
+ def to_h = { name: name, category: category, qty: qty, packed: packed }
45
+ end
46
+
47
+ module_function
48
+
49
+ def key(s) = s.to_s.strip.downcase
50
+
51
+ # Build a checklist. Per-night items get nights + 1 (one spare); duplicate names are merged (first wins).
52
+ def build_list(templates: ["travel"], nights: 0, extras: [])
53
+ raise ArgumentError, "nights must be an integer from 0 to 365" unless nights.is_a?(Integer) && (0..365).cover?(nights)
54
+ seen = {}
55
+ items = []
56
+ push = lambda do |it|
57
+ k = key(it.name)
58
+ next if k.empty? || seen[k]
59
+ seen[k] = true
60
+ items << it
61
+ end
62
+ templates.each do |t|
63
+ tpl = TEMPLATES[t] or raise ArgumentError, %(Unknown template "#{t}". Choose from: #{TEMPLATES.keys.join(', ')})
64
+ tpl.each { |r| push.call(Item.new(r.name, r.category, r.per_night && nights.positive? ? nights + 1 : 1)) }
65
+ end
66
+ extras.each { |e| push.call(Item.new(e.to_s.strip, "Extras")) }
67
+ items
68
+ end
69
+
70
+ def find(items, name)
71
+ k = key(name)
72
+ exact = items.find { |i| key(i.name) == k }
73
+ return exact if exact
74
+ partial = items.select { |i| key(i.name).include?(k) }
75
+ return partial.first if partial.size == 1
76
+ raise ArgumentError, %("#{name}" matches #{partial.size} items: #{partial.map(&:name).join(', ')}) if partial.any?
77
+ raise ArgumentError, %(No item matches "#{name}")
78
+ end
79
+
80
+ # Mark an item packed (toggle when packed is nil). Exact name first, then a unique substring.
81
+ def mark(items, name, packed = nil)
82
+ it = find(items, name)
83
+ it.packed = packed.nil? ? !it.packed : packed ? true : false
84
+ it
85
+ end
86
+
87
+ def progress(items)
88
+ packed = items.count(&:packed)
89
+ { packed: packed, total: items.size, percent: items.empty? ? 0 : (packed * 100.0 / items.size + 0.5).floor }
90
+ end
91
+
92
+ def remaining(items) = items.reject(&:packed)
93
+
94
+ def filter_items(items, query)
95
+ q = key(query)
96
+ items.select { |i| key(i.name).include?(q) || key(i.category).include?(q) }
97
+ end
98
+
99
+ def categories(items) = items.map(&:category).uniq
100
+
101
+ # Markdown checkboxes grouped by category; parse_markdown reads it back.
102
+ def to_markdown(items, title = "Packing list")
103
+ lines = ["# #{title}", ""]
104
+ categories(items).each do |c|
105
+ lines << "## #{c}"
106
+ items.select { |i| i.category == c }.each { |i| lines << "- [#{i.packed ? 'x' : ' '}] #{i.label}" }
107
+ lines << ""
108
+ end
109
+ lines.join("\n")
110
+ end
111
+
112
+ def to_text(items)
113
+ categories(items).map do |c|
114
+ ([c] + items.select { |i| i.category == c }.map { |i| " #{i.packed ? '[x]' : '[ ]'} #{i.label}" }).join("\n")
115
+ end.join("\n")
116
+ end
117
+
118
+ # Returns [title, items].
119
+ def parse_markdown(md)
120
+ title = "Packing list"
121
+ category = "Items"
122
+ items = []
123
+ md.to_s.each_line do |raw|
124
+ line = raw.strip
125
+ if (m = line.match(/\A#\s+(.+)\z/))
126
+ title = m[1].strip
127
+ elsif (m = line.match(/\A##\s+(.+)\z/))
128
+ category = m[1].strip
129
+ elsif (m = line.match(/\A[-*]\s+\[([ xX])\]\s+(.+)\z/))
130
+ name = m[2].strip
131
+ qty = 1
132
+ if (q = name.match(/\A(\d+)\s*[×x]\s+(.+)\z/))
133
+ qty = q[1].to_i
134
+ name = q[2].strip
135
+ end
136
+ items << Item.new(name, category, qty, m[1] != " ")
137
+ end
138
+ end
139
+ [title, items]
140
+ end
141
+ end
142
+ end
@@ -0,0 +1 @@
1
+ require_relative "gopromotional/packlist"
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gopromotional-packlist
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - GoPromotional
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Build a packing checklist from templates (work day, travel, trade show,
13
+ gym), scale clothes to the number of nights, add your own items, tick things off
14
+ and see what is left. Lists are stored as plain Markdown checklists you can also
15
+ edit by hand.
16
+ executables:
17
+ - gopromotional-packlist
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - LICENSE
22
+ - README.md
23
+ - exe/gopromotional-packlist
24
+ - lib/gopromotional-packlist.rb
25
+ - lib/gopromotional/packlist.rb
26
+ - lib/gopromotional/packlist/cli.rb
27
+ - lib/gopromotional/packlist/version.rb
28
+ homepage: https://www.gopromotional.co.uk/branded-products/tote-bags
29
+ licenses:
30
+ - MIT
31
+ metadata:
32
+ homepage_uri: https://www.gopromotional.co.uk/branded-products/tote-bags
33
+ source_code_uri: https://github.com/nbkdevs/GoPromotional/tree/main/packages/tote-bags/rubygems
34
+ bug_tracker_uri: https://github.com/nbkdevs/GoPromotional/issues
35
+ rubygems_mfa_required: 'true'
36
+ rdoc_options: []
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '3.0'
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ requirements: []
50
+ rubygems_version: 3.6.7
51
+ specification_version: 4
52
+ summary: Packing checklists with templates, per-night quantities and a Markdown format.
53
+ test_files: []