mirror of
https://github.com/alexmickelson/canvasManagement.git
synced 2026-03-26 23:58:31 -06:00
testing markdown storage and retrieval
This commit is contained in:
158
Management/Models/Local/Quiz/LocalQuiz.cs
Normal file
158
Management/Models/Local/Quiz/LocalQuiz.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace LocalModels;
|
||||
|
||||
public record LocalQuiz
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public DateTime? LockAt { get; init; }
|
||||
public DateTime DueAt { get; init; }
|
||||
public bool ShuffleAnswers { get; init; } = true;
|
||||
public bool OneQuestionAtATime { get; init; } = false;
|
||||
public string? LocalAssignmentGroupName { get; init; }
|
||||
public int AllowedAttempts { get; init; } = -1; // -1 is infinite
|
||||
// public bool ShowCorrectAnswers { get; init; }
|
||||
// public int? TimeLimit { get; init; } = null;
|
||||
// public string? HideResults { get; init; } = null;
|
||||
// If null, students can see their results after any attempt.
|
||||
// If “always”, students can never see their results.
|
||||
// If “until_after_last_attempt”, students can only see results after their last attempt. (Only valid if allowed_attempts > 1). Defaults to null.
|
||||
public IEnumerable<LocalQuizQuestion> Questions { get; init; } =
|
||||
Enumerable.Empty<LocalQuizQuestion>();
|
||||
public ulong? GetCanvasAssignmentGroupId(IEnumerable<LocalAssignmentGroup> assignmentGroups) =>
|
||||
assignmentGroups
|
||||
.FirstOrDefault(g => g.Name == LocalAssignmentGroupName)?
|
||||
.CanvasId;
|
||||
|
||||
public string GetDescriptionHtml() => Markdig.Markdown.ToHtml(Description);
|
||||
|
||||
public string ToYaml()
|
||||
{
|
||||
var serializer = new SerializerBuilder().DisableAliases().Build();
|
||||
var yaml = serializer.Serialize(this);
|
||||
return yaml;
|
||||
}
|
||||
|
||||
public string ToMarkdown()
|
||||
{
|
||||
var questionMarkdownArray = Questions.Select(q => q.ToMarkdown()).ToArray();
|
||||
var questionDelimiter = Environment.NewLine + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine;
|
||||
var questionMarkdown = string.Join(questionDelimiter, questionMarkdownArray);
|
||||
|
||||
return $@"Name: {Name}
|
||||
LockAt: {LockAt}
|
||||
DueAt: {DueAt}
|
||||
ShuffleAnswers: {ShuffleAnswers.ToString().ToLower()}
|
||||
OneQuestionAtATime: {OneQuestionAtATime.ToString().ToLower()}
|
||||
AssignmentGroup: {LocalAssignmentGroupName}
|
||||
AllowedAttempts: {AllowedAttempts}
|
||||
Description: {Description}
|
||||
---
|
||||
{questionMarkdown}
|
||||
";
|
||||
}
|
||||
|
||||
public static LocalQuiz ParseMarkdown(string input)
|
||||
{
|
||||
|
||||
var splitInput = input.Split("---" + Environment.NewLine);
|
||||
var settings = splitInput[0];
|
||||
var quizWithoutQuestions = getQuizWithOnlySettings(settings);
|
||||
|
||||
var questions = splitInput[1..]
|
||||
.Where(str => !string.IsNullOrWhiteSpace(str))
|
||||
.Select((q, i) => LocalQuizQuestion.ParseMarkdown(q, i))
|
||||
.ToArray();
|
||||
return quizWithoutQuestions with
|
||||
{
|
||||
Questions = questions
|
||||
};
|
||||
}
|
||||
|
||||
private static LocalQuiz getQuizWithOnlySettings(string settings)
|
||||
{
|
||||
|
||||
var name = extractLabelValue(settings, "Name");
|
||||
|
||||
var rawShuffleAnswers = extractLabelValue(settings, "ShuffleAnswers");
|
||||
var shuffleAnswers = bool.TryParse(rawShuffleAnswers, out bool parsedShuffleAnswers)
|
||||
? parsedShuffleAnswers
|
||||
: throw new QuizMarkdownParseException($"Error with ShuffleAnswers: {rawShuffleAnswers}");
|
||||
|
||||
|
||||
var rawOneQuestionAtATime = extractLabelValue(settings, "OneQuestionAtATime");
|
||||
var oneQuestionAtATime = bool.TryParse(rawOneQuestionAtATime, out bool parsedOneQuestion)
|
||||
? parsedOneQuestion
|
||||
: throw new QuizMarkdownParseException($"Error with oneQuestionAtATime: {rawOneQuestionAtATime}");
|
||||
|
||||
var rawAllowedAttempts = extractLabelValue(settings, "AllowedAttempts");
|
||||
var allowedAttempts = int.TryParse(rawAllowedAttempts, out int parsedAllowedAttempts)
|
||||
? parsedAllowedAttempts
|
||||
: throw new QuizMarkdownParseException($"Error with AllowedAttempts: {rawAllowedAttempts}");
|
||||
|
||||
|
||||
var rawDueAt = extractLabelValue(settings, "DueAt");
|
||||
var dueAt = DateTime.TryParse(rawDueAt, out DateTime parsedDueAt)
|
||||
? parsedDueAt
|
||||
: throw new QuizMarkdownParseException($"Error with DueAt: {rawDueAt}");
|
||||
|
||||
|
||||
var rawLockAt = extractLabelValue(settings, "LockAt");
|
||||
DateTime? lockAt = DateTime.TryParse(rawLockAt, out DateTime parsedLockAt)
|
||||
? parsedLockAt
|
||||
: null;
|
||||
|
||||
|
||||
var description = extractDescription(settings);
|
||||
var assignmentGroup = extractLabelValue(settings, "AssignmentGroup");
|
||||
|
||||
return new LocalQuiz()
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
LockAt = lockAt,
|
||||
DueAt = dueAt,
|
||||
ShuffleAnswers = shuffleAnswers,
|
||||
OneQuestionAtATime = oneQuestionAtATime,
|
||||
LocalAssignmentGroupName = assignmentGroup,
|
||||
AllowedAttempts = allowedAttempts,
|
||||
Questions = new LocalQuizQuestion[] { }
|
||||
};
|
||||
}
|
||||
|
||||
static string extractLabelValue(string input, string label)
|
||||
{
|
||||
string pattern = $@"{label}: (.*?)\n";
|
||||
Match match = Regex.Match(input, pattern);
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
return match.Groups[1].Value;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
static string extractDescription(string input)
|
||||
{
|
||||
string pattern = "Description: (.*?)$";
|
||||
Match match = Regex.Match(input, pattern, RegexOptions.Singleline);
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
return match.Groups[1].Value;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public class QuizMarkdownParseException : Exception
|
||||
{
|
||||
public QuizMarkdownParseException(string message): base(message)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
186
Management/Models/Local/Quiz/LocalQuizQuestion.cs
Normal file
186
Management/Models/Local/Quiz/LocalQuizQuestion.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace LocalModels;
|
||||
|
||||
public record LocalQuizQuestion
|
||||
{
|
||||
public string Text { get; init; } = string.Empty;
|
||||
public string HtmlText => Markdig.Markdown.ToHtml(Text);
|
||||
public string QuestionType { get; init; } = string.Empty;
|
||||
public int Points { get; init; }
|
||||
public IEnumerable<LocalQuizQuestionAnswer> Answers { get; init; } =
|
||||
Enumerable.Empty<LocalQuizQuestionAnswer>();
|
||||
public string ToMarkdown()
|
||||
{
|
||||
var answerArray = Answers.Select((answer, i) =>
|
||||
{
|
||||
var questionLetter = (char)(i + 97);
|
||||
var isMultipleChoice = QuestionType == "multiple_choice";
|
||||
|
||||
var correctIndicator = answer.Correct ? "*" : isMultipleChoice ? "" : " ";
|
||||
|
||||
|
||||
var questionTypeIndicator = isMultipleChoice
|
||||
? $"{correctIndicator}{questionLetter}) "
|
||||
: $"[{correctIndicator}] ";
|
||||
|
||||
// var textWithSpecificNewline = answer.Text.Replace(Environment.NewLine, Environment.NewLine + " ");
|
||||
|
||||
var multilineMarkdownCompatibleText = answer.Text.StartsWith("```")
|
||||
? Environment.NewLine + answer.Text
|
||||
: answer.Text;
|
||||
return $"{questionTypeIndicator}{multilineMarkdownCompatibleText}";
|
||||
});
|
||||
var answersText = string.Join(Environment.NewLine, answerArray);
|
||||
var questionTypeIndicator = QuestionType == "essay" || QuestionType == "short_answer" ? QuestionType : "";
|
||||
|
||||
return $@"Points: {Points}
|
||||
{Text}
|
||||
{answersText}{questionTypeIndicator}";
|
||||
}
|
||||
|
||||
private static readonly string[] validFirstAnswerDelimiters = new string[] { "*a)", "a)", "[ ]", "[*]" };
|
||||
|
||||
public static LocalQuizQuestion ParseMarkdown(string input, int questionIndex)
|
||||
{
|
||||
var lines = input.Trim().Split(Environment.NewLine);
|
||||
var firstLineIsPoints = lines.First().Contains("points: ", StringComparison.CurrentCultureIgnoreCase);
|
||||
|
||||
var textHasPoints = lines.Length > 0
|
||||
&& lines.First().Contains(": ")
|
||||
&& lines.First().Split(": ").Length > 1
|
||||
&& int.TryParse(lines.First().Split(": ")[1], out _);
|
||||
|
||||
int points = firstLineIsPoints && textHasPoints ? int.Parse(lines.First().Split(": ")[1]) : 1;
|
||||
|
||||
var linesWithoutPoints = firstLineIsPoints ? lines[1..] : lines;
|
||||
|
||||
var linesWithoutAnswers = linesWithoutPoints
|
||||
.TakeWhile(
|
||||
(line, index) =>
|
||||
!validFirstAnswerDelimiters.Any(prefix => line.TrimStart().StartsWith(prefix))
|
||||
)
|
||||
.ToArray();
|
||||
|
||||
|
||||
var questionType = getQuestionType(linesWithoutPoints, questionIndex);
|
||||
|
||||
var questionTypesWithoutAnswers = new string[] { "essay", "short answer", "short_answer" };
|
||||
var descriptionLines = questionTypesWithoutAnswers.Contains(questionType.ToLower())
|
||||
? linesWithoutAnswers
|
||||
.TakeWhile(
|
||||
(line, index) => index != linesWithoutPoints.Length && !questionTypesWithoutAnswers.Contains(line.ToLower())
|
||||
)
|
||||
.ToArray()
|
||||
: linesWithoutAnswers;
|
||||
var description = string.Join(Environment.NewLine, descriptionLines);
|
||||
|
||||
|
||||
|
||||
var typesWithAnswers = new string[] { "multiple_choice", "multiple_answers" };
|
||||
var answers = typesWithAnswers.Contains(questionType)
|
||||
? getAnswers(linesWithoutPoints, questionIndex)
|
||||
: [];
|
||||
|
||||
return new LocalQuizQuestion()
|
||||
{
|
||||
Text = description,
|
||||
Points = points,
|
||||
Answers = answers,
|
||||
QuestionType = questionType
|
||||
};
|
||||
}
|
||||
|
||||
private static string getQuestionType(string[] linesWithoutPoints, int questionIndex)
|
||||
{
|
||||
|
||||
if (linesWithoutPoints.Length == 0)
|
||||
return "";
|
||||
if (linesWithoutPoints[^1].Equals("essay", StringComparison.CurrentCultureIgnoreCase))
|
||||
return "essay";
|
||||
if (linesWithoutPoints[^1].Equals("short answer", StringComparison.CurrentCultureIgnoreCase))
|
||||
return "short_answer";
|
||||
if (linesWithoutPoints[^1].Equals("short_answer", StringComparison.CurrentCultureIgnoreCase))
|
||||
return "short_answer";
|
||||
|
||||
var answerLines = getAnswersGroupedByLines(linesWithoutPoints, questionIndex);
|
||||
var isMultipleChoice =
|
||||
answerLines.First().StartsWith("a)")
|
||||
|| answerLines.First().StartsWith("*a)");
|
||||
if (isMultipleChoice)
|
||||
return "multiple_choice";
|
||||
|
||||
var isMultipleAnswer =
|
||||
answerLines.First().StartsWith("[ ]")
|
||||
|| answerLines.First().StartsWith("[*]");
|
||||
|
||||
if (isMultipleAnswer)
|
||||
return "multiple_answers";
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static List<string> getAnswersGroupedByLines(string[] linesWithoutPoints, int questionIndex)
|
||||
{
|
||||
var indexOfAnswerStart = linesWithoutPoints
|
||||
.ToList()
|
||||
.FindIndex(
|
||||
l => validFirstAnswerDelimiters.Any(prefix => l.TrimStart().StartsWith(prefix))
|
||||
);
|
||||
if (indexOfAnswerStart == -1)
|
||||
{
|
||||
var debugLine = linesWithoutPoints.FirstOrDefault(l => l.Trim().Length > 0);
|
||||
throw new QuizMarkdownParseException($"question {questionIndex + 1}: no answers when detecting question type on {debugLine}");
|
||||
}
|
||||
|
||||
var answerLinesRaw = linesWithoutPoints[indexOfAnswerStart..];
|
||||
|
||||
var answerStartPattern = @"^(\*?[a-z]\))|\[\s*\]|\[\*\]";
|
||||
var answerLines = answerLinesRaw.Aggregate(new List<string>(), (acc, line) =>
|
||||
{
|
||||
var isNewAnswer = Regex.IsMatch(line, answerStartPattern);
|
||||
if (isNewAnswer)
|
||||
{
|
||||
acc.Add(line);
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (acc.Count != 0) // Append to the previous line if there is one
|
||||
acc[^1] += Environment.NewLine + line;
|
||||
else
|
||||
acc.Add(line);
|
||||
|
||||
return acc;
|
||||
});
|
||||
return answerLines;
|
||||
}
|
||||
|
||||
private static LocalQuizQuestionAnswer[] getAnswers(string[] linesWithoutPoints, int questionIndex)
|
||||
{
|
||||
var answerLines = getAnswersGroupedByLines(linesWithoutPoints, questionIndex);
|
||||
|
||||
var answers = answerLines
|
||||
.Select((a, i) => LocalQuizQuestionAnswer.ParseMarkdown(a))
|
||||
.ToArray();
|
||||
|
||||
return answers;
|
||||
}
|
||||
}
|
||||
|
||||
public static class QuestionType
|
||||
{
|
||||
public static readonly string MULTIPLE_ANSWERS = "multiple_answers";
|
||||
public static readonly string MULTIPLE_CHOICE = "multiple_choice";
|
||||
public static readonly string ESSAY = "essay";
|
||||
public static readonly string SHORT_ANSWER = "short_answer";
|
||||
|
||||
// possible support for: calculated, file_upload, fill_in_multiple_blanks, matching, multiple_dropdowns, numerical, text_only, true_false,
|
||||
public static readonly IEnumerable<string> AllTypes = new string[]
|
||||
{
|
||||
MULTIPLE_ANSWERS,
|
||||
MULTIPLE_CHOICE,
|
||||
ESSAY,
|
||||
SHORT_ANSWER,
|
||||
};
|
||||
}
|
||||
25
Management/Models/Local/Quiz/LocalQuizQuestionAnswer.cs
Normal file
25
Management/Models/Local/Quiz/LocalQuizQuestionAnswer.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace LocalModels;
|
||||
|
||||
public record LocalQuizQuestionAnswer
|
||||
{
|
||||
//correct gets a weight of 100 in canvas
|
||||
public bool Correct { get; init; }
|
||||
public string Text { get; init; } = string.Empty;
|
||||
|
||||
public string HtmlText => Markdig.Markdown.ToHtml(Text);
|
||||
|
||||
public static LocalQuizQuestionAnswer ParseMarkdown(string input)
|
||||
{
|
||||
var isCorrect = input[0] == '*' || input[1] == '*';
|
||||
string startingQuestionPattern = @"^(\*?[a-z]\))|\[\s*\]|\[\*\] ";
|
||||
var text = Regex.Replace(input, startingQuestionPattern, string.Empty).Trim();
|
||||
|
||||
return new LocalQuizQuestionAnswer()
|
||||
{
|
||||
Correct = isCorrect,
|
||||
Text = text,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user