historiographer 4.4.1 → 4.4.3
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 +4 -4
- data/DEVELOPMENT.md +124 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +14 -0
- data/README.md +16 -1
- data/Rakefile +54 -0
- data/VERSION +1 -1
- data/bin/console +10 -0
- data/bin/setup +15 -0
- data/bin/test +5 -0
- data/bin/test-all +10 -0
- data/bin/test-rails +5 -0
- data/historiographer.gemspec +38 -4
- data/lib/historiographer/history.rb +193 -60
- data/spec/combustion_helper.rb +34 -0
- data/spec/db/migrate/20250823000000_create_easy_ml_columns.rb +26 -0
- data/spec/db/migrate/20250824000000_create_test_articles.rb +26 -0
- data/spec/db/migrate/20250824000001_create_test_categories.rb +26 -0
- data/spec/db/migrate/20250825000000_create_bylines.rb +11 -0
- data/spec/db/migrate/20250826000000_create_test_users.rb +8 -0
- data/spec/db/migrate/20250826000001_create_test_user_histories.rb +18 -0
- data/spec/db/migrate/20250826000002_create_test_websites.rb +9 -0
- data/spec/db/migrate/20250826000003_create_test_website_histories.rb +19 -0
- data/spec/db/schema.rb +110 -40
- data/spec/historiographer_spec.rb +319 -1
- data/spec/integration/historiographer_safe_integration_spec.rb +154 -0
- data/spec/internal/app/models/application_record.rb +5 -0
- data/spec/internal/app/models/deploy.rb +5 -0
- data/spec/internal/app/models/user.rb +4 -0
- data/spec/internal/app/models/website.rb +5 -0
- data/spec/internal/app/models/website_history.rb +7 -0
- data/spec/internal/config/database.yml +9 -0
- data/spec/internal/config/routes.rb +2 -0
- data/spec/internal/db/schema.rb +48 -0
- data/spec/models/author.rb +1 -0
- data/spec/models/byline.rb +4 -0
- data/spec/models/post.rb +2 -0
- data/spec/models/test_article.rb +4 -0
- data/spec/models/test_article_history.rb +3 -0
- data/spec/models/test_category.rb +4 -0
- data/spec/models/test_category_history.rb +3 -0
- data/spec/models/test_user.rb +4 -0
- data/spec/models/test_user_history.rb +3 -0
- data/spec/models/test_website.rb +4 -0
- data/spec/models/test_website_history.rb +3 -0
- data/spec/rails_integration/historiographer_rails_integration_spec.rb +106 -0
- data/spec/spec_helper.rb +2 -3
- metadata +42 -4
- data/spec/foreign_key_spec.rb +0 -189
@@ -0,0 +1,154 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
RSpec.describe 'Historiographer::Safe Integration' do
|
4
|
+
# This test reproduces the exact error from a real Rails app where:
|
5
|
+
# 1. WebsiteHistory is defined first and includes Historiographer::History
|
6
|
+
# 2. Website is defined later and includes Historiographer::Safe
|
7
|
+
# 3. The error occurs because foreign_class.constantize fails when Website isn't loaded yet
|
8
|
+
|
9
|
+
context 'when history class is loaded before the main model' do
|
10
|
+
before(:each) do
|
11
|
+
# Ensure clean state
|
12
|
+
Object.send(:remove_const, :RealAppWebsite) if defined?(RealAppWebsite)
|
13
|
+
Object.send(:remove_const, :RealAppWebsiteHistory) if defined?(RealAppWebsiteHistory)
|
14
|
+
|
15
|
+
# Create the tables
|
16
|
+
ActiveRecord::Base.connection.create_table :real_app_websites, force: true do |t|
|
17
|
+
t.string :name
|
18
|
+
t.integer :project_id
|
19
|
+
t.integer :user_id
|
20
|
+
t.integer :template_id
|
21
|
+
t.timestamps
|
22
|
+
end
|
23
|
+
|
24
|
+
ActiveRecord::Base.connection.create_table :real_app_website_histories, force: true do |t|
|
25
|
+
t.integer :real_app_website_id, null: false
|
26
|
+
t.string :name
|
27
|
+
t.integer :project_id
|
28
|
+
t.integer :user_id
|
29
|
+
t.integer :template_id
|
30
|
+
t.datetime :created_at, null: false
|
31
|
+
t.datetime :updated_at, null: false
|
32
|
+
t.datetime :history_started_at, null: false
|
33
|
+
t.datetime :history_ended_at
|
34
|
+
t.integer :history_user_id
|
35
|
+
t.string :snapshot_id
|
36
|
+
t.string :thread_id
|
37
|
+
end
|
38
|
+
|
39
|
+
ActiveRecord::Base.connection.add_index :real_app_website_histories, :real_app_website_id
|
40
|
+
ActiveRecord::Base.connection.add_index :real_app_website_histories, :history_started_at
|
41
|
+
ActiveRecord::Base.connection.add_index :real_app_website_histories, :history_ended_at
|
42
|
+
end
|
43
|
+
|
44
|
+
after(:each) do
|
45
|
+
# Clean up tables
|
46
|
+
ActiveRecord::Base.connection.drop_table :real_app_website_histories if ActiveRecord::Base.connection.table_exists?(:real_app_website_histories)
|
47
|
+
ActiveRecord::Base.connection.drop_table :real_app_websites if ActiveRecord::Base.connection.table_exists?(:real_app_websites)
|
48
|
+
|
49
|
+
# Clean up constants
|
50
|
+
Object.send(:remove_const, :RealAppWebsiteHistory) if defined?(RealAppWebsiteHistory)
|
51
|
+
Object.send(:remove_const, :RealAppWebsite) if defined?(RealAppWebsite)
|
52
|
+
end
|
53
|
+
|
54
|
+
it 'handles history class being defined before the main model exists' do
|
55
|
+
# This is the exact scenario from the error report:
|
56
|
+
# WebsiteHistory is loaded/required first (common in Rails autoloading)
|
57
|
+
|
58
|
+
expect {
|
59
|
+
class RealAppWebsiteHistory < ApplicationRecord
|
60
|
+
self.table_name = 'real_app_website_histories'
|
61
|
+
include Historiographer::History
|
62
|
+
end
|
63
|
+
}.not_to raise_error
|
64
|
+
|
65
|
+
# At this point, RealAppWebsite doesn't exist yet
|
66
|
+
# The history class should handle this gracefully
|
67
|
+
expect(RealAppWebsiteHistory.foreign_class).to be_nil
|
68
|
+
|
69
|
+
# Now define the main model (simulating Rails autoloading it later)
|
70
|
+
class RealAppWebsite < ApplicationRecord
|
71
|
+
self.table_name = 'real_app_websites'
|
72
|
+
include Historiographer::Safe
|
73
|
+
end
|
74
|
+
|
75
|
+
# After the main model is defined, foreign_class should resolve
|
76
|
+
expect(RealAppWebsiteHistory.foreign_class).to eq(RealAppWebsite)
|
77
|
+
|
78
|
+
# And all functionality should work
|
79
|
+
website = RealAppWebsite.create!(name: 'Test Site', history_user_id: 1)
|
80
|
+
expect(website.histories.count).to eq(1)
|
81
|
+
|
82
|
+
history = website.histories.first
|
83
|
+
expect(history).to be_a(RealAppWebsiteHistory)
|
84
|
+
expect(history.real_app_website_id).to eq(website.id)
|
85
|
+
expect(history.name).to eq('Test Site')
|
86
|
+
end
|
87
|
+
|
88
|
+
it 'allows history class to define associations even when parent model is not loaded' do
|
89
|
+
# Define a Deploy model for association testing
|
90
|
+
ActiveRecord::Base.connection.create_table :deploys, force: true do |t|
|
91
|
+
t.integer :real_app_website_history_id
|
92
|
+
t.string :status
|
93
|
+
t.timestamps
|
94
|
+
end
|
95
|
+
|
96
|
+
class Deploy < ApplicationRecord
|
97
|
+
self.table_name = 'deploys'
|
98
|
+
end
|
99
|
+
|
100
|
+
# Define history class with associations before main model exists
|
101
|
+
expect {
|
102
|
+
class RealAppWebsiteHistory < ApplicationRecord
|
103
|
+
self.table_name = 'real_app_website_histories'
|
104
|
+
include Historiographer::History
|
105
|
+
|
106
|
+
# This should work even though RealAppWebsite doesn't exist yet
|
107
|
+
has_many :deploys, foreign_key: :real_app_website_history_id, dependent: :destroy
|
108
|
+
end
|
109
|
+
}.not_to raise_error
|
110
|
+
|
111
|
+
# Now define the main model
|
112
|
+
class RealAppWebsite < ApplicationRecord
|
113
|
+
self.table_name = 'real_app_websites'
|
114
|
+
include Historiographer::Safe
|
115
|
+
end
|
116
|
+
|
117
|
+
# Test that associations work
|
118
|
+
website = RealAppWebsite.create!(name: 'Test Site', history_user_id: 1)
|
119
|
+
history = website.histories.first
|
120
|
+
|
121
|
+
deploy = Deploy.create!(real_app_website_history_id: history.id, status: 'pending')
|
122
|
+
expect(history.deploys).to include(deploy)
|
123
|
+
|
124
|
+
# Clean up
|
125
|
+
ActiveRecord::Base.connection.drop_table :deploys
|
126
|
+
Object.send(:remove_const, :Deploy)
|
127
|
+
end
|
128
|
+
|
129
|
+
it 'supports after_initialize Rails hook when available' do
|
130
|
+
# Simulate Rails being available with after_initialize
|
131
|
+
rails_app = double('Rails App')
|
132
|
+
config = double('Rails Config')
|
133
|
+
allow(config).to receive(:after_initialize).and_yield
|
134
|
+
allow(rails_app).to receive(:config).and_return(config)
|
135
|
+
allow(Rails).to receive(:application).and_return(rails_app)
|
136
|
+
|
137
|
+
# Define history class
|
138
|
+
class RealAppWebsiteHistory < ApplicationRecord
|
139
|
+
self.table_name = 'real_app_website_histories'
|
140
|
+
include Historiographer::History
|
141
|
+
end
|
142
|
+
|
143
|
+
# Define main model
|
144
|
+
class RealAppWebsite < ApplicationRecord
|
145
|
+
self.table_name = 'real_app_websites'
|
146
|
+
include Historiographer::Safe
|
147
|
+
end
|
148
|
+
|
149
|
+
# The after_initialize should have set up associations
|
150
|
+
expect(RealAppWebsiteHistory).to respond_to(:setup_history_associations)
|
151
|
+
expect { RealAppWebsiteHistory.setup_history_associations }.not_to raise_error
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
test:
|
2
|
+
adapter: postgresql
|
3
|
+
database: historiographer_combustion_test
|
4
|
+
username: <%= ENV['DB_USERNAME'] || 'postgres' %>
|
5
|
+
password: <%= ENV['DB_PASSWORD'] || '' %>
|
6
|
+
host: <%= ENV['DB_HOST'] || 'localhost' %>
|
7
|
+
port: <%= ENV['DB_PORT'] || 5432 %>
|
8
|
+
pool: 5
|
9
|
+
timeout: 5000
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
ActiveRecord::Schema.define(version: 1) do
|
4
|
+
# Website table - the main model
|
5
|
+
create_table :websites, force: true do |t|
|
6
|
+
t.string :name
|
7
|
+
t.integer :project_id
|
8
|
+
t.integer :user_id
|
9
|
+
t.integer :template_id
|
10
|
+
t.timestamps
|
11
|
+
end
|
12
|
+
|
13
|
+
# Website history table
|
14
|
+
create_table :website_histories, force: true do |t|
|
15
|
+
t.integer :website_id, null: false
|
16
|
+
t.string :name
|
17
|
+
t.integer :project_id
|
18
|
+
t.integer :user_id
|
19
|
+
t.integer :template_id
|
20
|
+
t.datetime :created_at, null: false
|
21
|
+
t.datetime :updated_at, null: false
|
22
|
+
t.datetime :history_started_at, null: false
|
23
|
+
t.datetime :history_ended_at
|
24
|
+
t.integer :history_user_id
|
25
|
+
t.string :snapshot_id
|
26
|
+
t.string :thread_id
|
27
|
+
end
|
28
|
+
|
29
|
+
add_index :website_histories, :website_id
|
30
|
+
add_index :website_histories, :history_started_at
|
31
|
+
add_index :website_histories, :history_ended_at
|
32
|
+
add_index :website_histories, :history_user_id
|
33
|
+
add_index :website_histories, :snapshot_id
|
34
|
+
add_index :website_histories, [:thread_id], unique: true, name: 'index_website_histories_on_thread_id'
|
35
|
+
|
36
|
+
# Deploy table - to test associations on history models
|
37
|
+
create_table :deploys, force: true do |t|
|
38
|
+
t.integer :website_history_id
|
39
|
+
t.string :status
|
40
|
+
t.timestamps
|
41
|
+
end
|
42
|
+
|
43
|
+
# User table
|
44
|
+
create_table :users, force: true do |t|
|
45
|
+
t.string :name
|
46
|
+
t.timestamps
|
47
|
+
end
|
48
|
+
end
|
data/spec/models/author.rb
CHANGED
data/spec/models/post.rb
CHANGED
@@ -0,0 +1,106 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'combustion_helper'
|
4
|
+
|
5
|
+
RSpec.describe 'Historiographer Rails Integration', type: :model do
|
6
|
+
describe 'when WebsiteHistory is loaded before Website (Rails autoloading scenario)' do
|
7
|
+
it 'handles the load order gracefully' do
|
8
|
+
expect(defined?(WebsiteHistory)).to be_truthy
|
9
|
+
expect(WebsiteHistory.ancestors).to include(Historiographer::History)
|
10
|
+
|
11
|
+
expect(defined?(Website)).to be_truthy
|
12
|
+
expect(Website.ancestors).to include(Historiographer::Safe)
|
13
|
+
|
14
|
+
expect(WebsiteHistory.foreign_class).to eq(Website)
|
15
|
+
|
16
|
+
expect(WebsiteHistory).to respond_to(:setup_history_associations)
|
17
|
+
expect(WebsiteHistory).to respond_to(:original_class)
|
18
|
+
|
19
|
+
expect { WebsiteHistory.setup_history_associations }.not_to raise_error
|
20
|
+
end
|
21
|
+
|
22
|
+
it 'allows creating and querying history records' do
|
23
|
+
user = User.create!(name: 'Test User')
|
24
|
+
|
25
|
+
website = Website.create!(
|
26
|
+
name: 'Production Site',
|
27
|
+
project_id: 1,
|
28
|
+
user_id: user.id,
|
29
|
+
history_user_id: user.id
|
30
|
+
)
|
31
|
+
|
32
|
+
expect(website.histories.count).to eq(1)
|
33
|
+
|
34
|
+
history = website.histories.first
|
35
|
+
expect(history).to be_a(WebsiteHistory)
|
36
|
+
expect(history.website_id).to eq(website.id)
|
37
|
+
expect(history.name).to eq('Production Site')
|
38
|
+
expect(history.history_user_id).to eq(user.id)
|
39
|
+
expect(history.history_started_at).to be_present
|
40
|
+
expect(history.history_ended_at).to be_nil
|
41
|
+
|
42
|
+
website.update!(name: 'Updated Site', history_user_id: user.id)
|
43
|
+
|
44
|
+
expect(website.histories.count).to eq(2)
|
45
|
+
|
46
|
+
old_history = website.histories.where.not(history_ended_at: nil).first
|
47
|
+
expect(old_history.name).to eq('Production Site')
|
48
|
+
expect(old_history.history_ended_at).to be_present
|
49
|
+
|
50
|
+
current_history = website.histories.current.first
|
51
|
+
expect(current_history.name).to eq('Updated Site')
|
52
|
+
expect(current_history.history_ended_at).to be_nil
|
53
|
+
end
|
54
|
+
|
55
|
+
it 'supports associations on history models' do
|
56
|
+
website = Website.create!(name: 'Deploy Test Site', history_user_id: 1)
|
57
|
+
history = website.histories.first
|
58
|
+
|
59
|
+
deploy = Deploy.create!(
|
60
|
+
website_history_id: history.id,
|
61
|
+
status: 'pending'
|
62
|
+
)
|
63
|
+
|
64
|
+
expect(history.deploys).to include(deploy)
|
65
|
+
expect(deploy.website_history).to eq(history)
|
66
|
+
|
67
|
+
expect(history.deploys.where(status: 'pending').count).to eq(1)
|
68
|
+
end
|
69
|
+
|
70
|
+
it 'handles Safe mode without requiring history_user_id after initial creation' do
|
71
|
+
website = Website.create!(name: 'Safe Mode Test', history_user_id: 1)
|
72
|
+
|
73
|
+
expect { website.update!(name: 'Updated Safe Mode Test') }.not_to raise_error
|
74
|
+
|
75
|
+
expect(website.histories.count).to eq(2)
|
76
|
+
|
77
|
+
current_history = website.histories.current.first
|
78
|
+
expect(current_history.name).to eq('Updated Safe Mode Test')
|
79
|
+
expect(current_history.history_user_id).to eq(1)
|
80
|
+
|
81
|
+
website.update!(name: 'Another Update', history_user_id: nil)
|
82
|
+
expect(website.histories.count).to eq(3)
|
83
|
+
newest_history = website.histories.current.first
|
84
|
+
expect(newest_history.history_user_id).to be_nil
|
85
|
+
end
|
86
|
+
|
87
|
+
it 'properly sets up delegated methods on history instances' do
|
88
|
+
website = Website.create!(name: 'Method Delegation Test', history_user_id: 1)
|
89
|
+
history = website.histories.first
|
90
|
+
|
91
|
+
expect(history).to respond_to(:name)
|
92
|
+
|
93
|
+
expect(history.name).to eq('Method Delegation Test')
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
describe 'Rails after_initialize hook' do
|
98
|
+
it 'sets up associations after Rails initialization' do
|
99
|
+
expect(WebsiteHistory.reflect_on_association(:website)).to be_present
|
100
|
+
expect(WebsiteHistory.reflect_on_association(:deploys)).to be_present
|
101
|
+
|
102
|
+
website_association = WebsiteHistory.reflect_on_association(:website)
|
103
|
+
expect(website_association.class_name).to eq('Website')
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
data/spec/spec_helper.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: historiographer
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 4.4.
|
4
|
+
version: 4.4.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- brettshollenberger
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-08-
|
11
|
+
date: 2025-08-22 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -208,7 +208,12 @@ dependencies:
|
|
208
208
|
version: '0'
|
209
209
|
description: Creates separate tables for each history table
|
210
210
|
email: brett.shollenberger@gmail.com
|
211
|
-
executables:
|
211
|
+
executables:
|
212
|
+
- console
|
213
|
+
- setup
|
214
|
+
- test
|
215
|
+
- test-all
|
216
|
+
- test-rails
|
212
217
|
extensions: []
|
213
218
|
extra_rdoc_files:
|
214
219
|
- LICENSE.txt
|
@@ -218,6 +223,7 @@ files:
|
|
218
223
|
- ".rspec"
|
219
224
|
- ".ruby-version"
|
220
225
|
- ".standalone_migrations"
|
226
|
+
- DEVELOPMENT.md
|
221
227
|
- Gemfile
|
222
228
|
- Gemfile.lock
|
223
229
|
- Guardfile
|
@@ -225,6 +231,11 @@ files:
|
|
225
231
|
- README.md
|
226
232
|
- Rakefile
|
227
233
|
- VERSION
|
234
|
+
- bin/console
|
235
|
+
- bin/setup
|
236
|
+
- bin/test
|
237
|
+
- bin/test-all
|
238
|
+
- bin/test-rails
|
228
239
|
- historiographer-4.1.12.gem
|
229
240
|
- historiographer-4.1.13.gem
|
230
241
|
- historiographer-4.1.14.gem
|
@@ -244,6 +255,7 @@ files:
|
|
244
255
|
- lib/historiographer/safe.rb
|
245
256
|
- lib/historiographer/silent.rb
|
246
257
|
- lib/historiographer/version.rb
|
258
|
+
- spec/combustion_helper.rb
|
247
259
|
- spec/db/database.yml
|
248
260
|
- spec/db/migrate/20161121212228_create_posts.rb
|
249
261
|
- spec/db/migrate/20161121212229_create_post_histories.rb
|
@@ -262,13 +274,30 @@ files:
|
|
262
274
|
- spec/db/migrate/20241119000000_create_datasets.rb
|
263
275
|
- spec/db/migrate/2025082100000_create_projects.rb
|
264
276
|
- spec/db/migrate/2025082100001_create_project_files.rb
|
277
|
+
- spec/db/migrate/20250823000000_create_easy_ml_columns.rb
|
278
|
+
- spec/db/migrate/20250824000000_create_test_articles.rb
|
279
|
+
- spec/db/migrate/20250824000001_create_test_categories.rb
|
280
|
+
- spec/db/migrate/20250825000000_create_bylines.rb
|
281
|
+
- spec/db/migrate/20250826000000_create_test_users.rb
|
282
|
+
- spec/db/migrate/20250826000001_create_test_user_histories.rb
|
283
|
+
- spec/db/migrate/20250826000002_create_test_websites.rb
|
284
|
+
- spec/db/migrate/20250826000003_create_test_website_histories.rb
|
265
285
|
- spec/db/schema.rb
|
266
286
|
- spec/factories/post.rb
|
267
|
-
- spec/foreign_key_spec.rb
|
268
287
|
- spec/historiographer_spec.rb
|
288
|
+
- spec/integration/historiographer_safe_integration_spec.rb
|
289
|
+
- spec/internal/app/models/application_record.rb
|
290
|
+
- spec/internal/app/models/deploy.rb
|
291
|
+
- spec/internal/app/models/user.rb
|
292
|
+
- spec/internal/app/models/website.rb
|
293
|
+
- spec/internal/app/models/website_history.rb
|
294
|
+
- spec/internal/config/database.yml
|
295
|
+
- spec/internal/config/routes.rb
|
296
|
+
- spec/internal/db/schema.rb
|
269
297
|
- spec/models/application_record.rb
|
270
298
|
- spec/models/author.rb
|
271
299
|
- spec/models/author_history.rb
|
300
|
+
- spec/models/byline.rb
|
272
301
|
- spec/models/comment.rb
|
273
302
|
- spec/models/comment_history.rb
|
274
303
|
- spec/models/easy_ml/column.rb
|
@@ -283,10 +312,19 @@ files:
|
|
283
312
|
- spec/models/safe_post_history.rb
|
284
313
|
- spec/models/silent_post.rb
|
285
314
|
- spec/models/silent_post_history.rb
|
315
|
+
- spec/models/test_article.rb
|
316
|
+
- spec/models/test_article_history.rb
|
317
|
+
- spec/models/test_category.rb
|
318
|
+
- spec/models/test_category_history.rb
|
319
|
+
- spec/models/test_user.rb
|
320
|
+
- spec/models/test_user_history.rb
|
321
|
+
- spec/models/test_website.rb
|
322
|
+
- spec/models/test_website_history.rb
|
286
323
|
- spec/models/thing_with_compound_index.rb
|
287
324
|
- spec/models/thing_with_compound_index_history.rb
|
288
325
|
- spec/models/thing_without_history.rb
|
289
326
|
- spec/models/user.rb
|
327
|
+
- spec/rails_integration/historiographer_rails_integration_spec.rb
|
290
328
|
- spec/spec_helper.rb
|
291
329
|
homepage: http://github.com/brettshollenberger/historiographer
|
292
330
|
licenses:
|