e621_export_downloader 0.0.16 → 0.0.18

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: 0ca5e1aa72caa7ce565b4887222579025187c23a13f86ec2b818367991ee6cc5
4
- data.tar.gz: 393166c4a129496f7ecdd409899b644750b98a623d6501ca72c1f937d0645b62
3
+ metadata.gz: 7259f8077bf1df8805620f4efa3fbeac43c29616e496bea576518275794c5fef
4
+ data.tar.gz: 4b23216298e535aeed4ceaeb7f38f581bf6fc6fb68c1f7fa94975e9157f7bf71
5
5
  SHA512:
6
- metadata.gz: 6a2ea533d91b0f8dc403d9f4a287880815e1c955d76b28897cb80b55593c570524099760dfa37f8924c80e340cc83800955da0a5e14a042e270a9bea64f8047c
7
- data.tar.gz: 7a38db5401d422e48ae51470a5363bd5bfee65c4e2f0a6095e2fb3803123ee38a7dee90afaf4904f3f3390f593f33b6064ec7f99fcb94ba79f8ea5e6d21dde25
6
+ metadata.gz: 50436457d2de9e91e8f938631484653a71d8102122ec58f77c8c6287454092c38ef6719215a4aa20cb676978d659ce936750338667d892045027b4ee0a0fa0ed
7
+ data.tar.gz: b8a0c19646cd5b7ed2076b023adabc016962700dcc4c755ef2b66d3f3b018349be45aafca1c4be1dc8c0be9c8f2dd6de5f1529f6ea27f45a04df5660d6f6f1a9
data/CHANGELOG.md ADDED
@@ -0,0 +1,85 @@
1
+ # Changelog
2
+
3
+ ## [0.0.18] - 2026-07-02
4
+
5
+ _0.0.18_
6
+
7
+ ## [0.0.17] - 2026-06-29
8
+
9
+ _0.0.17_
10
+
11
+ ## [0.0.16] - 2026-06-24
12
+
13
+ ### Added
14
+ - Add new dbexport fields (from: e621ng/e621ng#2181, pending: e621ng/e621ng#2222)
15
+
16
+ ## [0.0.15] - 2026-06-14
17
+
18
+ ### Added
19
+ - Add method to track download progress
20
+
21
+ ## [0.0.14] - 2026-06-14
22
+
23
+ ### Changed
24
+ - optimize csv import
25
+
26
+ ## [0.0.13] - 2026-06-13
27
+
28
+ ### Added
29
+ - Add stored row count & text indexes
30
+
31
+ ## [0.0.12] - 2026-06-12
32
+
33
+ ### Fixed
34
+ - Fix post versions missing post_id
35
+
36
+ ## [0.0.11] - 2026-06-12
37
+
38
+ ### Fixed
39
+ - Fix csv import
40
+
41
+ ## [0.0.10] - 2026-06-12
42
+
43
+ ### Fixed
44
+ - Fix models
45
+
46
+ ## [0.0.9] - 2026-06-12
47
+
48
+ ### Fixed
49
+ - Fix parsing
50
+
51
+ ## [0.0.8] - 2026-06-12
52
+
53
+ ### Added
54
+ - Add csv import function to models
55
+
56
+ ## [0.0.7] - 2026-06-12
57
+
58
+ ### Fixed
59
+ - Fix railtie
60
+
61
+ ## [0.0.6] - 2026-06-12
62
+
63
+ _Rails Migration & models_
64
+
65
+ ## [0.0.5] - 2026-06-12
66
+
67
+ _force republish_
68
+
69
+ ## [0.0.4] - 2026-06-12
70
+
71
+ _db export changes_
72
+
73
+ ## [0.0.3] - 2026-05-19
74
+
75
+ ### Added
76
+ - Add activejob serializer
77
+
78
+ ## [0.0.2] - 2026-05-05
79
+
80
+ ### Fixed
81
+ - Fix Types serialization
82
+
83
+ ## [0.0.1] - 2026-05-05
84
+
85
+ _Initial Commit_
data/README.md CHANGED
@@ -23,12 +23,13 @@ client = E621ExportDownloader::Client.new
23
23
 
24
24
  # configure options after creation
25
25
  client.config do |c|
26
- c.cache = true # keep export files after reading, defaults to false
26
+ c.cache = true # keep export files after reading, defaults to false
27
+ c.validate_checksum = true # verify the downloaded file against the API-provided checksum, defaults to false
27
28
  end
28
29
 
29
30
  # or pass an Options struct directly
30
31
  client = E621ExportDownloader::Client.new(
31
- E621ExportDownloader::Client::Options.new(cache: true)
32
+ E621ExportDownloader::Client::Options.new(cache: true, validate_checksum: true)
32
33
  )
33
34
 
34
35
  # get the export for a type — resolves the latest available export from the e621 API
@@ -39,10 +40,10 @@ export = client.get("posts")
39
40
  deferred = client.get_deferred("posts")
40
41
 
41
42
  # get raw metadata for all exports from the e621 API
42
- # returns an Array of E621ExportDownloader::APIExportData: { file_name, file_size, name, updated_at, url }
43
+ # returns an Array of E621ExportDownloader::APIExportData: { file_name, file_size, name, checksum, updated_at, url }
43
44
  data = client.get_data
44
45
 
45
- # access the metadata for this specific export: { file_name, file_size, name, updated_at, url }
46
+ # access the metadata for this specific export: { file_name, file_size, name, checksum, updated_at, url }
46
47
  export.data
47
48
 
48
49
  # check if the export exists
@@ -50,6 +51,7 @@ exists = export.exists?
50
51
 
51
52
  # download the export, returns the file path — not required before reading
52
53
  # if you move or remove the file do not reuse the export object
54
+ # raises ResolveError if validate_checksum is enabled and the downloaded file's checksum does not match the API
53
55
  file = export.download
54
56
 
55
57
  # delete the downloaded file, if it exists
data/Rakefile CHANGED
@@ -8,11 +8,21 @@ Rake::TestTask.new(:test) do |t|
8
8
  t.pattern = "test/**/*_test.rb"
9
9
  end
10
10
 
11
- namespace(:test) do
12
- desc("Test Zeitwerk Auto-Importing")
13
- task(:zeitwerk) do
14
- exec(%(bundle exec ruby -e "require 'e621_export_downloader'; Zeitwerk::Loader.eager_load_all"))
15
- end
11
+ desc("Test Zeitwerk Auto-Importing")
12
+ task(:zeitwerk) do
13
+ ruby(%(-e "require 'e621_export_downloader'; Zeitwerk::Loader.eager_load_all"))
16
14
  end
17
15
 
18
- task(default: :rubocop)
16
+ desc("Run all tests")
17
+ task(:all) do
18
+ puts("=== rubocop ===")
19
+ sh("bundle exec rubocop")
20
+ puts("=== zeitwerk ===")
21
+ sh("bundle exec rake zeitwerk")
22
+ puts("=== test ===")
23
+ sh("bundle exec rake test")
24
+ puts("=== srb ===")
25
+ sh("bundle exec srb tc")
26
+ end
27
+
28
+ task(default: :all)
@@ -29,7 +29,7 @@ module E621
29
29
  approver_id: record.approver_id,
30
30
  consequent_name: record.consequent_name,
31
31
  created_at: record.created_at,
32
- # creator_id: record.creator_id,
32
+ creator_id: record.creator_id,
33
33
  down_votes: record.down_votes,
34
34
  forum_post_id: record.forum_post_id,
35
35
  forum_topic_id: record.forum_topic_id,
@@ -29,7 +29,7 @@ module E621
29
29
  approver_id: record.approver_id,
30
30
  consequent_name: record.consequent_name,
31
31
  created_at: record.created_at,
32
- # creator_id: record.creator_id,
32
+ creator_id: record.creator_id,
33
33
  descendant_names: record.descendant_names,
34
34
  down_votes: record.down_votes,
35
35
  forum_post_id: record.forum_post_id,
@@ -3,9 +3,10 @@
3
3
 
4
4
  module E621ExportDownloader
5
5
  class APIExportData < T::Struct
6
+ const(:name, Types)
6
7
  const(:file_name, String)
7
8
  const(:file_size, Integer)
8
- const(:name, Types)
9
+ const(:checksum, String)
9
10
  const(:updated_at, String)
10
11
  const(:url, String)
11
12
  end
@@ -10,13 +10,17 @@ module E621ExportDownloader
10
10
  sig { returns(T::Boolean) }
11
11
  attr_accessor(:cache)
12
12
 
13
+ sig { returns(T::Boolean) }
14
+ attr_accessor(:validate_checksum)
15
+
13
16
  sig { params(parsers: Options::Parsers).returns(Options::Parsers) }
14
17
  attr_writer(:parsers)
15
18
 
16
19
  sig { params(defaults: Options).void }
17
20
  def initialize(defaults = Options.new)
18
- @cache = T.let(defaults.cache, T::Boolean)
19
- @parsers = T.let(defaults.parsers, Options::Parsers)
21
+ @cache = T.let(defaults.cache, T::Boolean)
22
+ @validate_checksum = T.let(defaults.validate_checksum, T::Boolean)
23
+ @parsers = T.let(defaults.parsers, Options::Parsers)
20
24
  end
21
25
 
22
26
  sig { params(block: T.nilable(T.proc.params(arg0: Builder::Parsers).void)).returns(Options::Parsers) }
@@ -38,6 +38,7 @@ module E621ExportDownloader
38
38
  end
39
39
 
40
40
  const(:cache, T::Boolean, default: false)
41
+ const(:validate_checksum, T::Boolean, default: false)
41
42
  const(:parsers, Parsers, default: Parsers.defaults)
42
43
  end
43
44
  end
@@ -21,7 +21,9 @@ module E621ExportDownloader
21
21
 
22
22
  sig { params(block: T.proc.params(arg0: Options::Builder).void).void }
23
23
  def config(&block)
24
- block.call(Options::Builder.new(@options))
24
+ builder = Options::Builder.new(@options)
25
+ block.call(builder)
26
+ @options = Options.new(cache: builder.cache, validate_checksum: builder.validate_checksum, parsers: builder.parsers)
25
27
  end
26
28
 
27
29
  sig { returns(Faraday::Connection) }
@@ -61,9 +63,10 @@ module E621ExportDownloader
61
63
  data = JSON.parse(T.unsafe(res).body)
62
64
  result = T.let(data.map do |d|
63
65
  APIExportData.new(
66
+ name: Types.deserialize(d["name"]),
64
67
  file_name: d["file_name"],
65
68
  file_size: d["file_size"].to_i,
66
- name: Types.deserialize(d["name"]),
69
+ checksum: d["checksum"],
67
70
  updated_at: d["updated_at"],
68
71
  url: d["url"],
69
72
  )
@@ -2,6 +2,10 @@
2
2
  # typed: strict
3
3
 
4
4
  require("csv")
5
+ require("date")
6
+ require("digest")
7
+ require("fileutils")
8
+ require("zlib")
5
9
 
6
10
  module E621ExportDownloader
7
11
  class Export
@@ -51,11 +55,13 @@ module E621ExportDownloader
51
55
  client.debug("downloading export", header: ["export:#{type.serialize}"])
52
56
 
53
57
  FileUtils.mkdir_p(Constants::TEMP_DIR)
58
+ digest = T.let(client.options.validate_checksum ? Digest::MD5.new : nil, T.nilable(Digest::MD5))
54
59
  File.open(file_path, "wb") do |file|
55
60
  inflater = Zlib::Inflate.new(Zlib::MAX_WBITS + 16)
56
61
 
57
62
  res = client.connection.get(data.url) do |req|
58
63
  req.options.on_data = proc do |chunk, received|
64
+ digest&.update(chunk)
59
65
  decompressed = inflater.inflate(chunk)
60
66
  file.write(decompressed) if decompressed && !decompressed.empty?
61
67
  block.call(received, data.file_size) if block
@@ -69,6 +75,10 @@ module E621ExportDownloader
69
75
  unless res.success?
70
76
  raise(ResolveError, "Failed to download export #{type.serialize}: #{res.status} #{res.reason_phrase}")
71
77
  end
78
+
79
+ if digest && digest.hexdigest != data.checksum
80
+ raise(ResolveError, "Checksum mismatch for export #{type.serialize}")
81
+ end
72
82
  rescue # rubocop:disable Style/RescueStandardError
73
83
  FileUtils.rm_f(file_path)
74
84
  raise
@@ -18,8 +18,8 @@ module E621ExportDownloader
18
18
  sig { returns(T.nilable(DateTime)) }
19
19
  attr_reader(:created_at)
20
20
 
21
- # sig { returns(T.nilable(Integer)) }
22
- # attr_reader(:creator_id)
21
+ sig { returns(T.nilable(Integer)) }
22
+ attr_reader(:creator_id)
23
23
 
24
24
  sig { returns(Integer) }
25
25
  attr_reader(:down_votes)
@@ -58,7 +58,7 @@ module E621ExportDownloader
58
58
  @approver_id = T.let(T.must(record["approver_id"]).empty? ? nil : record["approver_id"].to_i, T.nilable(Integer))
59
59
  @consequent_name = T.let(T.must(record["consequent_name"]), String)
60
60
  @created_at = T.let(T.must(record["created_at"]).empty? ? nil : DateTime.parse(record["created_at"]), T.nilable(DateTime))
61
- # @creator_id = T.let(T.must(record["creator_id"]).empty? ? nil : record["creator_id"].to_i, T.nilable(Integer))
61
+ @creator_id = T.let(T.must(record["creator_id"]).empty? ? nil : record["creator_id"].to_i, T.nilable(Integer))
62
62
  @down_votes = T.let(record["down_votes"].to_i, Integer)
63
63
  @forum_post_id = T.let(T.must(record["forum_post_id"]).empty? ? nil : record["forum_post_id"].to_i, T.nilable(Integer))
64
64
  @forum_topic_id = T.let(T.must(record["forum_topic_id"]).empty? ? nil : record["forum_topic_id"].to_i, T.nilable(Integer))
@@ -78,7 +78,7 @@ module E621ExportDownloader
78
78
  approver_id: @approver_id,
79
79
  consequent_name: @consequent_name,
80
80
  created_at: @created_at,
81
- # creator_id: @creator_id,
81
+ creator_id: @creator_id,
82
82
  down_votes: @down_votes,
83
83
  forum_post_id: @forum_post_id,
84
84
  forum_topic_id: @forum_topic_id,
@@ -18,8 +18,8 @@ module E621ExportDownloader
18
18
  sig { returns(T.nilable(DateTime)) }
19
19
  attr_reader(:created_at)
20
20
 
21
- # sig { returns(T.nilable(Integer)) }
22
- # attr_reader(:creator_id)
21
+ sig { returns(T.nilable(Integer)) }
22
+ attr_reader(:creator_id)
23
23
 
24
24
  sig { returns(T::Array[String]) }
25
25
  attr_reader(:descendant_names)
@@ -58,7 +58,7 @@ module E621ExportDownloader
58
58
  @approver_id = T.let(T.must(record["approver_id"]).empty? ? nil : record["approver_id"].to_i, T.nilable(Integer))
59
59
  @consequent_name = T.let(T.must(record["consequent_name"]), String)
60
60
  @created_at = T.let(T.must(record["created_at"]).empty? ? nil : DateTime.parse(record["created_at"]), T.nilable(DateTime))
61
- # @creator_id = T.let(T.must(record["creator_id"]).empty? ? nil : record["creator_id"].to_i, T.nilable(Integer))
61
+ @creator_id = T.let(T.must(record["creator_id"]).empty? ? nil : record["creator_id"].to_i, T.nilable(Integer))
62
62
  inner = T.must(T.must(record["descendant_names"])[1..-2])
63
63
  @descendant_names = T.let(inner.empty? ? [] : inner.split(","), T::Array[String])
64
64
  @down_votes = T.let(record["down_votes"].to_i, Integer)
@@ -79,7 +79,7 @@ module E621ExportDownloader
79
79
  approver_id: @approver_id,
80
80
  consequent_name: @consequent_name,
81
81
  created_at: @created_at,
82
- # creator_id: @creator_id,
82
+ creator_id: @creator_id,
83
83
  descendant_names: @descendant_names,
84
84
  down_votes: @down_votes,
85
85
  forum_post_id: @forum_post_id,
@@ -4,7 +4,7 @@
4
4
  # loaded by bundler
5
5
  module E621ExportDownloader
6
6
  module Constants
7
- VERSION = "0.0.16"
7
+ VERSION = "0.0.18"
8
8
  WEBSITE = "https://github.com/DonovanDMC/E621ExportDownloader.rb"
9
9
  end
10
10
  end
@@ -3,9 +3,16 @@
3
3
  require("zeitwerk")
4
4
  require("sorbet-runtime")
5
5
 
6
+ # Loaded eagerly (rather than left to Zeitwerk) because Bundler's gemspec evaluation already
7
+ # requires version.rb directly to read the gem version, which predefines E621ExportDownloader::Constants
8
+ # before Zeitwerk::Loader#setup runs below. That premature definition makes Zeitwerk skip autoloading
9
+ # constants.rb entirely, since it only autoloads namespaces it hasn't already seen defined.
10
+ require_relative("e621_export_downloader/constants")
11
+
6
12
  loader = Zeitwerk::Loader.for_gem
7
13
  loader.inflector.inflect({ "api_export_data" => "APIExportData" })
8
14
  loader.ignore("#{__dir__}/e621_export_downloader/version.rb")
15
+ loader.ignore("#{__dir__}/e621_export_downloader/constants.rb")
9
16
  loader.ignore("#{__dir__}/e621_export_downloader/railtie.rb")
10
17
  loader.ignore("#{__dir__}/e621_export_downloader/serializers/active_job.rb")
11
18
  loader.ignore("#{__dir__}/e621_export_downloader/active_record_models.rb")
@@ -2,8 +2,6 @@
2
2
 
3
3
  class CreateE621Tables < ActiveRecord::Migration<%= migration_version %>
4
4
  def change
5
- enable_extension("pg_trgm")
6
-
7
5
  <% if use_schema? -%>
8
6
  reversible do |r|
9
7
  r.up { execute("CREATE SCHEMA <%= schema %>") }
@@ -13,48 +11,46 @@ class CreateE621Tables < ActiveRecord::Migration<%= migration_version %>
13
11
  <% end -%>
14
12
  create_table(<%= table_ref("artists") %>, id: :bigint, default: nil, force: :cascade) do |t|
15
13
  t.datetime(:created_at, null: false)
16
- t.bigint(:creator_id, null: false, index: true)
14
+ t.bigint(:creator_id, null: false)
17
15
  t.string(:group_name)
18
16
  t.boolean(:is_active, null: false)
19
17
  t.boolean(:is_locked, null: false)
20
- t.bigint(:linked_user_id, index: true)
21
- t.string(:name, null: false, index: { unique: true })
18
+ t.bigint(:linked_user_id)
19
+ t.string(:name, null: false)
22
20
  t.text(:other_names, array: true, null: false, default: [])
23
21
  t.datetime(:updated_at, null: false)
24
- t.text(:urls)
25
- t.index("group_name gin_trgm_ops", using: :gin)
26
- t.index("name gin_trgm_ops", using: :gin)
22
+ t.text(:active_urls, null: false)
23
+ t.text(:inactive_urls, null: false)
27
24
  end
28
25
 
29
26
  create_table(<%= table_ref("bulk_update_requests") %>, id: :bigint, default: nil, force: :cascade) do |t|
30
- t.bigint(:approver_id, index: true)
27
+ t.bigint(:approver_id)
31
28
  t.datetime(:created_at, null: false)
32
- t.bigint(:forum_topic_id, index: true)
33
- t.bigint(:forum_post_id, index: true)
29
+ t.bigint(:forum_topic_id)
30
+ t.bigint(:forum_post_id)
34
31
  t.text(:script, null: false)
35
- t.string(:status, null: false, index: true)
32
+ t.string(:status, null: false)
36
33
  t.string(:title)
37
34
  t.datetime(:updated_at, null: false)
38
- t.bigint(:user_id, null: false, index: true)
39
- t.index("script gin_trgm_ops", using: :gin)
40
- t.index("title gin_trgm_ops", using: :gin)
35
+ t.bigint(:user_id, null: false)
36
+ t.integer(:down_votes, null: false)
37
+ t.integer(:meh_votes, null: false)
38
+ t.integer(:up_votes, null: false)
41
39
  end
42
40
 
43
41
  create_table(<%= table_ref("pools") %>, id: :bigint, default: nil, force: :cascade) do |t|
44
42
  t.string(:category, null: false)
45
43
  t.datetime(:created_at, null: false)
46
- t.bigint(:creator_id, null: false, index: true)
44
+ t.bigint(:creator_id, null: false)
47
45
  t.text(:description, null: false)
48
46
  t.boolean(:is_active, null: false)
49
- t.string(:name, null: false, index: true)
50
- t.bigint(:post_ids, array: true, null: false, default: [], index: { using: :gin })
47
+ t.string(:name, null: false)
48
+ t.bigint(:post_ids, array: true, null: false)
51
49
  t.datetime(:updated_at)
52
- t.index("description gin_trgm_ops", using: :gin)
53
- t.index("name gin_trgm_ops", using: :gin)
54
50
  end
55
51
 
56
52
  create_table(<%= table_ref("posts") %>, id: :bigint, default: nil, force: :cascade) do |t|
57
- t.bigint(:approver_id, index: true)
53
+ t.bigint(:approver_id)
58
54
  t.bigint(:change_seq, null: false)
59
55
  t.integer(:comment_count, null: false)
60
56
  t.datetime(:created_at, null: false)
@@ -66,112 +62,118 @@ class CreateE621Tables < ActiveRecord::Migration<%= migration_version %>
66
62
  t.bigint(:file_size, null: false)
67
63
  t.integer(:image_height, null: false)
68
64
  t.integer(:image_width, null: false)
69
- t.boolean(:is_deleted, null: false, index: true)
70
- t.boolean(:is_flagged, null: false, index: true)
71
- t.boolean(:is_note_locked, null: false, index: true)
72
- t.boolean(:is_pending, null: false, index: true)
73
- t.boolean(:is_rating_locked, null: false, index: true)
74
- t.boolean(:is_status_locked, null: false, index: true)
75
- t.text(:locked_tags, null: false)
76
- t.string(:md5, index: { unique: true }, null: false)
77
- t.bigint(:parent_id, index: true)
78
- t.string(:rating, null: false, index: true)
65
+ t.boolean(:is_deleted, null: false)
66
+ t.boolean(:is_flagged, null: false)
67
+ t.boolean(:is_note_locked, null: false)
68
+ t.boolean(:is_pending, null: false)
69
+ t.boolean(:is_rating_locked, null: false)
70
+ t.boolean(:is_status_locked, null: false)
71
+ t.text(:locked_tags)
72
+ t.string(:md5, null: false)
73
+ t.bigint(:parent_id)
74
+ t.string(:rating, null: false)
79
75
  t.integer(:score, null: false)
80
76
  t.text(:source, null: false)
81
77
  t.text(:tag_string, null: false)
82
78
  t.integer(:up_score, null: false)
83
- t.datetime(:updated_at, index: true)
84
- t.bigint(:uploader_id, index: true)
85
- t.index("description gin_trgm_ops", using: :gin)
86
- t.index("source gin_trgm_ops", using: :gin)
87
- t.index("tag_string gin_trgm_ops", using: :gin)
79
+ t.datetime(:updated_at)
80
+ t.bigint(:uploader_id)
81
+ t.string(:bg_color)
82
+ t.datetime(:last_noted_at)
83
+ t.datetime(:last_commented_at)
88
84
  end
89
85
 
90
86
  create_table(<%= table_ref("post_replacements") %>, id: :bigint, default: nil, force: :cascade) do |t|
91
- t.bigint(:approver_id, index: true)
87
+ t.bigint(:approver_id)
92
88
  t.datetime(:created_at, null: false)
93
- t.bigint(:creator_id, null: false, index: true)
94
- t.string(:file_ext, null: false, index: true)
95
- t.string(:file_name, null: false, index: true)
96
- t.bigint(:file_size, null: false, index: true)
97
- t.integer(:image_height, null: false, index: true)
98
- t.integer(:image_width, null: false, index: true)
99
- t.string(:md5, null: false, index: true)
100
- t.bigint(:post_id, null: false, index: true)
89
+ t.bigint(:creator_id, null: false)
90
+ t.string(:file_ext, null: false)
91
+ t.string(:file_name, null: false)
92
+ t.bigint(:file_size, null: false)
93
+ t.integer(:image_height, null: false)
94
+ t.integer(:image_width, null: false)
95
+ t.string(:md5, null: false)
96
+ t.bigint(:post_id, null: false)
101
97
  t.text(:reason, null: false)
102
98
  t.text(:source)
103
- t.string(:status, null: false, index: true)
99
+ t.string(:status, null: false)
104
100
  t.datetime(:updated_at, null: false)
105
- t.index("reason gin_trgm_ops", using: :gin)
106
- t.index("source gin_trgm_ops", using: :gin)
107
101
  end
108
102
 
109
103
  create_table(<%= table_ref("post_versions") %>, id: :bigint, default: nil, force: :cascade) do |t|
110
104
  t.text(:added_locked_tags, array: true, null: false, default: [])
111
105
  t.text(:added_tags, array: true, null: false, default: [])
112
106
  t.text(:description)
113
- t.boolean(:description_changed, null: false, index: true)
107
+ t.boolean(:description_changed, null: false)
114
108
  t.text(:locked_tags)
115
- t.boolean(:parent_changed, null: false, index: true)
109
+ t.boolean(:parent_changed, null: false)
116
110
  t.bigint(:parent_id)
117
- t.bigint(:post_id, null: false, index: true)
111
+ t.bigint(:post_id, null: false)
118
112
  t.string(:rating)
119
- t.boolean(:rating_changed, null: false, index: true)
113
+ t.boolean(:rating_changed, null: false)
120
114
  t.text(:reason)
121
115
  t.text(:removed_locked_tags, array: true, null: false, default: [])
122
116
  t.text(:removed_tags, array: true, null: false, default: [])
123
117
  t.text(:source)
124
- t.boolean(:source_changed, null: false, index: true)
118
+ t.boolean(:source_changed, null: false)
125
119
  t.text(:tags)
126
- t.datetime(:updated_at, null: false, index: true)
127
- t.bigint(:updater_id, index: true)
128
- t.integer(:version, null: false, index: true)
129
- t.index("description gin_trgm_ops", using: :gin)
130
- t.index("locked_tags gin_trgm_ops", using: :gin)
131
- t.index("reason gin_trgm_ops", using: :gin)
132
- t.index("source gin_trgm_ops", using: :gin)
133
- t.index("tags gin_trgm_ops", using: :gin)
120
+ t.datetime(:updated_at, null: false)
121
+ t.bigint(:updater_id)
122
+ t.integer(:version, null: false)
134
123
  end
135
124
 
136
125
  create_table(<%= table_ref("tag_aliases") %>, id: :bigint, default: nil, force: :cascade) do |t|
137
- t.string(:antecedent_name, null: false, index: true)
138
- t.string(:consequent_name, null: false, index: true)
126
+ t.string(:antecedent_name, null: false)
127
+ t.string(:consequent_name, null: false)
139
128
  t.datetime(:created_at)
140
- t.string(:status, null: false, index: true)
141
- t.index(%i[status antecedent_name])
142
- t.index("antecedent_name gin_trgm_ops", using: :gin)
143
- t.index("consequent_name gin_trgm_ops", using: :gin)
129
+ t.string(:status, null: false)
130
+ t.integer(:forum_post_id, null: false)
131
+ t.integer(:forum_topic_id, null: false)
132
+ t.string(:reason, null: false)
133
+ t.datetime(:updated_at)
134
+ t.integer(:approver_id)
135
+ t.integer(:post_count, null: false)
136
+ t.integer(:down_votes, null: false)
137
+ t.integer(:meh_votes, null: false)
138
+ t.integer(:up_votes, null: false)
139
+ t.integer(:creator_id, null: false)
144
140
  end
145
141
 
146
142
  create_table(<%= table_ref("tag_implications") %>, id: :bigint, default: nil, force: :cascade) do |t|
147
- t.string(:antecedent_name, null: false, index: true)
148
- t.string(:consequent_name, null: false, index: true)
143
+ t.string(:antecedent_name, null: false)
144
+ t.string(:consequent_name, null: false)
149
145
  t.datetime(:created_at)
150
- t.string(:status, null: false, index: true)
151
- t.index(%i[status consequent_name])
152
- t.index(%i[status antecedent_name])
153
- t.index("antecedent_name gin_trgm_ops", using: :gin)
154
- t.index("consequent_name gin_trgm_ops", using: :gin)
146
+ t.string(:status, null: false)
147
+ t.integer(:forum_post_id, null: false)
148
+ t.integer(:forum_topic_id, null: false)
149
+ t.string(:reason, null: false)
150
+ t.datetime(:updated_at)
151
+ t.integer(:approver_id)
152
+ t.string(:descendant_names, array: true)
153
+ t.integer(:down_votes, null: false)
154
+ t.integer(:meh_votes, null: false)
155
+ t.integer(:up_votes, null: false)
156
+ t.integer(:creator_id, null: false)
155
157
  end
156
158
 
157
159
  create_table(<%= table_ref("tags") %>, id: :bigint, default: nil, force: :cascade) do |t|
158
- t.string(:category, null: false, index: true)
159
- t.string(:name, null: false, index: { unique: true })
160
- t.integer(:post_count, null: false, index: { order: { post_count: :desc } })
161
- t.index(%i[category post_count], order: { post_count: :desc })
162
- t.index("name gin_trgm_ops", using: :gin)
160
+ t.string(:category, null: false)
161
+ t.string(:name, null: false)
162
+ t.integer(:post_count, null: false)
163
+ t.datetime(:created_at, null: false)
164
+ t.datetime(:updated_at, null: false)
165
+ t.boolean(:is_locked, null: false)
163
166
  end
164
167
 
165
168
  create_table(<%= table_ref("wiki_pages") %>, id: :bigint, default: nil, force: :cascade) do |t|
166
- t.text(:body, null: false, index: true)
169
+ t.text(:body, null: false)
167
170
  t.datetime(:created_at, null: false)
168
- t.bigint(:creator_id, null: false, index: true)
169
- t.boolean(:is_locked, null: false, index: true)
170
- t.string(:title, null: false, index: { unique: true })
171
+ t.bigint(:creator_id, null: false)
172
+ t.boolean(:is_locked, null: false)
173
+ t.string(:title, null: false)
171
174
  t.datetime(:updated_at, null: false)
172
- t.bigint(:updater_id, index: true)
173
- t.index("body gin_trgm_ops", using: :gin)
174
- t.index("title gin_trgm_ops", using: :gin)
175
+ t.bigint(:updater_id)
176
+ t.string(:parent)
175
177
  end
176
178
 
177
179
  create_table(<%= table_ref("row_counts") %>, primary_key: :table_name, id: :string, force: :cascade) do |t|
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require("date")
5
+
6
+ def categorize(subject)
7
+ case subject
8
+ when /\Aadd /i then "Added"
9
+ when /\Afix /i then "Fixed"
10
+ when /\Aoptimize /i, /\Arefactor /i, /\Aupdate /i then "Changed"
11
+ end
12
+ end
13
+
14
+ shas = `git log --follow --format="%H" -- lib/e621_export_downloader/version.rb`.strip.split("\n")
15
+
16
+ entries = shas.map do |sha|
17
+ subject = `git log -1 --format="%s" #{sha}`.strip
18
+ date_str = `git log -1 --format="%ai" #{sha}`.strip
19
+ body = `git log -1 --format="%b" #{sha}`.strip
20
+ version_file = `git show #{sha}:lib/e621_export_downloader/version.rb`
21
+ version = version_file.match(/VERSION = "([^"]+)"/)[1]
22
+ date = Date.parse(date_str).strftime("%Y-%m-%d")
23
+ section = categorize(subject)
24
+
25
+ { version: version, date: date, section: section, subject: subject, body: body }
26
+ end
27
+
28
+ changelog = +"# Changelog\n"
29
+
30
+ entries.each do |e|
31
+ changelog << "\n## [#{e[:version]}] - #{e[:date]}\n"
32
+
33
+ section = e[:section]
34
+ if section
35
+ changelog << "\n### #{section}\n"
36
+ changelog << "- #{e[:subject]}"
37
+
38
+ unless e[:body].empty?
39
+ refs = e[:body].scan(/(?:from|pending):\s*\S+/).join(", ")
40
+ changelog << " (#{refs})" unless refs.empty?
41
+ end
42
+
43
+ changelog << "\n"
44
+ else
45
+ changelog << "\n_#{e[:subject]}_\n"
46
+ end
47
+ end
48
+
49
+ File.write("CHANGELOG.md", changelog)
50
+ puts("Generated CHANGELOG.md")
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("minitest/autorun")
4
+ require("digest")
5
+ require("fileutils")
6
+ require("zlib")
7
+ require("e621_export_downloader")
8
+
9
+ module ExportTestHelpers
10
+ # Stands in for a Faraday::Connection, driving the same #get(url) { |req| req.options.on_data = ... }
11
+ # streaming contract that Export#download relies on, without touching the network.
12
+ class FakeConnection
13
+ def initialize(chunks)
14
+ @chunks = chunks
15
+ end
16
+
17
+ def get(_url)
18
+ req = Struct.new(:options).new(Struct.new(:on_data).new)
19
+ yield(req)
20
+
21
+ received = 0
22
+ @chunks.each do |chunk|
23
+ received += chunk.bytesize
24
+ req.options.on_data.call(chunk, received)
25
+ end
26
+
27
+ Struct.new(:status, :reason_phrase, :success?).new(200, "OK", true)
28
+ end
29
+
30
+ def head(_url)
31
+ Struct.new(:success?).new(true)
32
+ end
33
+ end
34
+ end
35
+
36
+ class ExportChecksumTest < Minitest::Test
37
+ include(ExportTestHelpers)
38
+
39
+ def setup
40
+ @csv_content = "id,name\n1,foo\n"
41
+ @compressed = Zlib.gzip(@csv_content)
42
+ @checksum = Digest::MD5.hexdigest(@compressed)
43
+ # split across chunks to exercise streaming, matching how a real download arrives
44
+ midpoint = @compressed.bytesize / 2
45
+ @chunks = [@compressed.byteslice(0, midpoint), @compressed.byteslice(midpoint..)]
46
+ end
47
+
48
+ def teardown
49
+ FileUtils.rm_rf(E621ExportDownloader::Constants::TEMP_DIR)
50
+ end
51
+
52
+ def build_export(checksum:, validate_checksum:)
53
+ data = E621ExportDownloader::APIExportData.new(
54
+ name: E621ExportDownloader::Types::Posts,
55
+ file_name: "posts-checksum-test.csv",
56
+ file_size: @compressed.bytesize,
57
+ checksum: checksum,
58
+ updated_at: "2026-01-01",
59
+ url: "https://example.com/posts.csv.gz",
60
+ )
61
+ client = E621ExportDownloader::Client.new(
62
+ E621ExportDownloader::Client::Options.new(validate_checksum: validate_checksum),
63
+ )
64
+ chunks = @chunks
65
+ client.define_singleton_method(:connection) { FakeConnection.new(chunks) }
66
+ E621ExportDownloader::Export.new(
67
+ data: data,
68
+ client: client,
69
+ type: E621ExportDownloader::Types::Posts,
70
+ parser: ->(record) { record },
71
+ )
72
+ end
73
+
74
+ def test_download_succeeds_when_checksum_matches_and_validation_is_enabled
75
+ export = build_export(checksum: @checksum, validate_checksum: true)
76
+ path = export.download
77
+ assert(File.exist?(path))
78
+ assert_equal(@csv_content, File.read(path))
79
+ end
80
+
81
+ def test_download_raises_and_removes_the_file_when_checksum_does_not_match
82
+ export = build_export(checksum: "0" * 32, validate_checksum: true)
83
+ error = assert_raises(E621ExportDownloader::ResolveError) { export.download }
84
+ assert_match(/Checksum mismatch/, error.message)
85
+ assert_empty(Dir.children(E621ExportDownloader::Constants::TEMP_DIR))
86
+ end
87
+
88
+ def test_download_ignores_checksum_mismatch_when_validation_is_disabled
89
+ export = build_export(checksum: "0" * 32, validate_checksum: false)
90
+ path = export.download
91
+ assert(File.exist?(path))
92
+ end
93
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: e621_export_downloader
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.16
4
+ version: 0.0.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Donovan_DMC
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-06-24 00:00:00.000000000 Z
10
+ date: 2026-07-05 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: csv
@@ -75,6 +75,7 @@ extra_rdoc_files: []
75
75
  files:
76
76
  - ".irbrc"
77
77
  - ".ruby-version"
78
+ - CHANGELOG.md
78
79
  - LICENSE
79
80
  - README.md
80
81
  - Rakefile
@@ -118,6 +119,7 @@ files:
118
119
  - lib/generators/e621_export_downloader/install_generator.rb
119
120
  - lib/generators/e621_export_downloader/templates/initializers/e621_models.rb.erb
120
121
  - lib/generators/e621_export_downloader/templates/migrations/create_e621_tables.rb.erb
122
+ - scripts/generate_changelog.rb
121
123
  - sorbet/config
122
124
  - sorbet/rbi/annotations/.gitattributes
123
125
  - sorbet/rbi/annotations/faraday.rbi
@@ -221,6 +223,7 @@ files:
221
223
  - sorbet/rbi/todo.rbi
222
224
  - sorbet/tapioca/config.yml
223
225
  - sorbet/tapioca/require.rb
226
+ - test/export_test.rb
224
227
  - test/generators/install_generator_test.rb
225
228
  homepage: https://github.com/DonovanDMC/E621ExportDownloader.rb
226
229
  licenses: