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.
@@ -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