public function save($req, $contact)
 {
     global $Conf, $Now;
     if (is_array($req)) {
         $req = (object) $req;
     }
     $Table = $this->prow->comment_table_name();
     $LinkTable = $this->prow->table_name();
     $LinkColumn = $this->prow->id_column();
     $req_visibility = get($req, "visibility");
     $is_response = !!($this->commentType & COMMENTTYPE_RESPONSE);
     if ($is_response && get($req, "submit")) {
         $ctype = COMMENTTYPE_RESPONSE | COMMENTTYPE_AUTHOR;
     } else {
         if ($is_response) {
             $ctype = COMMENTTYPE_RESPONSE | COMMENTTYPE_AUTHOR | COMMENTTYPE_DRAFT;
         } else {
             if ($req_visibility == "a" || $req_visibility == "au") {
                 $ctype = COMMENTTYPE_AUTHOR;
             } else {
                 if ($req_visibility == "p" || $req_visibility == "pc") {
                     $ctype = COMMENTTYPE_PCONLY;
                 } else {
                     if ($req_visibility == "admin") {
                         $ctype = COMMENTTYPE_ADMINONLY;
                     } else {
                         if ($this->commentId && $req_visibility === null) {
                             $ctype = $this->commentType;
                         } else {
                             // $req->visibility == "r" || $req->visibility == "rev"
                             $ctype = COMMENTTYPE_REVIEWER;
                         }
                     }
                 }
             }
         }
     }
     if ($is_response ? $this->prow->blind : $Conf->is_review_blind(!!get($req, "blind"))) {
         $ctype |= COMMENTTYPE_BLIND;
     }
     // tags
     if ($is_response) {
         $ctags = " response ";
         if (($rname = $Conf->resp_round_name($this->commentRound)) != "1") {
             $ctags .= "{$rname}response ";
         }
     } else {
         if (get($req, "tags") && preg_match_all(',\\S+,', $req->tags, $m)) {
             $tagger = new Tagger($contact);
             $ctags = array();
             foreach ($m[0] as $text) {
                 if (($text = $tagger->check($text, Tagger::NOVALUE)) && !stri_ends_with($text, "response")) {
                     $ctags[strtolower($text)] = $text;
                 }
             }
             $tagger->sort($ctags);
             $ctags = count($ctags) ? " " . join(" ", $ctags) . " " : null;
         } else {
             $ctags = null;
         }
     }
     // notifications
     $displayed = !($ctype & COMMENTTYPE_DRAFT);
     // query
     $text = get_s($req, "text");
     $q = "";
     $qv = array();
     if ($text === "" && $this->commentId) {
         $change = true;
         $q = "delete from {$Table} where commentId={$this->commentId}";
     } else {
         if ($text === "") {
             /* do nothing */
         } else {
             if (!$this->commentId) {
                 $change = true;
                 $qa = ["contactId, {$LinkColumn}, commentType, comment, commentOverflow, timeModified, replyTo"];
                 $qb = [$contact->contactId, $this->prow->{$LinkColumn}, $ctype, "?", "?", $Now, 0];
                 if (strlen($text) <= 32000) {
                     array_push($qv, $text, null);
                 } else {
                     array_push($qv, UnicodeHelper::utf8_prefix($text, 200), $text);
                 }
                 if ($ctags !== null) {
                     $qa[] = "commentTags";
                     $qb[] = "?";
                     $qv[] = $ctags;
                 }
                 if ($is_response) {
                     $qa[] = "commentRound";
                     $qb[] = $this->commentRound;
                 }
                 if ($displayed) {
                     $qa[] = "timeDisplayed, timeNotified";
                     $qb[] = "{$Now}, {$Now}";
                 }
                 $q = "insert into {$Table} (" . join(", ", $qa) . ") select " . join(", ", $qb) . "\n";
                 if ($is_response) {
                     // make sure there is exactly one response
                     $q .= " from (select {$LinkTable}.{$LinkColumn}, coalesce(commentId, 0) commentId\n                from {$LinkTable}\n                left join {$Table} on ({$Table}.{$LinkColumn}={$LinkTable}.{$LinkColumn} and (commentType&" . COMMENTTYPE_RESPONSE . ")!=0 and commentRound={$this->commentRound})\n                where {$LinkTable}.{$LinkColumn}={$this->prow->{$LinkColumn}} limit 1) t\n        where t.commentId=0";
                 }
             } else {
                 $change = $this->commentType >= COMMENTTYPE_AUTHOR != $ctype >= COMMENTTYPE_AUTHOR;
                 if ($this->timeModified >= $Now) {
                     $Now = $this->timeModified + 1;
                 }
                 // do not notify on updates within 3 hours
                 $qa = "";
                 if ($this->timeNotified + 10800 < $Now || $ctype & COMMENTTYPE_RESPONSE && !($ctype & COMMENTTYPE_DRAFT) && $this->commentType & COMMENTTYPE_DRAFT) {
                     $qa .= ", timeNotified={$Now}";
                 }
                 // reset timeDisplayed if you change the comment type
                 if ((!$this->timeDisplayed || $this->ordinal_missing($ctype)) && $text !== "" && $displayed) {
                     $qa .= ", timeDisplayed={$Now}";
                 }
                 $q = "update {$Table} set timeModified={$Now}{$qa}, commentType={$ctype}, comment=?, commentOverflow=?, commentTags=? where commentId={$this->commentId}";
                 if (strlen($text) <= 32000) {
                     array_push($qv, $text, null);
                 } else {
                     array_push($qv, UnicodeHelper::utf8_prefix($text, 200), $text);
                 }
                 $qv[] = $ctags;
             }
         }
     }
     $result = Dbl::qe_apply($q, $qv);
     if (!$result) {
         return false;
     }
     $cmtid = $this->commentId ?: $result->insert_id;
     if (!$cmtid) {
         return false;
     }
     // log
     $contact->log_activity("Comment {$cmtid} " . ($text !== "" ? "saved" : "deleted"), $this->prow->{$LinkColumn});
     // ordinal
     if ($text !== "" && $this->ordinal_missing($ctype)) {
         $this->save_ordinal($cmtid, $ctype, $Table, $LinkTable, $LinkColumn);
     }
     // reload
     if ($text !== "") {
         $comments = $this->prow->fetch_comments("commentId={$cmtid}");
         $this->merge($comments[$cmtid], $this->prow);
         if ($this->timeNotified == $this->timeModified) {
             self::$watching = $this;
             $this->prow->notify(WATCHTYPE_COMMENT, "CommentInfo::watch_callback", $contact);
             self::$watching = null;
         }
     } else {
         $this->commentId = 0;
         $this->comment = "";
         $this->commentTags = null;
     }
     return true;
 }
 static function resp_round_name_error($rname)
 {
     if ((string) $rname === "") {
         return "Empty round name.";
     } else {
         if (!strcasecmp($rname, "none") || !strcasecmp($rname, "any") || stri_ends_with($rname, "response")) {
             return "Round name “{$rname}” is reserved.";
         } else {
             if (!preg_match('/^[a-zA-Z][a-zA-Z0-9]*$/', $rname)) {
                 return "Round names must start with a letter and contain letters and numbers.";
             } else {
                 return false;
             }
         }
     }
 }