quick_exam 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 +7 -0
- data/.gitignore +15 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +8 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +157 -0
- data/bin/console +23 -0
- data/bin/quick_exam +8 -0
- data/bin/setup +8 -0
- data/lib/quick_exam.rb +11 -0
- data/lib/quick_exam/analyst/base_docx.rb +57 -0
- data/lib/quick_exam/analyst/base_html.rb +106 -0
- data/lib/quick_exam/analyst/base_text.rb +53 -0
- data/lib/quick_exam/analyst/common.rb +125 -0
- data/lib/quick_exam/analyzer.rb +64 -0
- data/lib/quick_exam/cli.rb +82 -0
- data/lib/quick_exam/core_ext.rb +28 -0
- data/lib/quick_exam/export.rb +125 -0
- data/lib/quick_exam/format.rb +21 -0
- data/lib/quick_exam/handle_error.rb +2 -0
- data/lib/quick_exam/record.rb +29 -0
- data/lib/quick_exam/record_collection.rb +34 -0
- data/lib/quick_exam/version.rb +3 -0
- data/quick_exam.gemspec +53 -0
- data/sample/quick_sample.docx +0 -0
- data/sample/quick_sample.html +982 -0
- data/sample/quick_sample.txt +107 -0
- metadata +132 -0
@@ -0,0 +1,53 @@
|
|
1
|
+
require 'quick_exam/format'
|
2
|
+
require 'quick_exam/record'
|
3
|
+
require 'quick_exam/record_collection'
|
4
|
+
require 'quick_exam/analyst/common'
|
5
|
+
|
6
|
+
module QuickExam
|
7
|
+
module Analyst
|
8
|
+
class BaseText
|
9
|
+
include QuickExam::Format
|
10
|
+
attr_reader :records, :total_line
|
11
|
+
|
12
|
+
def initialize(file_path, f_ques:'' , f_corr:'')
|
13
|
+
raise ErrorAnalyze.new('No such file') unless File.exist? file_path
|
14
|
+
@file_path = file_path
|
15
|
+
@f_ques = f_ques
|
16
|
+
@f_corr = f_corr
|
17
|
+
@records = QuickExam::RecordCollection.new()
|
18
|
+
end
|
19
|
+
|
20
|
+
def analyze
|
21
|
+
data_standardize
|
22
|
+
self
|
23
|
+
rescue => e
|
24
|
+
raise ErrorAnalyze.new('Data can not analyze')
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
include QuickExam::Analyst::Common
|
30
|
+
|
31
|
+
def data_standardize
|
32
|
+
file = File.open(@file_path, 'r')
|
33
|
+
@total_line = File.foreach(file).count
|
34
|
+
@object = QuickExam::Record.new()
|
35
|
+
|
36
|
+
file.each_line.with_index do |row, idx|
|
37
|
+
idx += 1 # The first row is 1
|
38
|
+
|
39
|
+
if end_of_line?(idx) || end_of_one_ticket_for_next_question?(row)
|
40
|
+
get_answer(row) # if the last line is answer then will get answer
|
41
|
+
collect_object_ticket
|
42
|
+
end
|
43
|
+
|
44
|
+
next if row.__blank?
|
45
|
+
|
46
|
+
next if get_answer(row)
|
47
|
+
next if get_question(row)
|
48
|
+
end
|
49
|
+
records
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
module QuickExam
|
2
|
+
module Analyst
|
3
|
+
module Common
|
4
|
+
def get_question(str)
|
5
|
+
return if @object.answers.__present?
|
6
|
+
@object.question += question(str)
|
7
|
+
end
|
8
|
+
|
9
|
+
def get_answer(str)
|
10
|
+
return if @object.question.__blank?
|
11
|
+
return unless answer?(str)
|
12
|
+
|
13
|
+
# Get answer
|
14
|
+
@object.answers << answer(str)
|
15
|
+
get_correct_indexes_answer(str)
|
16
|
+
end
|
17
|
+
|
18
|
+
def get_correct_indexes_answer(str)
|
19
|
+
return unless correct_answer?(str)
|
20
|
+
@object.correct_indexes << @object.answers.size - 1
|
21
|
+
end
|
22
|
+
|
23
|
+
def end_of_one_ticket_for_next_question?(str)
|
24
|
+
str = Sanitize.fragment(str)
|
25
|
+
@object.answers.__present? && @object.question.__present? && question?(str)
|
26
|
+
end
|
27
|
+
|
28
|
+
def end_of_line?(num_row)
|
29
|
+
num_row == @total_line
|
30
|
+
end
|
31
|
+
|
32
|
+
def collect_object_ticket
|
33
|
+
@records << clean_object
|
34
|
+
reset_object_ticket
|
35
|
+
end
|
36
|
+
|
37
|
+
def clean_object
|
38
|
+
@object.question.strip!
|
39
|
+
@object.answers.map(&:strip!)
|
40
|
+
@object
|
41
|
+
end
|
42
|
+
|
43
|
+
def reset_object_ticket
|
44
|
+
@object = QuickExam::Record.new()
|
45
|
+
end
|
46
|
+
|
47
|
+
def correct_answer?(str)
|
48
|
+
str.downcase.include?(correct_mark(@f_corr).downcase)
|
49
|
+
end
|
50
|
+
|
51
|
+
def question?(str)
|
52
|
+
str = rid_non_ascii!(str)
|
53
|
+
str = Sanitize.fragment(str).__squish
|
54
|
+
str[(regex_question_mark)].__present?
|
55
|
+
end
|
56
|
+
|
57
|
+
def answer?(str)
|
58
|
+
str = rid_non_ascii!(str)
|
59
|
+
str = Sanitize.fragment(str).__squish
|
60
|
+
str[(regex_answer_mark)].__present?
|
61
|
+
end
|
62
|
+
|
63
|
+
# TODO: Regex get clean answer
|
64
|
+
# i: case insensitive
|
65
|
+
# x: ignore whitespace in regex
|
66
|
+
# ?= : positive lookahead
|
67
|
+
def answer(str)
|
68
|
+
corr_mark = correct_mark(@f_corr, safe: true)
|
69
|
+
ans_with_mark_correct = /(#{regex_answer_sentence}(?=#{corr_mark}))/
|
70
|
+
ans_without_mark_correct = regex_answer_sentence
|
71
|
+
str[(/#{ans_with_mark_correct}|#{regex_answer_sentence}/ix)].__presence || str
|
72
|
+
end
|
73
|
+
|
74
|
+
# TODO: Regex get clean question
|
75
|
+
# i: case insensitive
|
76
|
+
# m: make dot match newlines
|
77
|
+
# ?<= : positive lookbehind
|
78
|
+
def question(str)
|
79
|
+
letter_question = Regexp.quote(str.match(regex_question_mark).to_a.last.to_s)
|
80
|
+
str[(/(?<=#{letter_question}).+/im)].__presence || str
|
81
|
+
end
|
82
|
+
|
83
|
+
# TODO: Regex match question mark
|
84
|
+
# Format question: Q1: , q1. , q1) , Q1/
|
85
|
+
# @return: Question mark
|
86
|
+
#
|
87
|
+
# i: case insensitive
|
88
|
+
# m: make dot match newlines
|
89
|
+
# x: ignore whitespace in regex
|
90
|
+
def regex_question_mark
|
91
|
+
ques_mark = question_mark(@f_ques, safe: true)
|
92
|
+
/(^#{ques_mark}[\s]*\d+[:|\)|\.|\/])/ixm
|
93
|
+
end
|
94
|
+
|
95
|
+
# TODO: Regex match answer sentence
|
96
|
+
# Format question: A) , a. , 1/
|
97
|
+
# @return: Answer sentence without answer mark
|
98
|
+
#
|
99
|
+
# ?<= : positive lookbehind
|
100
|
+
def regex_answer_sentence
|
101
|
+
/(?<=#{regex_answer_mark}).*/
|
102
|
+
end
|
103
|
+
|
104
|
+
# TODO: Regex match answer mark
|
105
|
+
# Format question: A) , a. , 1/
|
106
|
+
# @return: Answer mark
|
107
|
+
def regex_answer_mark
|
108
|
+
/(^\w[\.|\)|\/])/
|
109
|
+
end
|
110
|
+
|
111
|
+
# TODO: Remove non-unicode character
|
112
|
+
# Solutions:
|
113
|
+
# Ref: https://stackoverflow.com/a/26411802/14126700
|
114
|
+
# Ref: https://www.regular-expressions.info/posixbrackets.html
|
115
|
+
# [:print:] : Visible characters and spaces (anything except control characters)
|
116
|
+
def rid_non_ascii!(str)
|
117
|
+
# Solution 1: str.chars.reject { |char| char.ascii_only? and (char.ord < 32 or char.ord == 127) }.join
|
118
|
+
non_utf8 = str.slice(str[/[^[:print:]]/].to_s)
|
119
|
+
return str if non_utf8 == "\n" || non_utf8 == "\t"
|
120
|
+
str.slice!(str[/[^[:print:]]/].to_s)
|
121
|
+
str
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'quick_exam/format'
|
2
|
+
require 'quick_exam/analyst/base_text'
|
3
|
+
require 'quick_exam/analyst/base_docx'
|
4
|
+
require 'quick_exam/analyst/base_html'
|
5
|
+
|
6
|
+
module QuickExam
|
7
|
+
class Analyzer
|
8
|
+
include QuickExam::Format
|
9
|
+
attr_reader :records, :total_line, :f_ques, :f_corr
|
10
|
+
|
11
|
+
def initialize(file_path, f_ques:'' , f_corr:'')
|
12
|
+
raise ErrorAnalyze.new('No such file') unless File.exist? file_path
|
13
|
+
@file_path = file_path
|
14
|
+
@f_ques = f_ques.__presence || QUESTION_MARK
|
15
|
+
@f_corr = f_corr.__presence || CORRECT_MARK
|
16
|
+
end
|
17
|
+
|
18
|
+
def analyze
|
19
|
+
case
|
20
|
+
when txt? then process_base_text
|
21
|
+
when docx? then process_base_docx
|
22
|
+
when html? then process_base_html
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def txt?
|
27
|
+
File.extname(@file_path) == '.txt'
|
28
|
+
end
|
29
|
+
|
30
|
+
def html?
|
31
|
+
File.extname(@file_path) == '.html'
|
32
|
+
end
|
33
|
+
|
34
|
+
def docx?
|
35
|
+
File.extname(@file_path) == '.docx'
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def process_base_text
|
41
|
+
text_analyzer = QuickExam::Analyst::BaseText.new(@file_path, f_ques: @f_ques, f_corr: @f_corr)
|
42
|
+
text_analyzer.analyze
|
43
|
+
@records = text_analyzer.records
|
44
|
+
@total_line = text_analyzer.total_line
|
45
|
+
self
|
46
|
+
end
|
47
|
+
|
48
|
+
def process_base_html
|
49
|
+
html_analyzer = QuickExam::Analyst::BaseHTML.new(@file_path, f_ques: @f_ques, f_corr: @f_corr)
|
50
|
+
html_analyzer.analyze
|
51
|
+
@records = html_analyzer.records
|
52
|
+
@total_line = html_analyzer.total_line
|
53
|
+
self
|
54
|
+
end
|
55
|
+
|
56
|
+
def process_base_docx
|
57
|
+
docx_analyzer = QuickExam::Analyst::BaseDocx.new(@file_path, f_ques: @f_ques, f_corr: @f_corr)
|
58
|
+
docx_analyzer.analyze
|
59
|
+
@records = docx_analyzer.records
|
60
|
+
@total_line = docx_analyzer.total_line
|
61
|
+
self
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
require 'thor'
|
2
|
+
require 'pry'
|
3
|
+
require 'quick_exam'
|
4
|
+
|
5
|
+
module QuickExam
|
6
|
+
class CLI < Thor
|
7
|
+
desc 'help export', 'Help'
|
8
|
+
def self.help(shell, subcommand = false)
|
9
|
+
list = printable_commands(true, subcommand)
|
10
|
+
Thor::Util.thor_classes_in(self).each do |klass|
|
11
|
+
list += klass.printable_commands(false)
|
12
|
+
end
|
13
|
+
|
14
|
+
# Remove this line to disable alphabetical sorting
|
15
|
+
list.sort! { |a, b| a[0] <=> b[0] }
|
16
|
+
|
17
|
+
# Add this line to remove the help-command itself from the output
|
18
|
+
# list.reject! {|l| l[0].split[1] == 'help'}
|
19
|
+
if defined?(@package_name) && @package_name
|
20
|
+
shell.say "#{@package_name} commands:"
|
21
|
+
else
|
22
|
+
shell.say "Commands:"
|
23
|
+
end
|
24
|
+
|
25
|
+
shell.print_table(list, :indent => 2, :truncate => true)
|
26
|
+
shell.say
|
27
|
+
class_options_help(shell)
|
28
|
+
|
29
|
+
# Add this line if you want to print custom text at the end of your help output.
|
30
|
+
# (similar to how Rails does it)
|
31
|
+
shell.say 'All commands can be run with -h (or --help) for more information.'
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.define_exec_options
|
35
|
+
option :shuffle_question, type: :string, aliases: ['-q'], default: 'true', desc: 'Shuffle the question', banner: 'true|false'
|
36
|
+
option :shuffle_answer, type: :string, aliases: ['-a'], default: 'false', desc: 'Shuffle the answer', banner: 'true|false'
|
37
|
+
option :same_answer, type: :string, aliases: ['-s'], default: 'false', desc: 'The same answer for all questionnaires', banner: 'false'
|
38
|
+
option :f_ques, type: :string, aliases: ['-Q'], default: 'Q', desc: 'Question format. Just prefix the question', banner: 'Q'
|
39
|
+
option :f_corr, type: :string, aliases: ['-C'], default: '!!!T', desc: 'Correct answer format', banner: '!!!T'
|
40
|
+
option :count, type: :string, aliases: ['-c'], default: '2', desc: 'Number of copies to created', banner: '2'
|
41
|
+
option :dest, type: :string, aliases: ['-d'], default: '', desc: 'File storage path after export', banner: '~/quick_exam_export/'
|
42
|
+
end
|
43
|
+
|
44
|
+
desc 'export FILE_PATH [options]', 'shuffle or randomize quiz questions and answers then export file'
|
45
|
+
define_exec_options
|
46
|
+
def export(file_path)
|
47
|
+
shuffle_question = options[:shuffle_question] == 'true' ? true : false
|
48
|
+
shuffle_answer = options[:shuffle_answer] == 'true' ? true : false
|
49
|
+
same_answer = options[:same_answer] == 'true' ? true : false
|
50
|
+
f_ques = options[:f_ques]
|
51
|
+
f_corr = options[:f_corr]
|
52
|
+
count = options[:count].to_i
|
53
|
+
dest = options[:dest]
|
54
|
+
|
55
|
+
paths_export = QuickExam::Export.run(
|
56
|
+
file_path,
|
57
|
+
shuffle_question: shuffle_question,
|
58
|
+
shuffle_answer: shuffle_answer,
|
59
|
+
count: count,
|
60
|
+
dest: dest,
|
61
|
+
f_ques: f_ques,
|
62
|
+
f_corr: f_corr,
|
63
|
+
same_answer: same_answer
|
64
|
+
)
|
65
|
+
show_msg_after_export(paths_export)
|
66
|
+
end
|
67
|
+
|
68
|
+
map %w[--version -v] => :__print_version
|
69
|
+
desc '--version, -v', 'Show `quick_exam` version'
|
70
|
+
def __print_version
|
71
|
+
year = Time.now.year.to_s == '2020' ? '2020' : "2020 - #{Time.now.year}"
|
72
|
+
puts "quick_exam #{QuickExam::VERSION} (latest) (c) #{year} Tang Quoc Minh [vhquocminhit@gmail.com]"
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def show_msg_after_export(paths_export)
|
78
|
+
puts '♥️ Congratulations on your successful export ♥️'
|
79
|
+
paths_export.each { |path| puts path }
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
class Object
|
2
|
+
def __blank?
|
3
|
+
case self
|
4
|
+
when [], '', "\n", nil, false
|
5
|
+
true
|
6
|
+
else
|
7
|
+
false
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def __present?
|
12
|
+
!__blank?
|
13
|
+
end
|
14
|
+
|
15
|
+
def __presence
|
16
|
+
self if __present?
|
17
|
+
end
|
18
|
+
|
19
|
+
def __squish
|
20
|
+
dup.__squish!
|
21
|
+
end
|
22
|
+
|
23
|
+
def __squish!
|
24
|
+
gsub!(/[[:space:]]+/, " ")
|
25
|
+
strip!
|
26
|
+
self
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
require 'quick_exam/format'
|
3
|
+
|
4
|
+
module QuickExam
|
5
|
+
class Export
|
6
|
+
class << self
|
7
|
+
include QuickExam::Format
|
8
|
+
RUN_ARGS = %w(shuffle_question shuffle_answer same_answer count dest f_ques f_corr)
|
9
|
+
|
10
|
+
def run(file_path, options={})
|
11
|
+
@file = File.new(file_path)
|
12
|
+
arg = validate_arguments!(options)
|
13
|
+
count = arg[:count].__presence || 2
|
14
|
+
|
15
|
+
check_path(arg[:dest], file_path)
|
16
|
+
proccess_analyze(file_path, arg)
|
17
|
+
@f_ques = question_mark(arg[:f_ques])
|
18
|
+
@records = mixes(count, arg)
|
19
|
+
process_export_files
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
attr_reader :records, :object_qna, :dest, :analyzer, :file
|
25
|
+
|
26
|
+
def proccess_analyze(file_path, arg)
|
27
|
+
@analyzer = QuickExam::Analyzer.new(file_path, f_ques: arg[:f_ques] , f_corr: arg[:f_corr])
|
28
|
+
analyzer.analyze
|
29
|
+
end
|
30
|
+
|
31
|
+
def mixes(count, arg)
|
32
|
+
analyzer.records.mixes(
|
33
|
+
count,
|
34
|
+
shuffle_question: arg[:shuffle_question],
|
35
|
+
shuffle_answer: arg[:shuffle_answer],
|
36
|
+
same_answer: arg[:same_answer]
|
37
|
+
)
|
38
|
+
end
|
39
|
+
|
40
|
+
def check_path(dest_path, file_path)
|
41
|
+
if dest_path.__blank?
|
42
|
+
@dest = File.dirname(file_path) + "/#{FOLDER_NAME_EXPORT}/"
|
43
|
+
else
|
44
|
+
@dest = dest_path + "/#{FOLDER_NAME_EXPORT}/"
|
45
|
+
end
|
46
|
+
|
47
|
+
return raise ErrorExport.new('No such file') unless File.exist?(file_path)
|
48
|
+
FileUtils.mkdir_p(dest) unless Dir.exist? dest
|
49
|
+
end
|
50
|
+
|
51
|
+
def process_export_files
|
52
|
+
path_dest_files = []
|
53
|
+
records.each_with_index do |object_qna, i|
|
54
|
+
@object_qna = object_qna
|
55
|
+
i = i + 1
|
56
|
+
file_path_question = path_filename(i)
|
57
|
+
file_path_answer = path_filename(i, extra_name: '_answers')
|
58
|
+
export_question_sheet(file_path_question)
|
59
|
+
export_answer_sheet(file_path_answer)
|
60
|
+
path_dest_files << file_path_question << file_path_answer
|
61
|
+
end
|
62
|
+
path_dest_files
|
63
|
+
end
|
64
|
+
|
65
|
+
def export_question_sheet(path_filename)
|
66
|
+
File.open(path_filename, 'w') do |f|
|
67
|
+
object_qna.each_with_index do |ticket, i|
|
68
|
+
f.write question(ticket.question, i + 1)
|
69
|
+
f.write answers(ticket.answers)
|
70
|
+
analyzer.html? ? f.write("<br />") : f.write("\n")
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def export_answer_sheet(path_filename)
|
76
|
+
File.open(path_filename, 'w') do |f|
|
77
|
+
object_qna.each_with_index do |ticket, i|
|
78
|
+
ans = ticket.correct_indexes.map { |ci| alphabets[ci] }.join(', ')
|
79
|
+
str = "#{@f_ques}#{i + 1}. #{ans}"
|
80
|
+
if analyzer.html?
|
81
|
+
f.write("<p>#{str}</p>")
|
82
|
+
else
|
83
|
+
f.write(str)
|
84
|
+
f.write("\n")
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def path_filename(index_order, extra_name: '')
|
91
|
+
basename = File.basename(file.path, '.*')
|
92
|
+
basename = ("#{basename}_%.3i" % index_order) + extra_name
|
93
|
+
extname = analyzer.docx? ? '.docx.txt' : File.extname(file.path)
|
94
|
+
"#{dest}#{basename}#{extname}"
|
95
|
+
end
|
96
|
+
|
97
|
+
def question(str, index)
|
98
|
+
str = "#{@f_ques}#{index}. #{str}"
|
99
|
+
analyzer.html? ? "<p>#{str}</p>" : "#{str}\n"
|
100
|
+
end
|
101
|
+
|
102
|
+
def answers(data)
|
103
|
+
str_answer = ''
|
104
|
+
data.each_with_index do |str, i|
|
105
|
+
str = "#{alphabets[i]}. #{str}"
|
106
|
+
str_answer += analyzer.html? ? "<p>#{str}</p>" : "#{str}\n"
|
107
|
+
end
|
108
|
+
str_answer
|
109
|
+
end
|
110
|
+
|
111
|
+
def alphabets
|
112
|
+
@alphabets ||= ('A'..'Z').to_a
|
113
|
+
end
|
114
|
+
|
115
|
+
def validate_arguments!(args)
|
116
|
+
invalid_arg = args.detect{ |arg, _| !RUN_ARGS.include?(arg.to_s) }
|
117
|
+
raise ArgumentError.new("unknow keyword #{invalid_arg[0]}") if invalid_arg.__present?
|
118
|
+
RUN_ARGS.each_with_object({}) do |arg, memo|
|
119
|
+
arg = arg.to_sym
|
120
|
+
memo[arg] = args[arg]
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|