Backs Parth's changes back out, adds support for sorting Wiki pages in page list UI, a=chris

Chris Pollett [2022-07-12 18:Jul:th]
Backs Parth's changes back out, adds support for sorting Wiki pages in page list UI, a=chris
Filename
src/configs/Config.php
src/controllers/components/SocialComponent.php
src/css/search.css
src/library/VersionFunctions.php
src/library/media_jobs/RecommendationJob.php
src/models/GroupModel.php
src/models/ProfileModel.php
src/views/elements/WikiElement.php
diff --git a/src/configs/Config.php b/src/configs/Config.php
index f02b2f5d9..49979cfad 100755
--- a/src/configs/Config.php
+++ b/src/configs/Config.php
@@ -1225,5 +1225,3 @@ nsconddefine('SENTENCE_COMPRESSION_ENABLED', false);
 nsconddefine('NUM_LEX_BULK_INSERTS',100000);
 /** Length of advertisement credits service account id string*/
 nsconddefine('AD_CREDITS_SERVICE_ACCOUNT_LEN', 32);
-/** Type used to indicate recommendation scheme */
-nsdefine('RECOMMENDATION_TYPE', 1);
\ No newline at end of file
diff --git a/src/controllers/components/SocialComponent.php b/src/controllers/components/SocialComponent.php
index 6f5e06086..23099bd43 100644
--- a/src/controllers/components/SocialComponent.php
+++ b/src/controllers/components/SocialComponent.php
@@ -3201,13 +3201,27 @@ class SocialComponent extends Component implements CrawlConstants
                         $_SESSION["MAX_PAGES_TO_SHOW"] > 0) ?
                        $_SESSION["MAX_PAGES_TO_SHOW"] :
                        C\DEFAULT_ADMIN_PAGING_NUM;
+                    array_pop($data['sort_fields']);
+                    array_pop($data['sort_fields']);
+                    if (!empty($_REQUEST['sort']) && in_array($_REQUEST['sort'],
+                        array_keys($data['sort_fields']))) {
+                        if (!empty($_SESSION['media_sorts']) &&
+                            count($_SESSION['media_sorts']) > 10) {
+                            $first_key = array_key_first(
+                                $_SESSION['media_sorts']);
+                            unset($_SESSION['media_sorts'][$first_key]);
+                        }
+                        $_SESSION['media_sorts']['pages'] = $_REQUEST['sort'];
+                    }
+                    $data['CURRENT_SORT'] = $_SESSION['media_sorts']["pages"]
+                        ?? "";
                     $filter = (empty($filter)) ? "" : $filter;
                     if (isset($page_name)) {
                         $data['PAGE_NAME'] = $page_name;
                     }
                     $data["LIMIT"] = $limit;
                     $data["RESULTS_PER_PAGE"] = $num;
