sahih-al-bukhari 3.1.2
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 +196 -0
- data/bin/bukhari +262 -0
- data/data/bukhari.json.gz +0 -0
- data/lib/sahih_al_bukhari/bukhari.rb +245 -0
- data/lib/sahih_al_bukhari/version.rb +8 -0
- data/lib/sahih_al_bukhari.rb +13 -0
- data/sahih_al_bukhari.gemspec +51 -0
- metadata +127 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e6ebc0c85847f52e123dc4894cef9e8d0a139a3659b1c57f77e85a90fbc3b57e
|
|
4
|
+
data.tar.gz: 5eac6a24cc055bff4f09ce2a866e8ddf855a7c6fecb6dfd0bfcab92765246cee
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0e883151bc93ba133e9ae2fbf1c097e5174acbecd1ef6470eb2cf85aef9071bbca0e926339c8d070076b16df5e8576169de788d6ca097aebb5f622dfcd104a5d
|
|
7
|
+
data.tar.gz: 15eec75b751bbc3d093421cedeb7662fc796fd4a28a0826bf12b809a7a7855507050cd842602ddcfc24d5ebd511689410c01e85784e8b151dfaddf084fc5d4bf
|
data/README.md
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# Sahih al-Bukhari Ruby Gem
|
|
2
|
+
|
|
3
|
+
Complete Sahih al-Bukhari collection for Ruby — 7,277 authentic hadiths with full Arabic text and English translations.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Offline-first**: All data is bundled, no internet required
|
|
8
|
+
- **Zero dependencies**: Pure Ruby implementation
|
|
9
|
+
- **Complete collection**: All 7,277 hadiths from Sahih al-Bukhari
|
|
10
|
+
- **Bilingual**: Arabic text with English translations
|
|
11
|
+
- **CLI tool**: Command-line interface for quick access
|
|
12
|
+
- **Ruby library**: Easy integration into Ruby applications
|
|
13
|
+
- **Search functionality**: Find hadiths by text search
|
|
14
|
+
- **Chapter organization**: Browse by traditional chapter structure
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Add this line to your application's Gemfile:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem 'sahih-al-bukhari'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
And then execute:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
$ bundle install
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or install it yourself as:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
$ gem install sahih-al-bukhari
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
### Ruby Library
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
require 'sahih_al_bukhari'
|
|
42
|
+
|
|
43
|
+
# Initialize the collection
|
|
44
|
+
bukhari = SahihAlBukhari::Bukhari.new
|
|
45
|
+
|
|
46
|
+
# Get a specific hadith
|
|
47
|
+
hadith = bukhari.get(1)
|
|
48
|
+
puts hadith.arabic
|
|
49
|
+
puts hadith.english
|
|
50
|
+
|
|
51
|
+
# Search for hadiths
|
|
52
|
+
results = bukhari.search("prayer")
|
|
53
|
+
results.each { |h| puts h.english }
|
|
54
|
+
|
|
55
|
+
# Get random hadith
|
|
56
|
+
random = bukhari.get_random
|
|
57
|
+
puts random.english
|
|
58
|
+
|
|
59
|
+
# Get hadiths by chapter
|
|
60
|
+
chapter_hadiths = bukhari.get_by_chapter(1)
|
|
61
|
+
|
|
62
|
+
# Browse chapters
|
|
63
|
+
bukhari.chapters.each do |chapter|
|
|
64
|
+
puts "Chapter #{chapter.id}: #{chapter.title}"
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Command Line Interface
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Get a specific hadith
|
|
72
|
+
bukhari -n 1
|
|
73
|
+
|
|
74
|
+
# Search for hadiths
|
|
75
|
+
bukhari -s prayer
|
|
76
|
+
|
|
77
|
+
# Get random hadith
|
|
78
|
+
bukhari -r
|
|
79
|
+
|
|
80
|
+
# List all chapters
|
|
81
|
+
bukhari -l
|
|
82
|
+
|
|
83
|
+
# Get hadiths from a specific chapter
|
|
84
|
+
bukhari -c 1
|
|
85
|
+
|
|
86
|
+
# Show metadata
|
|
87
|
+
bukhari -m
|
|
88
|
+
|
|
89
|
+
# Output as JSON
|
|
90
|
+
bukhari -n 1 --json
|
|
91
|
+
|
|
92
|
+
# Limit search results
|
|
93
|
+
bukhari -s prayer --limit 5
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## API Reference
|
|
97
|
+
|
|
98
|
+
### SahihAlBukhari::Bukhari
|
|
99
|
+
|
|
100
|
+
#### Constructor
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
bukhari = SahihAlBukhari::Bukhari.new(data_path: optional_custom_path)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
#### Methods
|
|
107
|
+
|
|
108
|
+
- `get(number)` - Get hadith by number (1-based)
|
|
109
|
+
- `get_by_chapter(chapter_id)` - Get all hadiths from a chapter
|
|
110
|
+
- `search(query)` - Search hadiths by Arabic or English text
|
|
111
|
+
- `get_random()` - Get a random hadith
|
|
112
|
+
- `chapters` - Array of all chapters
|
|
113
|
+
- `metadata` - Collection metadata
|
|
114
|
+
- `length` - Total number of hadiths
|
|
115
|
+
|
|
116
|
+
#### Data Classes
|
|
117
|
+
|
|
118
|
+
**Hadith**
|
|
119
|
+
- `number` - Hadith number
|
|
120
|
+
- `arabic` - Arabic text
|
|
121
|
+
- `english` - English translation
|
|
122
|
+
- `chapter_id` - Chapter ID
|
|
123
|
+
|
|
124
|
+
**Chapter**
|
|
125
|
+
- `id` - Chapter ID
|
|
126
|
+
- `title` - English title
|
|
127
|
+
- `arabic_title` - Arabic title
|
|
128
|
+
|
|
129
|
+
**Metadata**
|
|
130
|
+
- `total_hadiths` - Total number of hadiths
|
|
131
|
+
- `total_chapters` - Total number of chapters
|
|
132
|
+
- `language` - Language codes
|
|
133
|
+
- `source` - Data source information
|
|
134
|
+
|
|
135
|
+
## CLI Options
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
Usage: bukhari [options]
|
|
139
|
+
|
|
140
|
+
-n, --number NUMBER Get specific hadith by number
|
|
141
|
+
-c, --chapter CHAPTER Get hadiths from specific chapter
|
|
142
|
+
-s, --search QUERY Search hadiths
|
|
143
|
+
-r, --random Get random hadith
|
|
144
|
+
-l, --list-chapters List all chapters
|
|
145
|
+
-m, --metadata Show collection metadata
|
|
146
|
+
-j, --json Output as JSON
|
|
147
|
+
--chapter-info Show chapter info with hadiths
|
|
148
|
+
--limit LIMIT Limit search results (default: 10)
|
|
149
|
+
-v, --version Show version
|
|
150
|
+
-h, --help Show this help
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Data Source
|
|
154
|
+
|
|
155
|
+
This gem shares the same data files as the Node.js and Python packages in this repository:
|
|
156
|
+
|
|
157
|
+
- `bin/bukhari.json` - Main data file (shared across all packages)
|
|
158
|
+
- `chapters/` - Chapter metadata files
|
|
159
|
+
|
|
160
|
+
The data is automatically detected in the following priority order:
|
|
161
|
+
|
|
162
|
+
1. Custom `data_path` parameter
|
|
163
|
+
2. `bin/bukhari.json` at repository root
|
|
164
|
+
3. Bundled data in the gem
|
|
165
|
+
4. CDN fallback (downloads automatically)
|
|
166
|
+
|
|
167
|
+
## Development
|
|
168
|
+
|
|
169
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
|
|
170
|
+
|
|
171
|
+
You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
172
|
+
|
|
173
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
174
|
+
|
|
175
|
+
### Rake Tasks
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
rake spec # Run tests
|
|
179
|
+
rake yard # Generate documentation
|
|
180
|
+
rake bukhari:test_basic # Test basic functionality
|
|
181
|
+
rake bukhari:test_cli # Test CLI
|
|
182
|
+
rake bukhari:prepare_data # Download data files
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Contributing
|
|
186
|
+
|
|
187
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/SENODROOM/sahih-al-bukhari.
|
|
188
|
+
|
|
189
|
+
## License
|
|
190
|
+
|
|
191
|
+
This gem is available as open source under the terms of the AGPL-3.0 License.
|
|
192
|
+
|
|
193
|
+
## Version History
|
|
194
|
+
|
|
195
|
+
- **3.1.2** - Initial Ruby implementation
|
|
196
|
+
- Matches API compatibility with Python and Node.js versions
|
data/bin/bukhari
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# CLI for sahih-al-bukhari (Ruby package)
|
|
5
|
+
|
|
6
|
+
require 'optparse'
|
|
7
|
+
require 'json'
|
|
8
|
+
require_relative '../sahih_al_bukhari'
|
|
9
|
+
|
|
10
|
+
VERSION = SahihAlBukhari::VERSION
|
|
11
|
+
|
|
12
|
+
# ANSI color codes
|
|
13
|
+
module Colors
|
|
14
|
+
RESET = "\e[0m"
|
|
15
|
+
BOLD = "\e[1m"
|
|
16
|
+
DIM = "\e[2m"
|
|
17
|
+
GREEN = "\e[32m"
|
|
18
|
+
YELLOW = "\e[33m"
|
|
19
|
+
CYAN = "\e[36m"
|
|
20
|
+
MAGENTA = "\e[35m"
|
|
21
|
+
BLUE = "\e[34m"
|
|
22
|
+
RED = "\e[31m"
|
|
23
|
+
GRAY = "\e[90m"
|
|
24
|
+
|
|
25
|
+
def self.color(code, text)
|
|
26
|
+
"#{code}#{text}#{RESET}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.bold(text); color(BOLD, text); end
|
|
30
|
+
def self.green(text); color(GREEN, text); end
|
|
31
|
+
def self.yellow(text); color(YELLOW, text); end
|
|
32
|
+
def self.cyan(text); color(CYAN, text); end
|
|
33
|
+
def self.magenta(text); color(MAGENTA, text); end
|
|
34
|
+
def self.blue(text); color(BLUE, text); end
|
|
35
|
+
def self.red(text); color(RED, text); end
|
|
36
|
+
def self.gray(text); color(GRAY, text); end
|
|
37
|
+
def self.dim(text); color(DIM, text); end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
DIV = Colors.gray('─' * 60)
|
|
41
|
+
|
|
42
|
+
def format_hadith(hadith, show_chapter: false)
|
|
43
|
+
output = []
|
|
44
|
+
output << Colors.bold("Hadith #{hadith.number}")
|
|
45
|
+
|
|
46
|
+
if show_chapter
|
|
47
|
+
chapter = bukhari.chapters.find { |c| c.id == hadith.chapter_id }
|
|
48
|
+
output << Colors.cyan("Chapter #{chapter.id}: #{chapter.title}") if chapter
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
output << ""
|
|
52
|
+
output << Colors.bold("Arabic:")
|
|
53
|
+
output << hadith.arabic
|
|
54
|
+
output << ""
|
|
55
|
+
output << Colors.bold("English:")
|
|
56
|
+
output << hadith.english
|
|
57
|
+
|
|
58
|
+
output.join("\n")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def format_chapter(chapter)
|
|
62
|
+
output = []
|
|
63
|
+
output << Colors.bold("Chapter #{chapter.id}: #{chapter.title}")
|
|
64
|
+
output << Colors.dim(chapter.arabic_title)
|
|
65
|
+
output.join("\n")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def print_hadith(hadith, show_chapter: false)
|
|
69
|
+
puts DIV
|
|
70
|
+
puts format_hadith(hadith, show_chapter: show_chapter)
|
|
71
|
+
puts DIV
|
|
72
|
+
puts
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def print_chapter(chapter)
|
|
76
|
+
puts DIV
|
|
77
|
+
puts format_chapter(chapter)
|
|
78
|
+
puts DIV
|
|
79
|
+
puts
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def print_list(items, title)
|
|
83
|
+
puts Colors.bold("#{title} (#{items.length}):")
|
|
84
|
+
puts
|
|
85
|
+
items.each { |item| puts " #{item}" }
|
|
86
|
+
puts
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Parse command line arguments
|
|
90
|
+
options = {
|
|
91
|
+
number: nil,
|
|
92
|
+
chapter: nil,
|
|
93
|
+
search: nil,
|
|
94
|
+
random: false,
|
|
95
|
+
list_chapters: false,
|
|
96
|
+
metadata: false,
|
|
97
|
+
json: false,
|
|
98
|
+
chapter_info: false,
|
|
99
|
+
limit: 10
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
parser = OptionParser.new do |opts|
|
|
103
|
+
opts.banner = "Usage: bukhari [options]"
|
|
104
|
+
|
|
105
|
+
opts.on("-n", "--number NUMBER", Integer, "Get specific hadith by number") do |n|
|
|
106
|
+
options[:number] = n
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
opts.on("-c", "--chapter CHAPTER", Integer, "Get hadiths from specific chapter") do |c|
|
|
110
|
+
options[:chapter] = c
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
opts.on("-s", "--search QUERY", "Search hadiths") do |q|
|
|
114
|
+
options[:search] = q
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
opts.on("-r", "--random", "Get random hadith") do
|
|
118
|
+
options[:random] = true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
opts.on("-l", "--list-chapters", "List all chapters") do
|
|
122
|
+
options[:list_chapters] = true
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
opts.on("-m", "--metadata", "Show collection metadata") do
|
|
126
|
+
options[:metadata] = true
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
opts.on("-j", "--json", "Output as JSON") do
|
|
130
|
+
options[:json] = true
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
opts.on("--chapter-info", "Show chapter info with hadiths") do
|
|
134
|
+
options[:chapter_info] = true
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
opts.on("--limit LIMIT", Integer, "Limit search results (default: 10)") do |l|
|
|
138
|
+
options[:limit] = l
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
opts.on("-v", "--version", "Show version") do
|
|
142
|
+
puts "bukhari #{VERSION}"
|
|
143
|
+
exit
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
opts.on("-h", "--help", "Show this help") do
|
|
147
|
+
puts opts
|
|
148
|
+
puts
|
|
149
|
+
puts Colors.bold("Examples:")
|
|
150
|
+
puts " bukhari -n 1 # Get hadith #1"
|
|
151
|
+
puts " bukhari -c 1 # Get hadiths from chapter 1"
|
|
152
|
+
puts " bukhari -s prayer # Search for 'prayer'"
|
|
153
|
+
puts " bukhari -r # Get random hadith"
|
|
154
|
+
puts " bukhari -l # List chapters"
|
|
155
|
+
puts " bukhari -m # Show metadata"
|
|
156
|
+
puts " bukhari -s prayer --limit 5 # Search with limit"
|
|
157
|
+
puts " bukhari -n 1 --json # Output as JSON"
|
|
158
|
+
exit
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
begin
|
|
163
|
+
parser.parse!
|
|
164
|
+
rescue OptionParser::InvalidOption => e
|
|
165
|
+
puts Colors.red("Error: #{e.message}")
|
|
166
|
+
puts parser
|
|
167
|
+
exit 1
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Initialize Bukhari instance
|
|
171
|
+
begin
|
|
172
|
+
$bukhari = SahihAlBukhari::Bukhari.new
|
|
173
|
+
rescue SahihAlBukhari::Error => e
|
|
174
|
+
puts Colors.red("Error: #{e.message}")
|
|
175
|
+
exit 1
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Execute commands
|
|
179
|
+
if options[:number]
|
|
180
|
+
begin
|
|
181
|
+
hadith = $bukhari.get(options[:number])
|
|
182
|
+
if options[:json]
|
|
183
|
+
puts hadith.to_json
|
|
184
|
+
else
|
|
185
|
+
print_hadith(hadith, show_chapter: options[:chapter_info])
|
|
186
|
+
end
|
|
187
|
+
rescue SahihAlBukhari::HadithNotFoundError => e
|
|
188
|
+
puts Colors.red("Error: #{e.message}")
|
|
189
|
+
exit 1
|
|
190
|
+
end
|
|
191
|
+
elsif options[:chapter]
|
|
192
|
+
begin
|
|
193
|
+
hadiths = $bukhari.get_by_chapter(options[:chapter])
|
|
194
|
+
if options[:json]
|
|
195
|
+
puts hadiths.map(&:to_h).to_json
|
|
196
|
+
else
|
|
197
|
+
chapter = $bukhari.chapters.find { |c| c.id == options[:chapter] }
|
|
198
|
+
if chapter
|
|
199
|
+
print_chapter(chapter)
|
|
200
|
+
puts Colors.bold("Hadiths in this chapter (#{hadiths.length}):")
|
|
201
|
+
puts
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
hadiths.each { |h| print_hadith(h) }
|
|
205
|
+
end
|
|
206
|
+
rescue SahihAlBukhari::ChapterNotFoundError => e
|
|
207
|
+
puts Colors.red("Error: #{e.message}")
|
|
208
|
+
exit 1
|
|
209
|
+
end
|
|
210
|
+
elsif options[:search]
|
|
211
|
+
results = $bukhari.search(options[:search])
|
|
212
|
+
limited_results = results.first(options[:limit])
|
|
213
|
+
|
|
214
|
+
if options[:json]
|
|
215
|
+
puts limited_results.map(&:to_h).to_json
|
|
216
|
+
else
|
|
217
|
+
if results.empty?
|
|
218
|
+
puts Colors.yellow("No hadiths found for '#{options[:search]}'")
|
|
219
|
+
else
|
|
220
|
+
puts Colors.bold("Found #{results.length} hadith(s) matching '#{options[:search]}'")
|
|
221
|
+
puts Colors.dim("Showing first #{limited_results.length}:") if results.length > options[:limit]
|
|
222
|
+
puts
|
|
223
|
+
|
|
224
|
+
limited_results.each { |h| print_hadith(h) }
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
elsif options[:random]
|
|
228
|
+
hadith = $bukhari.get_random
|
|
229
|
+
if options[:json]
|
|
230
|
+
puts hadith.to_json
|
|
231
|
+
else
|
|
232
|
+
print_hadith(hadith, show_chapter: options[:chapter_info])
|
|
233
|
+
end
|
|
234
|
+
elsif options[:list_chapters]
|
|
235
|
+
if options[:json]
|
|
236
|
+
puts $bukhari.chapters.map(&:to_h).to_json
|
|
237
|
+
else
|
|
238
|
+
puts Colors.bold("Chapters in Sahih al-Bukhari:")
|
|
239
|
+
puts
|
|
240
|
+
$bukhari.chapters.each { |c| print_chapter(c) }
|
|
241
|
+
end
|
|
242
|
+
elsif options[:metadata]
|
|
243
|
+
if options[:json]
|
|
244
|
+
puts $bukhari.metadata.to_h.to_json
|
|
245
|
+
else
|
|
246
|
+
puts Colors.bold("Sahih al-Bukhari Collection Metadata:")
|
|
247
|
+
puts
|
|
248
|
+
metadata = $bukhari.metadata
|
|
249
|
+
puts " Total Hadiths: #{Colors.green(metadata.total_hadiths.to_s)}"
|
|
250
|
+
puts " Total Chapters: #{Colors.green(metadata.total_chapters.to_s)}"
|
|
251
|
+
puts " Language: #{Colors.cyan(metadata.language)}"
|
|
252
|
+
puts " Source: #{Colors.dim(metadata.source)}"
|
|
253
|
+
puts
|
|
254
|
+
end
|
|
255
|
+
else
|
|
256
|
+
# Default: show help
|
|
257
|
+
puts parser
|
|
258
|
+
puts Colors.bold("Quick start:")
|
|
259
|
+
puts " bukhari -r # Get a random hadith"
|
|
260
|
+
puts " bukhari -s prayer # Search for hadiths about prayer"
|
|
261
|
+
puts " bukhari -l # List all chapters"
|
|
262
|
+
end
|
|
Binary file
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# sahih-al-bukhari — Complete Sahih al-Bukhari for Ruby.
|
|
4
|
+
#
|
|
5
|
+
# Quick start:
|
|
6
|
+
# require 'sahih_al_bukhari'
|
|
7
|
+
#
|
|
8
|
+
# bukhari = SahihAlBukhari::Bukhari.new
|
|
9
|
+
# bukhari.get(1)
|
|
10
|
+
# bukhari.search("prayer")
|
|
11
|
+
# bukhari.get_random
|
|
12
|
+
# bukhari.get_by_chapter(1)
|
|
13
|
+
|
|
14
|
+
require 'json'
|
|
15
|
+
require 'zlib'
|
|
16
|
+
require 'net/http'
|
|
17
|
+
require 'uri'
|
|
18
|
+
require 'fileutils'
|
|
19
|
+
require 'tmpdir'
|
|
20
|
+
|
|
21
|
+
require_relative 'version'
|
|
22
|
+
|
|
23
|
+
module SahihAlBukhari
|
|
24
|
+
CDN_URL = "https://cdn.jsdelivr.net/npm/sahih-al-bukhari@#{VERSION}/data/bukhari.json.gz"
|
|
25
|
+
|
|
26
|
+
# Locate the shared data file relative to the gem root.
|
|
27
|
+
# Layout (installed gem): lib/sahih_al_bukhari/bukhari.rb
|
|
28
|
+
# data/bukhari.json.gz ← same gem root
|
|
29
|
+
# Layout (monorepo dev): ruby/lib/sahih_al_bukhari/bukhari.rb
|
|
30
|
+
# data/bukhari.json.gz ← repo root
|
|
31
|
+
_LIB_DIR = File.expand_path('..', __dir__) # …/ruby/lib
|
|
32
|
+
_GEM_ROOT = File.expand_path('..', _LIB_DIR) # …/ruby (installed: gem root)
|
|
33
|
+
_REPO_ROOT = File.expand_path('..', _GEM_ROOT) # …/ (monorepo root)
|
|
34
|
+
|
|
35
|
+
INSTALLED_DATA = File.join(_GEM_ROOT, 'data', 'bukhari.json.gz')
|
|
36
|
+
REPO_DATA_GZ = File.join(_REPO_ROOT, 'data', 'bukhari.json.gz')
|
|
37
|
+
REPO_DATA_JSON = File.join(_REPO_ROOT, 'data', 'bukhari.json')
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
class Error < StandardError; end
|
|
41
|
+
class HadithNotFoundError < Error; end
|
|
42
|
+
class ChapterNotFoundError < Error; end
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Data structures
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
class Hadith
|
|
49
|
+
# FIX: JSON uses keys "id" and "chapterId", NOT "number" / "chapter_id".
|
|
50
|
+
# All accesses in the original code used the wrong key names, so every
|
|
51
|
+
# hadith came back with nil fields.
|
|
52
|
+
attr_reader :id, :number, :arabic, :english, :chapter_id
|
|
53
|
+
|
|
54
|
+
def initialize(data)
|
|
55
|
+
@id = data['id']
|
|
56
|
+
@number = data['id'] # alias kept for backward compat
|
|
57
|
+
@chapter_id = data['chapterId']
|
|
58
|
+
@arabic = data['arabic'] || ''
|
|
59
|
+
_en = data['english'] || {}
|
|
60
|
+
# english field is a Hash {"narrator"=>..., "text"=>...}
|
|
61
|
+
@english = _en
|
|
62
|
+
@narrator = _en['narrator'] || ''
|
|
63
|
+
@text = _en['text'] || ''
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def narrator = @narrator
|
|
67
|
+
def text = @text
|
|
68
|
+
|
|
69
|
+
def to_h
|
|
70
|
+
{
|
|
71
|
+
id: @id,
|
|
72
|
+
number: @number,
|
|
73
|
+
arabic: @arabic,
|
|
74
|
+
english: @english,
|
|
75
|
+
chapter_id: @chapter_id
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def to_json(*args) = to_h.to_json(*args)
|
|
80
|
+
|
|
81
|
+
def inspect
|
|
82
|
+
preview = @text.length > 60 ? "#{@text[0, 60]}…" : @text
|
|
83
|
+
"#<Hadith id=#{@id} chapterId=#{@chapter_id} text=#{preview.inspect}>"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
class Chapter
|
|
90
|
+
# FIX: JSON uses keys "english" and "arabic" for the title strings.
|
|
91
|
+
# Original code passed "title" / "arabic_title" which don't exist in the data.
|
|
92
|
+
attr_reader :id, :title, :arabic_title
|
|
93
|
+
|
|
94
|
+
def initialize(data)
|
|
95
|
+
@id = data['id']
|
|
96
|
+
@title = data['english'] || '' # FIX: key is "english", not "title"
|
|
97
|
+
@arabic_title = data['arabic'] || '' # FIX: key is "arabic", not "arabic_title"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def to_h
|
|
101
|
+
{ id: @id, title: @title, arabic_title: @arabic_title }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def to_json(*args) = to_h.to_json(*args)
|
|
105
|
+
def inspect = "#<Chapter id=#{@id} title=#{@title.inspect}>"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
class Metadata
|
|
111
|
+
# FIX: the actual JSON has a completely different metadata structure:
|
|
112
|
+
# { "id": 1, "length": 7277, "arabic": {...}, "english": {...} }
|
|
113
|
+
# The original code expected flat keys like "total_hadiths", "language",
|
|
114
|
+
# "source" — none of which exist — so metadata was always nil/broken.
|
|
115
|
+
attr_reader :total_hadiths, :total_chapters, :language, :source,
|
|
116
|
+
:arabic, :english
|
|
117
|
+
|
|
118
|
+
def initialize(data, total_chapters)
|
|
119
|
+
@total_hadiths = data['length'] || 0
|
|
120
|
+
@total_chapters = total_chapters
|
|
121
|
+
@arabic = data['arabic'] || {}
|
|
122
|
+
@english = data['english'] || {}
|
|
123
|
+
@language = 'Arabic / English'
|
|
124
|
+
@source = @english['title'] || 'Sahih al-Bukhari'
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def to_h
|
|
128
|
+
{
|
|
129
|
+
total_hadiths: @total_hadiths,
|
|
130
|
+
total_chapters: @total_chapters,
|
|
131
|
+
language: @language,
|
|
132
|
+
source: @source,
|
|
133
|
+
arabic: @arabic,
|
|
134
|
+
english: @english
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def to_json(*args) = to_h.to_json(*args)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# Main Bukhari class
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
class Bukhari
|
|
146
|
+
include Enumerable
|
|
147
|
+
|
|
148
|
+
attr_reader :metadata, :chapters, :length
|
|
149
|
+
|
|
150
|
+
def initialize(data_path: nil)
|
|
151
|
+
path = data_path || find_data_file
|
|
152
|
+
raw = load_raw(path)
|
|
153
|
+
|
|
154
|
+
@chapters = raw['chapters'].map { |c| Chapter.new(c) }
|
|
155
|
+
@metadata = Metadata.new(raw['metadata'], @chapters.length)
|
|
156
|
+
# Build Hadith objects and an O(1) lookup map
|
|
157
|
+
@hadiths = raw['hadiths'].map { |h| Hadith.new(h) }
|
|
158
|
+
@by_id = @hadiths.each_with_object({}) { |h, m| m[h.id] = h }
|
|
159
|
+
@length = @hadiths.length
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# ── Public API ────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
# Get a hadith by its global id (1-based, matches the JSON "id" field).
|
|
165
|
+
def get(id)
|
|
166
|
+
@by_id[id] || raise(HadithNotFoundError, "Hadith #{id} not found")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# All hadiths belonging to chapter_id.
|
|
170
|
+
def get_by_chapter(chapter_id)
|
|
171
|
+
unless @chapters.any? { |c| c.id == chapter_id }
|
|
172
|
+
raise ChapterNotFoundError, "Chapter #{chapter_id} not found"
|
|
173
|
+
end
|
|
174
|
+
@hadiths.select { |h| h.chapter_id == chapter_id }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Full-text search across English text + narrator. Case-insensitive.
|
|
178
|
+
# FIX: original called String#downcase on the `english` field which is a
|
|
179
|
+
# Hash, not a String — that raised NoMethodError at runtime.
|
|
180
|
+
def search(query)
|
|
181
|
+
q = query.downcase
|
|
182
|
+
@hadiths.select do |h|
|
|
183
|
+
h.text.downcase.include?(q) || h.narrator.downcase.include?(q) ||
|
|
184
|
+
h.arabic.downcase.include?(q)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def get_random
|
|
189
|
+
@hadiths.sample
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Array-like index access (0-based).
|
|
193
|
+
def [](index)
|
|
194
|
+
@hadiths[index]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Enumerable — lets callers use .map, .select, .each, .first, etc.
|
|
198
|
+
def each(&block)
|
|
199
|
+
@hadiths.each(&block)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def to_a
|
|
203
|
+
@hadiths.dup
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# ── Private helpers ───────────────────────────────────────────────────────
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
def find_data_file
|
|
210
|
+
candidates = [INSTALLED_DATA, REPO_DATA_GZ, REPO_DATA_JSON]
|
|
211
|
+
candidates.find { |p| File.exist?(p) } || download_from_cdn
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def load_raw(path)
|
|
215
|
+
data = if path.end_with?('.gz')
|
|
216
|
+
Zlib::GzipReader.open(path) { |gz| gz.read }
|
|
217
|
+
else
|
|
218
|
+
File.read(path, encoding: 'utf-8')
|
|
219
|
+
end
|
|
220
|
+
JSON.parse(data)
|
|
221
|
+
rescue => e
|
|
222
|
+
raise Error, "Failed to load data from #{path}: #{e.message}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def download_from_cdn
|
|
226
|
+
# FIX: original hard-coded 'C:\temp' — use Ruby's cross-platform tmpdir
|
|
227
|
+
dest = File.join(Dir.tmpdir, 'bukhari.json.gz')
|
|
228
|
+
return dest if File.exist?(dest)
|
|
229
|
+
|
|
230
|
+
warn "[sahih-al-bukhari] Downloading data from CDN (one-time) …"
|
|
231
|
+
uri = URI(CDN_URL)
|
|
232
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
|
|
233
|
+
resp = http.get(uri.request_uri)
|
|
234
|
+
raise Error, "CDN returned HTTP #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
|
|
235
|
+
File.binwrite(dest, resp.body)
|
|
236
|
+
end
|
|
237
|
+
dest
|
|
238
|
+
rescue => e
|
|
239
|
+
raise Error, "Failed to download data from CDN: #{e.message}"
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Clear any cached data (no-op in current implementation, kept for API compat)
|
|
244
|
+
def self.clear_cache; end
|
|
245
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SahihAlBukhari
|
|
4
|
+
# FIX: gemspec referenced SahihAlBukhari::Version::VERSION (a nested module that
|
|
5
|
+
# never existed). The constant must live directly in SahihAlBukhari so that both
|
|
6
|
+
# the gemspec and sahih_al_bukhari.rb agree on its location.
|
|
7
|
+
VERSION = '3.1.2'
|
|
8
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# FIX: the original file contained only:
|
|
4
|
+
# require_relative '../sahih_al_bukhari'
|
|
5
|
+
# which tried to load ruby/sahih_al_bukhari.rb via a relative path that breaks
|
|
6
|
+
# once the gem is installed (the installed gem's require_paths is ['lib'], so
|
|
7
|
+
# `require 'sahih_al_bukhari'` resolves to THIS file, not the root-level one).
|
|
8
|
+
#
|
|
9
|
+
# The correct approach: this IS the library entry point. Load version first,
|
|
10
|
+
# then the main implementation file that lives alongside it.
|
|
11
|
+
|
|
12
|
+
require_relative 'sahih_al_bukhari/version'
|
|
13
|
+
require_relative 'sahih_al_bukhari/bukhari'
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/sahih_al_bukhari/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'sahih-al-bukhari'
|
|
7
|
+
spec.version = SahihAlBukhari::VERSION # FIX: was SahihAlBukhari::Version::VERSION (wrong namespace)
|
|
8
|
+
spec.authors = ['muhammadsaadamin']
|
|
9
|
+
spec.email = ['muhammadsaadamin@example.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'Complete Sahih al-Bukhari — 7,277 hadiths. Offline-first, zero dependencies. CLI + Ruby library.'
|
|
12
|
+
spec.description = 'Complete Sahih al-Bukhari collection with 7,277 authentic hadiths featuring full Arabic text and English translations. Offline-first with zero external dependencies. Includes both Ruby library and command-line interface.'
|
|
13
|
+
spec.homepage = 'https://github.com/SENODROOM/sahih-al-bukhari'
|
|
14
|
+
spec.license = 'AGPL-3.0'
|
|
15
|
+
spec.required_ruby_version = '>= 2.7.0'
|
|
16
|
+
|
|
17
|
+
# FIX: spec.metadata must be assigned ONCE — previous code assigned it twice,
|
|
18
|
+
# which silently overwrote the first assignment (losing allowed_push_host etc.)
|
|
19
|
+
spec.metadata = {
|
|
20
|
+
'allowed_push_host' => 'https://rubygems.org',
|
|
21
|
+
'homepage_uri' => 'https://github.com/SENODROOM/sahih-al-bukhari',
|
|
22
|
+
'source_code_uri' => 'https://github.com/SENODROOM/sahih-al-bukhari',
|
|
23
|
+
'changelog_uri' => 'https://github.com/SENODROOM/sahih-al-bukhari/blob/main/CHANGELOG.md',
|
|
24
|
+
'bug_tracker_uri' => 'https://github.com/SENODROOM/sahih-al-bukhari/issues',
|
|
25
|
+
'rubygems_mfa_required' => 'true'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# FIX: `git ls-files` fails when building outside a git worktree (e.g. CI unshallow
|
|
29
|
+
# clone, or after `gem build` in a temp directory). Use Dir.glob instead so the
|
|
30
|
+
# file list is always reliable.
|
|
31
|
+
spec.files = Dir.chdir(__dir__) do
|
|
32
|
+
Dir.glob('{bin,lib,data}/**/*', File::FNM_DOTMATCH)
|
|
33
|
+
.reject { |f| File.directory?(f) }
|
|
34
|
+
.concat(%w[README.md LICENSE sahih_al_bukhari.gemspec])
|
|
35
|
+
.select { |f| File.exist?(f) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
spec.bindir = 'bin'
|
|
39
|
+
spec.executables = ['bukhari']
|
|
40
|
+
spec.require_paths = ['lib']
|
|
41
|
+
|
|
42
|
+
# Development dependencies
|
|
43
|
+
spec.add_development_dependency 'rake', '~> 13.0'
|
|
44
|
+
spec.add_development_dependency 'test-unit', '~> 3.6' # FIX: removed from Ruby 4.0 stdlib
|
|
45
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
|
46
|
+
spec.add_development_dependency 'rubocop', '~> 1.21'
|
|
47
|
+
spec.add_development_dependency 'yard', '~> 0.9'
|
|
48
|
+
|
|
49
|
+
# FIX: spec.has_rdoc and spec.test_files are both deprecated and raise warnings
|
|
50
|
+
# on modern RubyGems — removed entirely.
|
|
51
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sahih-al-bukhari
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 3.1.2
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- muhammadsaadamin
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rake
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '13.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '13.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: test-unit
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.6'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.6'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rspec
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rubocop
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.21'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.21'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: yard
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0.9'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.9'
|
|
82
|
+
description: Complete Sahih al-Bukhari collection with 7,277 authentic hadiths featuring
|
|
83
|
+
full Arabic text and English translations. Offline-first with zero external dependencies.
|
|
84
|
+
Includes both Ruby library and command-line interface.
|
|
85
|
+
email:
|
|
86
|
+
- muhammadsaadamin@example.com
|
|
87
|
+
executables:
|
|
88
|
+
- bukhari
|
|
89
|
+
extensions: []
|
|
90
|
+
extra_rdoc_files: []
|
|
91
|
+
files:
|
|
92
|
+
- README.md
|
|
93
|
+
- bin/bukhari
|
|
94
|
+
- data/bukhari.json.gz
|
|
95
|
+
- lib/sahih_al_bukhari.rb
|
|
96
|
+
- lib/sahih_al_bukhari/bukhari.rb
|
|
97
|
+
- lib/sahih_al_bukhari/version.rb
|
|
98
|
+
- sahih_al_bukhari.gemspec
|
|
99
|
+
homepage: https://github.com/SENODROOM/sahih-al-bukhari
|
|
100
|
+
licenses:
|
|
101
|
+
- AGPL-3.0
|
|
102
|
+
metadata:
|
|
103
|
+
allowed_push_host: https://rubygems.org
|
|
104
|
+
homepage_uri: https://github.com/SENODROOM/sahih-al-bukhari
|
|
105
|
+
source_code_uri: https://github.com/SENODROOM/sahih-al-bukhari
|
|
106
|
+
changelog_uri: https://github.com/SENODROOM/sahih-al-bukhari/blob/main/CHANGELOG.md
|
|
107
|
+
bug_tracker_uri: https://github.com/SENODROOM/sahih-al-bukhari/issues
|
|
108
|
+
rubygems_mfa_required: 'true'
|
|
109
|
+
rdoc_options: []
|
|
110
|
+
require_paths:
|
|
111
|
+
- lib
|
|
112
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - ">="
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: 2.7.0
|
|
117
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
118
|
+
requirements:
|
|
119
|
+
- - ">="
|
|
120
|
+
- !ruby/object:Gem::Version
|
|
121
|
+
version: '0'
|
|
122
|
+
requirements: []
|
|
123
|
+
rubygems_version: 4.0.6
|
|
124
|
+
specification_version: 4
|
|
125
|
+
summary: Complete Sahih al-Bukhari — 7,277 hadiths. Offline-first, zero dependencies.
|
|
126
|
+
CLI + Ruby library.
|
|
127
|
+
test_files: []
|