diff --git a/controllers/reviews.go b/controllers/reviews.go
index a400b0f..88d8df4 100644
--- a/controllers/reviews.go
+++ b/controllers/reviews.go
@@ -8,6 +8,7 @@ import (
"strconv"
"strings"
"time"
+ "unicode/utf8"
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/sm-quizzes-plugin/graders"
@@ -421,6 +422,61 @@ type reviewContextView struct {
ReviewComment string
Reviewed bool
ReviewedAt string
+ // TextCut and Cut tell that the student's text, or another long text,
+ // was shortened to fit cabana's partial size cap (see fitEscaped).
+ TextCut bool
+ Cut bool
+}
+
+// Escaped-byte budgets of the partial's long texts. cabana refuses a
+// partial that renders to more than 64 KiB, and html/template turns one
+// character into up to 5 bytes, so each text is cut to its budget measured
+// as escaped (fitEscaped). Their sum leaves room for the markup and the
+// translated labels; a long but ordinary answer (max_length up to 20000)
+// fits whole, only pathological texts are cut.
+const (
+ budgetText = 32 << 10
+ budgetQuestionText = 4 << 10
+ budgetModelAnswer = 6 << 10
+ budgetRubric = 6 << 10
+ budgetFeedback = 4 << 10
+ budgetComment = 4 << 10
+ budgetError = 1 << 10
+ budgetShort = 512
+)
+
+// fitEscaped cuts s so that it takes at most budget bytes once escaped by
+// html/template, ending it with "…" when cut, and reports whether it cut.
+func fitEscaped(s string, budget int) (string, bool) {
+ total := 0
+ for _, r := range s {
+ total += escapedLen(r)
+ }
+ if total <= budget {
+ return s, false
+ }
+ used := 0
+ for i, r := range s {
+ used += escapedLen(r)
+ if used > budget-len("…") {
+ return s[:i] + "…", true
+ }
+ }
+ return s, false
+}
+
+// escapedLen is the most bytes html/template writes for r in text.
+func escapedLen(r rune) int {
+ switch r {
+ case '"', '&', '\'', '+', '<', '>':
+ return 5
+ case 0:
+ return len("\uFFFD")
+ }
+ if n := utf8.RuneLen(r); n > 0 {
+ return n
+ }
+ return 3
}
// PartialData builds the review context of an answer from the review
@@ -445,7 +501,8 @@ func (reviewsAdmin) PartialData(ctx context.Context, name string, record any) (a
}
// newReviewContextView copies a review detail into the partial's view
-// model.
+// model, each long text cut to its escaped-byte budget so the partial
+// always fits cabana's size cap.
func newReviewContextView(item *classes.ReviewDetail, reg *graders.Registry) reviewContextView {
v := reviewContextView{
QuizTitle: item.QuizTitle,
@@ -473,6 +530,25 @@ func newReviewContextView(item *classes.ReviewDetail, reg *graders.Registry) rev
v.HasGraderPoints, v.GraderPoints = true, *item.GraderPoints
}
v.GraderLabel = graderLabel(reg, v.Grader)
+ v.Text, v.TextCut = fitEscaped(v.Text, budgetText)
+ for _, f := range []struct {
+ s *string
+ budget int
+ }{
+ {&v.QuizTitle, budgetShort},
+ {&v.StudentEmail, budgetShort},
+ {&v.StudentName, budgetShort},
+ {&v.QuestionText, budgetQuestionText},
+ {&v.ModelAnswer, budgetModelAnswer},
+ {&v.Rubric, budgetRubric},
+ {&v.GraderFeedback, budgetFeedback},
+ {&v.GradingError, budgetError},
+ {&v.ReviewComment, budgetComment},
+ } {
+ var cut bool
+ *f.s, cut = fitEscaped(*f.s, f.budget)
+ v.Cut = v.Cut || cut
+ }
return v
}
diff --git a/controllers/reviews/_review_context.htm b/controllers/reviews/_review_context.htm
index 8cd13c2..00d4e9d 100644
--- a/controllers/reviews/_review_context.htm
+++ b/controllers/reviews/_review_context.htm
@@ -1,5 +1,8 @@
{{with .Data}}
+{{if .Cut}}
+
{{trans "golem15.quizzes::lang.reviews.cut"}}
+{{end}}
- {{trans "golem15.quizzes::lang.reviews.quiz"}}
- {{.QuizTitle}}
@@ -16,7 +19,7 @@
{{.Rubric}}
{{end}}
- {{trans "golem15.quizzes::lang.reviews.text"}} {{trans "golem15.quizzes::lang.reviews.max_length"}}: {{.MaxLength}}
- {{.Text}}
+ {{.Text}}
{{if .TextCut}}{{trans "golem15.quizzes::lang.reviews.text_cut"}}
{{end}}
- {{trans "golem15.quizzes::lang.reviews.status"}}
- {{trans .StatusLabel}}
{{if .Grader}}
diff --git a/controllers/reviews_test.go b/controllers/reviews_test.go
index d0fc518..e00e9c2 100644
--- a/controllers/reviews_test.go
+++ b/controllers/reviews_test.go
@@ -209,3 +209,58 @@ func TestReviewContextEscapes(t *testing.T) {
t.Errorf("student text not escaped: %s", html)
}
}
+
+func TestFitEscaped(t *testing.T) {
+ if s, cut := fitEscaped("zażółć", 12); s != "zażółć" || cut {
+ t.Errorf("a text within budget = %q, %v", s, cut)
+ }
+ s, cut := fitEscaped(strings.Repeat(`"`, 10), 23)
+ if !cut || s != `""""`+"…" {
+ t.Errorf("quotes cut = %q, %v; want four quotes and an ellipsis", s, cut)
+ }
+ if s, cut := fitEscaped("abc", 0); !cut || s != "…" {
+ t.Errorf("zero budget = %q, %v", s, cut)
+ }
+}
+
+// TestReviewContextFitsPartialCap renders the partial with every text at
+// its worst case (characters html/template turns into 5 bytes) and checks
+// the output stays under cabana's 64 KiB partial cap.
+func TestReviewContextFitsPartialCap(t *testing.T) {
+ src, err := os.ReadFile("reviews/_review_context.htm")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // A translation longer than any real label.
+ long := strings.Repeat("ą", 100)
+ tpl, err := template.New("review_context").Funcs(template.FuncMap{"trans": func(string) string { return long }}).Parse(string(src))
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ worst := func(n int) *string { s := strings.Repeat(`"&'`, n); return &s }
+ grader := "ai"
+ gp := 1.5
+ v := newReviewContextView(&classes.ReviewDetail{
+ ReviewRow: classes.ReviewRow{
+ Status: "reviewed", QuizTitle: *worst(300), Email: *worst(300), Name: worst(300), Surname: worst(300),
+ QuestionText: *worst(10000), Text: *worst(20000), GraderPoints: &gp,
+ },
+ AnswerText: worst(10000), Rubric: worst(10000), Grader: &grader,
+ GraderFeedback: worst(2000), GradingError: worst(500), ReviewComment: worst(2000),
+ }, nil)
+ if !v.TextCut || !v.Cut {
+ t.Fatalf("worst-case texts not cut: TextCut=%v Cut=%v", v.TextCut, v.Cut)
+ }
+ var out bytes.Buffer
+ if err := tpl.Execute(&out, map[string]any{"Data": v}); err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ if n := out.Len(); n > 64<<10 {
+ t.Errorf("partial renders to %d bytes, over the 64 KiB cap", n)
+ }
+
+ plain := strings.Repeat("Długa, zwykła odpowiedź studenta. ", 580)
+ if v := newReviewContextView(&classes.ReviewDetail{ReviewRow: classes.ReviewRow{Text: plain}}, nil); v.TextCut || v.Text != plain {
+ t.Error("an ordinary answer near 20000 characters was cut")
+ }
+}
diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml
index f03319a..599265a 100644
--- a/lang/en/lang.yaml
+++ b/lang/en/lang.yaml
@@ -129,5 +129,7 @@ reviews:
already_reviewed: This answer is already verified.
grader_manual: This quiz is graded manually; nothing can grade the answer again.
attempt_open: The test is still in progress; review this answer once it ends.
+ text_cut: The answer is too long to show here in full; the quizzes_review_show tool shows all of it.
+ cut: Some long texts are shortened on this screen; the quizzes_review_show tool shows them in full.
not_found: This answer was not found.
not_created: Open answers come from students; they cannot be added or deleted here.
diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml
index a467153..c56695f 100644
--- a/lang/pl/lang.yaml
+++ b/lang/pl/lang.yaml
@@ -129,5 +129,7 @@ reviews:
already_reviewed: Ta odpowiedź jest już zweryfikowana.
grader_manual: Ten quiz jest oceniany ręcznie; nie ma kto ocenić odpowiedzi ponownie.
attempt_open: Test wciąż trwa; oceń tę odpowiedź po jego zakończeniu.
+ text_cut: Odpowiedź jest zbyt długa, by pokazać ją tu w całości; całą pokazuje narzędzie quizzes_review_show.
+ cut: Część długich tekstów jest tu skrócona; w całości pokazuje je narzędzie quizzes_review_show.
not_found: Nie znaleziono tej odpowiedzi.
not_created: Odpowiedzi otwarte pochodzą od studentów; tutaj nie można ich dodawać ani usuwać.