-                    $data["FILTER"] = preg_replace("/\s+/u", " ", $filter);;
+                    $data["FILTER"] = preg_replace("/\s+/u", " ", $filter);
                     $filter = preg_replace("/\s+/u", "_", $filter);
                     $search_page_info = false;
                     if ($filter != "") {
@@ -3219,7 +3233,7 @@ class SocialComponent extends Component implements CrawlConstants
                         list($data["TOTAL_ROWS"], $data["PAGES"]) =
                             $group_model->getPageList(
                             $group_id, $data['CURRENT_LOCALE_TAG'], $filter,
-                            $limit, $num);
+                            $data['CURRENT_SORT'], $limit, $num);
                     } else {
                         $data["MODE"] = "read";
                         $page_name = $data["FILTER"];
@@ -3754,12 +3768,11 @@ EOD;
         $folder_hash_id = L\crawlHash(($data['PAGE_ID'] ?? -1) . $sub_path);
         if (!empty($_REQUEST['sort']) && in_array($_REQUEST['sort'],
             array_keys($data['sort_fields']))) {
-            unset($_SESSION['media_sorts'][$folder_hash_id]);
-            $_SESSION['media_sorts'][$folder_hash_id] = $_REQUEST['sort'];
             if (count($_SESSION['media_sorts']) > 10) {
                 $first_key = array_key_first($_SESSION['media_sorts']);
                 unset($_SESSION['media_sorts'][$first_key]);
             }
+            $_SESSION['media_sorts'][$folder_hash_id] = $_REQUEST['sort'];
             $changed = true;
         }
         if (!empty($_REQUEST['layout']) && in_array($_REQUEST['layout'],
diff --git a/src/css/search.css b/src/css/search.css
index b6e3e1a68..e4b95265e 100755
--- a/src/css/search.css
+++ b/src/css/search.css
@@ -4098,6 +4098,7 @@ td.instruct
     padding: 0;
     margin: 0;
 }
+#page-sort-fields,
 #group-sort
 {
     position: relative;
diff --git a/src/library/VersionFunctions.php b/src/library/VersionFunctions.php
index b37a3b902..cf7f2c0d1 100644
--- a/src/library/VersionFunctions.php
+++ b/src/library/VersionFunctions.php
@@ -2025,17 +2025,6 @@ function upgradeDatabaseVersion72(&$db)
  */
 function upgradeDatabaseVersion73(&$db)
 {
-    $dbinfo = ["DBMS" => C\DBMS, "DB_HOST" => C\DB_HOST,
-        "DB_NAME" => C\DB_NAME, "DB_PASSWORD" => C\DB_PASSWORD];
-    $integer = $db->integerType($dbinfo);
-    $db->execute("DROP TABLE IF EXISTS USER_TERM_WEIGHTS_HASH2VEC");
-    $db->execute("DROP TABLE IF EXISTS USER_ITEM_SIMILARITY_HASH2VEC");
-    $db->execute("DROP TABLE IF EXISTS HASH2VEC_TERM_SIMILARITY");
-    $db->execute("CREATE TABLE USER_TERM_WEIGHTS_HASH2VEC(TERM_ID $integer,
-        USER_ID $integer, WEIGHT FLOAT, PRIMARY KEY(TERM_ID, USER_ID))");
-    $db->execute("CREATE TABLE USER_ITEM_SIMILARITY_HASH2VEC(USER_ID $integer,
-        THREAD_ID $integer, SIMILARITY FLOAT, GROUP_MEMBER $integer,
-        PRIMARY KEY(USER_ID, THREAD_ID))");
-    $db->execute("CREATE TABLE HASH2VEC_TERM_SIMILARITY(TERM1 $integer,
-        TERM2 $integer, SCORE FLOAT, PRIMARY KEY(TERM1, TERM2))");
+    $db->execute("ALTER TABLE GROUP_PAGE ADD COLUMN LAST_MODIFIED
+        NUMERIC(".C\TIMESTAMP_LEN.")");
 }
diff --git a/src/library/media_jobs/RecommendationJob.php b/src/library/media_jobs/RecommendationJob.php
index db6758dc2..d451c610b 100644
--- a/src/library/media_jobs/RecommendationJob.php
+++ b/src/library/media_jobs/RecommendationJob.php
@@ -33,6 +33,7 @@ namespace seekquarry\yioop\library\media_jobs;

 use seekquarry\yioop\configs as C;
 use seekquarry\yioop\library as L;
+use seekquarry\yioop\library\LinearAlgebra as LinearAlgebra;
 use seekquarry\yioop\library\CrawlConstants;
 use seekquarry\yioop\models\CronModel;

@@ -76,36 +77,6 @@ class RecommendationJob extends MediaJob
      * Maximum number of terms used in making recommendations
      */
     const MAX_TERMS = 20000;
-    /**
-     * Regular expression pattern to clean words in data corpus
-     * for HASH2VEC approach
-     */
-    const WORD_SPLIT_PATTERN = "/([.,!?\"':;)(])+/";
-    /**
-     * Dimensions of HASH2VEC vectors generated for words in data corpus
-     */
-    const HASH2VEC_VECTOR_LENGTH = 200;
-    /**
-     * Length of context window for HASH2VEC similarity calculation
-     */
-    const CONTEXT_WIDTH = 5;
-    /**
-     * Number of similar words to keep for a word in HASH2VEC
-     */
-    const MAX_SIMILAR_WORDS = 10;
-    /**
-     * Array of the generated HASH2VEC vectors for words in the data corpus
-     * @var array
-     */
-    public $hash2vec_vectors = [];
-    /**
-     * Associative array of words in the data corpus for HASH2VEC
-     */
-    public $hash2vec_words_dictionary = [];
-    /**
-     * Associative array of number of word in the data corpus for HASH2VEC
-     */
-    public $hash2vec_words_reverse_dictionary = [];
     /**
      * Sets up the database connection so can access tables related
      * to recommendations. Initialize timing info related to job.
@@ -230,9 +201,6 @@ class RecommendationJob extends MediaJob
         $this->computeUserItemIdf($number_items, $number_users);
         $this->tfIdfUsers();
         $this->tfIdfItems();
-        if (C\RECOMMENDATION_TYPE == 1) {
-            $this->initializeHash2Vec();
-        }
         $this->computeUserItemSimilarity();
         $not_belongs_subselect =  "NOT EXISTS (SELECT * FROM ".
             "GROUP_ITEM B WHERE S.USER_ID=B.USER_ID ".
@@ -524,32 +492,14 @@ class RecommendationJob extends MediaJob
     {
         L\crawlLog("...Computing User Item Similarity Scores.");
         $db = $this->db;
-        $user_weight_table = "USER_TERM_WEIGHTS";
-        if (C\RECOMMENDATION_TYPE == 1) {
-            $this->db->execute("INSERT INTO USER_TERM_WEIGHTS_HASH2VEC
-                SELECT * FROM USER_TERM_WEIGHTS");
-            $this->db->execute("UPDATE USER_TERM_WEIGHTS_HASH2VEC
-                SET WEIGHT=COALESCE((SELECT SUM(HTS.SCORE*UTW.WEIGHT)
-                FROM HASH2VEC_TERM_SIMILARITY HTS, USER_TERM_WEIGHTS UTW
-                WHERE HTS.TERM2 = UTW.TERM_ID AND HTS.TERM1 =
-                USER_TERM_WEIGHTS_HASH2VEC.TERM_ID),0)");
-            $this->db->execute("UPDATE USER_TERM_WEIGHTS_HASH2VEC
-                SET WEIGHT=WEIGHT+COALESCE((SELECT UTW.WEIGHT
-                FROM USER_TERM_WEIGHTS UTW WHERE
-                USER_TERM_WEIGHTS_HASH2VEC.TERM_ID = UTW.TERM_ID
-                AND USER_TERM_WEIGHTS_HASH2VEC.USER_ID = UTW.USER_ID),0)");
-            $this->db->execute("INSERT INTO USER_ITEM_SIMILARITY_HASH2VEC
-                SELECT * FROM USER_ITEM_SIMILARITY");
-            $user_weight_table = "USER_TERM_WEIGHTS_HASH2VEC";
-        }
         $similarity_parts_sql =
             "SELECT SUM(UTW.WEIGHT * ITW.WEIGHT) AS THREAD_DOT_USER, ".
             "SUM(UTW.WEIGHT * UTW.WEIGHT) AS USER_MAG," .
             "SUM(ITW.WEIGHT * ITW.WEIGHT) AS ITEM_MAG," .
             "GI.PARENT_ID AS THREAD_ID, UTW.USER_ID AS USER_ID ".
-            "FROM ITEM_TERM_WEIGHTS ITW, $user_weight_table UTW, ".
-            "GROUP_ITEM GI WHERE GI.ID = ITW.ITEM_ID AND ".
-            "UTW.TERM_ID=ITW.TERM_ID GROUP BY UTW.USER_ID, GI.PARENT_ID";
+            "FROM ITEM_TERM_WEIGHTS ITW, USER_TERM_WEIGHTS UTW, GROUP_ITEM GI ".
+            "WHERE GI.ID = ITW.ITEM_ID AND UTW.TERM_ID=ITW.TERM_ID " .
+            "GROUP BY UTW.USER_ID, GI.PARENT_ID";
         $similarity_parts_result = $db->execute($similarity_parts_sql);
         //used to check if belong to group
         $member_info_sql = "SELECT GI.GROUP_ID FROM ".
@@ -669,302 +619,4 @@ class RecommendationJob extends MediaJob
             $db->execute($insert_ignore_sql);
         }
     }
-    /**
-     * Initializes data corpus for HASH2VEC recommendation approach. The data
-     * consists of concated title and description text of the group items
-     * separated by new line character for previous item
-     */
-    public function initializeHash2Vec()
-    {
-        L\crawlLog("...Initializing Hash2Vec.");
-        $db = $this->db;
-        $data_corpus = "";
-        $group_item_sql = "SELECT ID AS ITEM_ID, TITLE, DESCRIPTION ".
-            "FROM GROUP_ITEM ".
-            "WHERE LOWER(TITLE) NOT LIKE '%page%'" .
-            "AND LOWER(DESCRIPTION) NOT LIKE '%-0700%'" .
-            "ORDER BY PUBDATE DESC " . $db->limitOffset(self::MAX_GROUP_ITEMS);
-        $results = $db->execute($group_item_sql);
-        while ($item = $db->fetchArray($results)) {
-            $data_corpus .= $item['TITLE']. " ";
-            $data_corpus .= $item['DESCRIPTION'] . "\n";
-        }
-        $this->generateVectors($data_corpus);
-    }
-    /**
-     * Generates the HASH2VEC vectors for words in the given data corpus
-     *
-     * @param string $data_corpus the data corpus of group items in the form
-     *      title + description for each item per line
-     */
-    public function generateVectors($data_corpus)
-    {
-        L\crawlLog("...Generating Hash2Vec Vectors.");
-        for ($i=0; $i<self::CONTEXT_WIDTH; $i++) {
-            $context_distance_vector[] = -$i + self::CONTEXT_WIDTH;
-        }
-        for ($i=0; $i<self::CONTEXT_WIDTH; $i++) {
-            $context_distance_vector[] = $i;
-        }
-        $standard_deviation = $this->calculateStandardDeviation(
-            $context_distance_vector);
-        $word_id = 0;
-        $data_lines = explode("\n", strtolower($data_corpus));
-        foreach ($data_lines as $line) {
-            $line = preg_replace("/[\n\r]/",'',$line);
-            if (strlen($line) == 0) {
-                continue;
-            }
-            $words = explode(" ", strtolower($line));
-            if (count($words) <= 1) { continue; }
-            $clean_words = [];
-            foreach ($words as $word) {
-                if ($word){
-                    $clean_word = preg_replace(
-                        self::WORD_SPLIT_PATTERN, '', $word);
-                    $clean_words[] = $clean_word;
-                }
-            }
-            $word_ids = [];
-            foreach ($clean_words as $word) {
-                $word_ids[] = $this->wordToId($word, $word_id);
-                $word_id += 1;
-            }
-            $word_index = 0;
-            foreach ($word_ids as $id) {
-                list($context_words, $distances) =
-                    $this->getContextWords($word_ids, $word_index);
-                $i = 0;
-                foreach ($context_words as $word) {
-                    $power = pow($distances[$i] / $standard_deviation, 2);
-                    $distance = exp(-1 * $power);
-                    list($index, $sign) = $this->getHashIndex(
-                        $this->hash2vec_words_reverse_dictionary[$word]);
-                    $this->hash2vec_vectors[$id][$index] =
-                        $this->hash2vec_vectors[$id][$index] +
-                        $sign * $distance;
-                    $i += 1;
-                }
-                $word_index += 1;
-            }
-        }
-        $this->normalizeVectors();
-        $this->calculateSimilarityHash2Vec();
-    }
-    /**
-     * Performs normalization for the HASH2VEC vectors in order to avoid
-     * the features with less values getting neglected in calculating
-     * similarity
-     */
-    public function normalizeVectors()
-    {
-        L\crawlLog("...Normalizing Hash2Vec Vectors.");
-        for ($i = 0; $i < count($this->hash2vec_vectors); $i++) {
-            $vector = [];
-            foreach ($this->hash2vec_vectors[$i] as $value) {
-                $vector[] = abs($value);
-            }
-            $sum = array_sum($vector);
-            foreach ($vector as $index=>$value) {
-                if ($sum == 0) { $sum = 1; }
-                $this->hash2vec_vectors[$i][$index] = $value * 1. / $sum;
-            }
-        }
-    }
-    /**
-     * Calculates top 10 similar words for every word in the hash2vec words
-     * dictionary. The similarity is calculated using cosine coefficient
-     * between the corresponding vectors of two words
-     */
-    public function calculateSimilarityHash2Vec()
-    {
-        L\crawlLog("...Generating Hash2Vec Similarity Score.");
-        $db=$this->db;
-        $base_sql = "INSERT INTO HASH2VEC_TERM_SIMILARITY VALUES";
-        $insert_sql = $base_sql;
-        $insert_count = 0;
-        $comma = "";
-        foreach ($this->hash2vec_words_reverse_dictionary as $id => $word) {
-            $similar_words = $this->getSimilarWords($word);
-            if (!empty($similar_words)) {
-                $word_hash = floor(bindec(str_replace(" ", "",
-                    L\toBinString(hash("crc32b", strtolower($word), true))))/2);
-                $db->execute("DELETE FROM HASH2VEC_TERM_SIMILARITY WHERE
-                    TERM1 = " . $word_hash);
-                foreach ($similar_words as $item) {
-                    $term_hash = floor(bindec(str_replace(" ", "",
-                        L\toBinString(hash("crc32b", $item[0], true))))/2);
-                    $score = $item[1];
-                    $insert_sql .= "$comma ($word_hash, $term_hash,
-                        $score)";
-                    $comma = ",";
-                    $insert_count++;
-                    if ($insert_count == self::BATCH_SQL_INSERT_NUM) {
-                        $insert_ignore_sql = $db->insertIgnore($insert_sql);
-                        $db->execute($insert_ignore_sql);
-                        $insert_sql = $base_sql;
-                        $insert_count = 0;
-                        $comma = "";
-                    }
-                }
-            }
-        }
-        if ($insert_count > 0) {
-            $insert_ignore_sql = $db->insertIgnore($insert_sql);
-            $db->execute($insert_ignore_sql);
-        }
-    }
-    /**
-     * Calculates hash score for all the words in the dictionary with given
-     * word and finds defined number of similar words
-     *
-     * @param string $word word for which similar words are to be calculated
-     * @return array array of similar words and similarity score
-     */
-    public function getSimilarWords($word) {
-        $word = strtolower($word);
-        if (!array_key_exists($word, $this->hash2vec_words_dictionary)) {
-            return NULL;
-        }
-        $word_id = $this->hash2vec_words_dictionary[$word];
-        $word_vector = $this->hash2vec_vectors[$word_id];
-        $heap = new \SplMinHeap();
-        foreach ($this->hash2vec_vectors as $index => $vector) {
-            if ($index == $word_id) {
-                continue;
-            }
-            $power = [];
-            foreach ($vector as $value) {
-                $power[] = pow($value, 2);
-            };
-            $sum = array_sum($power);
-            if ($sum == 0) {
-                $sum = 1;
-            }
-            $root_sum = sqrt($sum);
-            $score = $this->dotProduct($word_vector, $vector) / $root_sum;
-            if ($heap->count() < self::MAX_SIMILAR_WORDS) {
-                $heap->insert([$score, $index]);
-            } else if ($heap->top()[0] < $score) {
-                $heap->extract();
-                $heap->insert([$score, $index]);
-            }
-        }
-        $similar_words = [];
-        for ($heap->top(); $heap->valid(); $heap->next()) {
-            $word = $heap->current();
-            array_push($similar_words, [$this->hash2vec_words_reverse_dictionary
-                [$word[1]], $word[0]]);
-        }
-        return $similar_words;
-    }
-    /**
-     * Calculates statistical standard deviation of given data elements
-     *
-     * @param array $data array of elements
-     * @return float standard deviation
-     */
-    public function calculateStandardDeviation($data)
-    {
-        $average = round(array_sum($data) / count($data), 1);
-        $differences = [];
-        foreach ($data as $value) {
-            $difference = $value - $average;
-            $differences[] = pow($difference, 2);
-        }
-        $sum = array_sum($differences);
-        $variance = $sum / count($differences);
-        $standard_deviation = sqrt($variance);
-        return $standard_deviation;
-    }
-    /**
-     * Insert a word to dictionary of words and assign a id
-     *
-     * @param string $word word to insert into dictionary
-     * @param int $id id of the word
-     * @return int id assigned to the word
-     */
-    public function wordToId($word, $id)
-    {
-        if (!array_key_exists($word, $this->hash2vec_words_dictionary)) {
-            $this->hash2vec_words_dictionary[$word] = $id;
-            $this->hash2vec_words_reverse_dictionary[$id] = $word;
-            $this->hash2vec_vectors[] =
-                array_fill(0,self::HASH2VEC_VECTOR_LENGTH,0);
-        }
-        return $this->hash2vec_words_dictionary[$word];
-    }
-    /**
-     * Generates appropriate context window of words for the given word
-     *
-     * @param array $word_ids ids of words in current data line
-     * @param int index the index of word for which the context window is
-     *      calculated
-     * @return array of context words and their distances from given word
-     */
-    public function getContextWords($word_ids, $index) {
-        $start_idx = 0;
-        $end_idx = $index + 1 + self::CONTEXT_WIDTH;
-        if ($index > self::CONTEXT_WIDTH) {
-            $start_idx = $index - self::CONTEXT_WIDTH;
-        }
-        if ($end_idx > count($word_ids)) { $end_idx = count($word_ids); }
-        $prefix = array_slice($word_ids, $start_idx, $index-$start_idx);
-        if ($index <= self::CONTEXT_WIDTH) {
-            $suffix = array_slice($word_ids,$index + 1,
-                $end_idx - $index - $start_idx - 1);
-        } else if ($index >= (count($word_ids) - self::CONTEXT_WIDTH)) {
-            $suffix = array_slice($word_ids, $index + 1, $end_idx - $index - 1);
-        } else {
-            $suffix = array_slice($word_ids, $index + 1, self::CONTEXT_WIDTH);
-        }
-        $context_words = array_merge($prefix, $suffix);
-        if ($index - $start_idx == 0) {
-            $prefix = [];
-        } else {
-            $prefix = range(1, $index - $start_idx);
-        }
-        if ($index <= self::CONTEXT_WIDTH) {
-            if ($end_idx - $index - $start_idx - 1 == 0) {
-                $suffix = [];
-            } else {
-                $suffix = range($end_idx - $index - $start_idx - 1, 1, -1);
-            }
-        } else if ($index == count($word_ids) - 1) {
-            $suffix = [];
-        } else {
-            $suffix = range($end_idx - $index - 1, 1, -1);
-        }
-        $distance = array_merge($prefix, $suffix);
-        assert(count($distance) == count($context_words));
-        return [$context_words, $distance];
-    }
-    /**
-     * Calculates the index in HASH2VEC vector of the word where the given
-     * word's context should be written using md5 hash value of the given word
-     *
-     * @param string $word whose hash value is to be calculated
-     * @return array of index and sign of hash
-     */
-    public function getHashIndex($word)
-    {
-        $hash = unpack("N", substr(md5($word), 0, 4))[1];
-        $index = $hash % self::HASH2VEC_VECTOR_LENGTH;
-        $sign = $hash % 2 ? 1 : -1;
-        return [$index, $sign];
-    }
-    /**
-     * Performs dot product operation on two vectors
-     *
-     * @param array $vector1 array representing vector 1 elements
-     * @param array $vector2 array representing vector 2 elements
-     * @return float product of two vectors
-     */
-    public function dotProduct($vector1, $vector2) {
-        $product = 0;
-        for ($i = 0; $i < count($vector1); $i++) {
-            $product = $product + $vector1[$i] * $vector2[$i];
-        }
-        return $product;
-    }
 }
diff --git a/src/models/GroupModel.php b/src/models/GroupModel.php
index e861e1ef0..99885a5ae 100644
--- a/src/models/GroupModel.php
+++ b/src/models/GroupModel.php
@@ -1437,16 +1437,16 @@ class GroupModel extends Model implements MediaConstants
             //can only add and use resources for a page that exists
             $parsed_page = $this->insertResourcesParsePage($group_id, $page_id,
                 $locale_tag, $parsed_page);
-            $sql = "UPDATE GROUP_PAGE SET PAGE=? WHERE ID = ?";
-            $result = $db->execute($sql, [$parsed_page, $page_id]);
+            $sql = "UPDATE GROUP_PAGE SET PAGE=?, LAST_MODIFIED=? WHERE ID = ?";
+            $result = $db->execute($sql, [$parsed_page, $pubdate, $page_id]);
         } else {
             $discuss_thread = $this->addGroupItem(0, $group_id, $user_id,
                 $thread_title, $thread_description . " " . date("r", $pubdate),
                 C\WIKI_GROUP_ITEM);
-            $sql = "INSERT INTO GROUP_PAGE (DISCUSS_THREAD, GROUP_ID,
-                TITLE, PAGE, LOCALE_TAG) VALUES (?, ?, ?, ?, ?)";
+            $sql = "INSERT INTO GROUP_PAGE (DISCUSS_THREAD, GROUP_ID, TITLE,
+                PAGE, LOCALE_TAG, LAST_MODIFIED) VALUES (?, ?, ?, ?, ?, ?)";
             $result = $db->execute($sql, [$discuss_thread, $group_id,
-                $page_name, $parsed_page, $locale_tag]);
+                $page_name, $parsed_page, $locale_tag, $pubdate]);
             $page_id = $db->insertID("GROUP_PAGE");
             ImpressionModel::initWithDb($user_id, $page_id, C\WIKI_IMPRESSION,
                 $db);
@@ -1728,8 +1728,8 @@ class GroupModel extends Model implements MediaConstants
                 AND GP.TITLE = ? AND GP.LOCALE_TAG = ? AND HP.PAGE_ID = GP.ID
                 ORDER BY HP.PUBDATE DESC " . $db->limitOffset(0, 1);
         } else {
-            $sql = "SELECT ID, PAGE, DISCUSS_THREAD FROM GROUP_PAGE
-                WHERE GROUP_ID = ? AND TITLE=? AND LOCALE_TAG = ?";
+            $sql = "SELECT ID, PAGE, DISCUSS_THREAD, LAST_MODIFIED FROM
+                GROUP_PAGE WHERE GROUP_ID = ? AND TITLE=? AND LOCALE_TAG = ?";
         }
         $result = $db->execute($sql, [$group_id, $name, $locale_tag]);
         if (!$result) {
@@ -1742,8 +1742,8 @@ class GroupModel extends Model implements MediaConstants
         return $row;
     }
     /**
-     * Returns the group_id, language, and page name of a wiki page
-     *     corresponding to a page discussion thread with id $page_thread_id
+     * Returns the group_id, language, page name, last modified date of a wiki
+     * pagecorresponding to a page discussion thread with id $page_thread_id
      * @param int $page_thread_id the id of a wiki page discussion thread
      *     to look up page info for
      * @return array (group_id, language, and page name) of that wiki page
@@ -1751,8 +1751,8 @@ class GroupModel extends Model implements MediaConstants
     public function getPageInfoByThread($page_thread_id)
     {
         $db = $this->db;
-        $sql = "SELECT GROUP_ID, LOCALE_TAG, TITLE AS PAGE_NAME FROM GROUP_PAGE
-            WHERE DISCUSS_THREAD = ?";
+        $sql = "SELECT GROUP_ID, LOCALE_TAG, TITLE AS PAGE_NAME,
+            LAST_MODIFIED FROM GROUP_PAGE WHERE DISCUSS_THREAD = ?";
         $result = $db->execute($sql,  [$page_thread_id]);
         if (!$result) {
             return false;
@@ -1772,8 +1772,8 @@ class GroupModel extends Model implements MediaConstants
     public function getPageInfoByPageId($page_id)
     {
         $db = $this->db;
-        $sql = "SELECT GROUP_ID, LOCALE_TAG, TITLE AS PAGE_NAME, DISCUSS_THREAD
-            AS DISCUSS_THREAD FROM GROUP_PAGE WHERE ID = ?";
+        $sql = "SELECT GROUP_ID, LOCALE_TAG, TITLE AS PAGE_NAME,
+            DISCUSS_THREAD, LAST_MODIFIED FROM GROUP_PAGE WHERE ID = ?";
         $result = $db->execute($sql, [$page_id]);
         if (!$result) {
             return false;
@@ -4159,6 +4159,8 @@ EOD;
      * @param int $group_id of group want list of wiki pages for
      * @param string $locale_tag language want wiki page list for
      * @param string $filter string we want to filter wiki page title by
+     * @param string $sort one of name_asc, name_desc, date_asc, date_desc
+     *      specifying how the page list should be sorted
      * @param string $limit first row we want from the result set
      * @param string $num number of rows we want starting from the first row
      *     in the result set
@@ -4167,10 +4169,17 @@ EOD;
      *     $pages is an array each of whose elements is an array corresponding
      *     to one TITLE and the first 100 chars out of a wiki page.
      */
-    public function getPageList($group_id, $locale_tag, $filter, $limit, $num)
+    public function getPageList($group_id, $locale_tag, $filter, $sort, $limit,
+        $num)
     {
         $db = $this->db;
         $filter_parts = preg_split("/\s+/", $filter);
+        $sort_map = [ "name_asc" => "LOWER(TITLE) ASC",
+            "name_desc" => "LOWER(TITLE) DESC",
+            "modified_asc" => "LAST_MODIFIED ASC",
+            "modified_desc" => "LAST_MODIFIED DESC",
+        ];
+        $sort_dir = $sort_map[$sort] ?? "LOWER(TITLE) ASC";
         $like = "";
         $params = [$group_id, $locale_tag];
         foreach ($filter_parts as $part) {
@@ -4189,10 +4198,10 @@ EOD;
         $total = (isset($row) && $row) ? $row["TOTAL"] : 0;
         $pages = [];
         if ($total > 0) {
-            $sql = "SELECT TITLE, PAGE AS DESCRIPTION
+            $sql = "SELECT TITLE, PAGE AS DESCRIPTION, LAST_MODIFIED
                 FROM GROUP_PAGE WHERE GROUP_ID = ? AND
                 LOCALE_TAG= ? AND LENGTH(PAGE) > 0
-                $like ORDER BY LOWER(TITLE) ASC ".
+                $like ORDER BY $sort_dir ".
                 $db->limitOffset($limit, $num);
             $result = $db->execute($sql, $params);
             $i = 0;
diff --git a/src/models/ProfileModel.php b/src/models/ProfileModel.php
index 0e507a22b..44a1eed05 100755
--- a/src/models/ProfileModel.php
+++ b/src/models/ProfileModel.php
@@ -205,7 +205,8 @@ class ProfileModel extends Model
             "GROUP_PAGE" => "CREATE TABLE GROUP_PAGE (
                 ID $serial PRIMARY KEY $auto_increment, GROUP_ID $integer,
                 DISCUSS_THREAD $integer, TITLE VARCHAR(" . C\TITLE_LEN . "),
-                PAGE $page_type, LOCALE_TAG VARCHAR(" . C\NAME_LEN . "))",
+                PAGE $page_type, LOCALE_TAG VARCHAR(" . C\NAME_LEN . "),
+                LAST_MODIFIED NUMERIC(" . C\TIMESTAMP_LEN . "))",
             "GP_ID_INDEX" => "CREATE INDEX GP_ID_INDEX ON GROUP_PAGE
                  (GROUP_ID, TITLE, LOCALE_TAG)",
             "GROUP_PAGE_HISTORY" => "CREATE TABLE GROUP_PAGE_HISTORY(
@@ -390,16 +391,6 @@ class ProfileModel extends Model
                 ACCESS_COUNT $integer,
                 PRIMARY KEY(ADDRESS, PAGE_NAME))",
             "VERSION" => "CREATE TABLE VERSION(ID $integer PRIMARY KEY)",
-            "USER_TERM_WEIGHTS_HASH2VEC" => "CREATE TABLE
-                USER_TERM_WEIGHTS_HASH2VEC(TERM_ID $integer, USER_ID $integer,
-                WEIGHT FLOAT, PRIMARY KEY(TERM_ID, USER_ID))",
-            "USER_ITEM_SIMILARITY_HASH2VEC" => "CREATE TABLE
-                USER_ITEM_SIMILARITY_HASH2VEC(USER_ID $integer, THREAD_ID
-                $integer, SIMILARITY FLOAT, GROUP_MEMBER $integer,
-                PRIMARY KEY(USER_ID, THREAD_ID))",
-            "HASH2VEC_TERM_SIMILARITY" => "CREATE TABLE HASH2VEC_TERM_SIMILARITY
-                (TERM1 $integer, TERM2 $integer, SCORE FLOAT,
-                PRIMARY KEY(TERM1, TERM2))",
             ];
     }
     /**
diff --git a/src/views/elements/WikiElement.php b/src/views/elements/WikiElement.php
index e77c62a5c..0126f2632 100644
--- a/src/views/elements/WikiElement.php
+++ b/src/views/elements/WikiElement.php
@@ -1617,6 +1617,13 @@ class WikiElement extends Element implements CrawlConstants
         <input type="hidden" name="arg" value="pages" />
         <input type="hidden" name="group_id" value="<?=
             $data['GROUP']['GROUP_ID'] ?>" />
+        <?php
+        $this->view->helper("options")->renderLinkDropDown(
+        "page-sort-fields", $data['sort_fields'] ?? "",
+        $data['CURRENT_SORT']?? "", "$paging_query&amp;sort=",
+        false, "",
+        "<span class='hover-lightgray' role='img' aria-label='" .
+        tl('wiki_element_sort_order') . "'>&#8645;</span>"); ?>
         <div class="search-filter-container">
         <input type="search" name="filter" class="extra-wide-field"
             maxlength="<?= C\SHORT_TITLE_LEN ?>"
@@ -1636,6 +1643,7 @@ class WikiElement extends Element implements CrawlConstants
         <div>&nbsp;</div>
         <?php
         if ($data['PAGES'] != []) {
+            $time = time();
             foreach ($data['PAGES'] as $page) {
                 if ($page['TYPE'] == 'page_alias' && isset($page['ALIAS'])) {
                     $page["DESCRIPTION"] = tl('wiki_element_redirect_to').
@@ -1671,7 +1679,10 @@ class WikiElement extends Element implements CrawlConstants
                         "small-margin no-padding", "", false, $tab_target);
                 }
                 ?></br />
-                <?= $page["DESCRIPTION"] ?>
+                <?= $page["DESCRIPTION"] ?><br />
+                <span class="float-opposite gray"><?=
+                    $this->view->helper("feeds")->getPubdateString(
+                    $time, $page['LAST_MODIFIED']); ?></span>
                 </div>
                 <?php
             }?>
ViewGit