Repository: BOINC/boinc
Pull request #6338: Web, scheduler: support keyword prefs on BOINC projects
Author: login: davidpanderson
Labels: name: C: Client
name: C: Server
name: C: Web - Project
name: PR: Enhancement
Created: 2025-05-20T21:26:43Z | Merged: 2025-05-25T03:07:19Z

Science United lets users specify keyword prefs.
These are used to decide what projects to assign the user. They are also conveyed (in scheduler requests)
to projects that have jobs with keywords.
The scheduler uses this info to decide what jobs to send.

This PR extends this by
- letting non-SU users of such projects (e.g. BOINC Central) specify keyword prefs on the project web site
- assigning keywords to BUDA jobs: the union of the keywords of the BUDA app (usually science areas) and the "yes" keywords of the job submitter (usually location)

Note to self: avoid strtok(); it changes the string. Use stringstream and getline() instead.
I spent several hours chasing a bug caused by this.

Fixes #6326


Linked issue #6326: Keyword preferences in BOINC web code
Keywords (science area and location) are used in Science United to select projects.
We need to add them to BOINC Central (for users who attach directly);
this may as well be part of the standard BOINC code.
Linked issue comments: user: login: davidpanderson
created_at: 2025-05-15T23:28:21Z
body: ## Goals

A project (e.g. BOINC Central) may have jobs with various science areas and locations.
Volunteers should be able to use keyword prefs to control what jobs they get.

This currently works if the volunteer uses Science United.
It should also work if they're attached to the project directly.
This means letting them edit keyword prefs on the project web site.

## Terms

* A 'full keyword' is ID, name, category, parent
* A 'keyword ID' is just the ID (int)
* A 'user KW prefs' is lists of yes and no KW IDs

## Current:

Account manager RPC:
* reply includes user KW prefs.
These are stored in the acct_mgr_login.xml file, and in the ACCT_MGR_INFO object in memory.

Scheduler RPC:
* request: includes user KW prefs (from acct mgr)
* replies: workunits have "job_keyword_ids": lists of IDs

Client:
* reads list of all full keywords from a file 'keywords.xml'.
I'm not sure where this file comes from; it's not updated by the client.

GUI RPC:
* replies: workunits have list of full keywords

Job submission:
* create_work has a --keywords arg (list of IDs).
This should get stored in workunit.keywords (check that it does this, and that they're added to xml_doc when job is sent).

## Design decisions

* Do we want keywords.xml on clients?
    * No; keep it simple

* Should KW prefs propagate between projects, like computing prefs?
    * No; keep it simple

* where to store per-user KW prefs on server?
    * for now, in text files (not DB)

## Changes

client/manager:
* don't include job keywords in GUI RPC
* don't show user or job keywords anywhere;
user can see job keywords on project web site if they want

Actually, may as well keep the existing features.
They'll work if keywords.xml is present.

project web:
* add interface for editing KW prefs (borrow code from SU)
* store results in files: project/kw_prefs/userid.json

Scheduler:
* if KW scheduling enabled: if request doesn't have KW prefs, look for them in file
*  make sure KW scheduling works

Job submission:
* job submitters can have keywords (location and area). For now, use their KW prefs for this.
* BUDA apps can have area keywords
* BUDA jobs get the union of these

author_association: CONTRIBUTOR

Diff:
diff --git a/html/inc/kw_prefs.inc b/html/inc/kw_prefs.inc
new file mode 100644
index 00000000000..e5669e8cefb
--- /dev/null
+++ b/html/inc/kw_prefs.inc
@@ -0,0 +1,52 @@
+<?php
+// This file is part of BOINC.
+// https://boinc.berkeley.edu
+// Copyright (C) 2024 University of California
+//
+// BOINC is free software; you can redistribute it and/or modify it
+// under the terms of the GNU Lesser General Public License
+// as published by the Free Software Foundation,
+// either version 3 of the License, or (at your option) any later version.
+//
+// BOINC is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+// See the GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with BOINC.  If not, see <http://www.gnu.org/licenses/>.
+// read user prefs; return lists of yes and no keywords
+//
+
+// utility functions for user keyword prefs
+
+define('KW_YES', 1);
+define('KW_MAYBE', 0);
+define('KW_NO', -1);
+
+// read user prefs; return lists of yes and no keywords
+//
+function read_kw_prefs($user) {
+    $path = "../../kw_prefs/$user->id";
+    if (file_exists($path)) {
+        $x = file($path);
+        $yes = explode(' ', trim($x[0]));
+        if ($yes[0]=='') $yes=[];
+        $yes = array_map('intval', $yes);
+        $no = explode(' ', trim($x[1]));
+        if ($no[0]=='') $no=[];
+        $no = array_map('intval', $no);
+        return [$yes, $no];
+    } else {
+        return [[],[]];
+    }
+}
+
+function write_kw_prefs($user, $yes, $no) {
+    $y = implode(' ', array_map('strval', $yes));
+    $n = implode(' ', array_map('strval', $no));
+    $path = "../../kw_prefs/$user->id";
+    file_put_contents($path, "$y\n$n\n");
+}
+
+?>
diff --git a/html/inc/user.inc b/html/inc/user.inc
index 7aa2ab21d5c..d0b4c58ed04 100644
--- a/html/inc/user.inc
+++ b/html/inc/user.inc
@@ -333,6 +333,14 @@ function show_preference_links() {
             "<a href=\"prefs.php?subset=project\">".tra("%1 preferences", PROJECT)."</a>"
         );
     }
+    if (parse_bool(get_config(), 'keyword_sched')) {
+        row2(
+            tra('What types of jobs to run'),
+            sprintf('<a href=kw_prefs.php>%s</a>',
+                tra('Science and location preferences')
+            )
+        );
+    }
 }
 
 // return string describing a friend:
diff --git a/html/user/buda.php b/html/user/buda.php
index e2e5850849d..09a014a9744 100644
--- a/html/user/buda.php
+++ b/html/user/buda.php
@@ -68,7 +68,7 @@ function app_list($notice=null) {
         <p>BUDA lets you submit Docker jobs using a web interface.
         <a href=https://github.com/BOINC/boinc/wiki/BUDA-overview>Learn more</a>.
         <p>
-        BUDA science apps:
+        <h3>BUDA science apps</h3>
     ";
 
     $apps = get_buda_apps();
@@ -88,12 +88,12 @@ function show_app($dir) {
     $desc = json_decode(file_get_contents($desc_path));
     echo '<hr>';
     echo sprintf('<h3>%s</h3>', $desc->long_name);
-    echo sprintf('<a href=buda.php?action=app_details&name=%s>Manage app</a>',
+    echo sprintf('<a href=buda.php?action=app_details&name=%s>Details</a>',
         $desc->name
     );
     $vars = get_buda_variants($dir);
     if ($vars) {
-        echo "<p>App variants:<ul>";
+        echo "<p>Variants:<ul>";
         foreach ($vars as $var) {
             echo sprintf(
                 '<li><a href=buda.php?action=variant_view&app=%s&variant=%s>%s</a>',
@@ -494,7 +494,7 @@ function app_form($desc=null) {
     form_input_text(
         'URL of web page describing app',
         'url',
-        $desc?$desc->url:''
+        !empty($desc->url)?$desc->url:''
     );
     // don't include location keywords;
     // various people may submit jobs to this app
diff --git a/html/user/buda_submit.php b/html/user/buda_submit.php
index d76d19407e1..4ee4282eb67 100644
--- a/html/user/buda_submit.php
+++ b/html/user/buda_submit.php
@@ -22,6 +22,7 @@
 require_once('../inc/submit_util.inc');
 require_once('../inc/sandbox.inc');
 require_once('../inc/buda.inc');
+require_once('../inc/kw_prefs.inc');
 
 display_errors();
 
@@ -213,7 +214,8 @@ function stage_input_files($batch_dir, $batch_desc, $batch_id) {
 function create_jobs(
     $app, $app_desc, $variant, $variant_desc,
     $batch_desc, $batch_id, $batch_dir_name,
-    $wrapper_verbose, $cmdline, $max_fpops, $exp_fpops
+    $wrapper_verbose, $cmdline, $max_fpops, $exp_fpops,
+    $keywords
 ) {
     global $buda_boinc_app;
 
@@ -256,6 +258,9 @@ function create_jobs(
         "buda_apps/$app/$variant/template_out",
         $max_fpops, $exp_fpops
     );
+    if ($keywords) {
+        $cmd .= " --keywords '$keywords'";
+    }
     $cmd .= sprintf(' > %s 2<&1', "buda_batches/errfile");
 
     $h = popen($cmd, "w");
@@ -323,10 +328,17 @@ function handle_submit($user) {
     //
     stage_input_files($batch_dir, $batch_desc, $batch->id);
 
+    // get job keywords: user keywords plus BUDA app keywords
+    //
+    [$yes, $no] = read_kw_prefs($user);
+    $keywords = array_merge($yes, $app_desc->sci_kw, $app_desc->loc_kw);
+    $keywords = array_unique($keywords);
+    $keywords = implode(' ', $keywords);
+
     create_jobs(
         $app, $app_desc, $variant, $variant_desc,
         $batch_desc, $batch->id, $batch_dir_name,
-        $wrapper_verbose, $cmdline, $max_fpops, $exp_fpops
+        $wrapper_verbose, $cmdline, $max_fpops, $exp_fpops, $keywords
     );
 
     // mark batch as in progress
diff --git a/html/user/kw_prefs.php b/html/user/kw_prefs.php
new file mode 100644
index 00000000000..5bdb5b99809
--- /dev/null
+++ b/html/user/kw_prefs.php
@@ -0,0 +1,387 @@
+<?php
+
+// This file is part of BOINC.
+// http://boinc.berkeley.edu
+// Copyright (C) 2019 University of California
+//
+// BOINC is free software; you can redistribute it and/or modify it
+// under the terms of the GNU Lesser General Public License
+// as published by the Free Software Foundation,
+// either version 3 of the License, or (at your option) any later version.
+//
+// BOINC is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+// See the GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with BOINC.  If not, see <http://www.gnu.org/licenses/>.
+
+require_once("../inc/util.inc");
+require_once("../inc/keywords.inc");
+require_once("../inc/kw_prefs.inc");
+
+// Interactive page for editing keyword prefs
+//
+// The PHP generates rows for all keywords, and hides the appropriate ones.
+// The Javascript hides and unhides rows as needed.
+//
+// element IDs for keyword K
+//
+// rowK: the <tr> row element
+// textK: the plus or minus sign, or spaces
+// radioK: the radio buttons
+
+// for each kw K
+// nprefs(K) = how many descendants of K have nonzero pref
+// expanded(K): true if user chose to expand K
+//      persists if ancestors are contracted;
+//      doesn't persist if nprefs becomes nonzero
+//
+// actions:
+// click on expand or contract: set or clear expanded(K)
+// radio: recompute nprefs for all ancestors
+//
+// render:
+//  for each terminal node K
+//      if nprefs(parent)
+//          unhide
+//      else if all ancesters are either nprefs<>0 or expanded
+//          unhide
+//      else
+//          hide
+//
+//  for each nonterminal node K
+//      if nprefs(K)
+//          expanded=true
+//          unhide
+//          button=none
+//      else if nprefs(parent)
+//          unhide
+//          set button according to expanded
+//      else if all ancestors are expanded
+//          set button according to expanded
+//      else
+//          hide
+
+// for each keyword K:
+// - generate list of K's children
+//
+function keyword_setup($uprefs) {
+    global $job_keywords;
+    foreach ($job_keywords as $id=>$k) {
+        $job_keywords[$id]->children = array();
+        $job_keywords[$id]->expand = 0;
+    }
+    foreach ($job_keywords as $id=>$k) {
+        if (!$k->parent) continue;
+        $job_keywords[$k->parent]->children[] = $id;
+    }
+    foreach ($uprefs as $id=>$n) {
+        while (1) {
+            $id = $job_keywords[$id]->parent;
+            if (!$id) break;
+            $job_keywords[$id]->expand = true;
+        }
+    }
+}
+
+// output a keyword and (recursively) its descendants.
+// If its parent is expanded, show it, else hide
+//
+function generate_html_kw($id, $uprefs) {
+    global $job_keywords;
+    $kw = $job_keywords[$id];
+    $u = $uprefs[$id];
+    $yes_checked = ($u == KW_YES)?"checked":"";
+    $no_checked = ($u == KW_NO)?"checked":"";
+    $maybe_checked = ($u == KW_MAYBE)?"checked":"";
+
+    echo sprintf('<tr id="%s" hidden>%s', "row$id", "\n");
+    echo sprintf('   <td width=50%% id="%s"></td>%s', "text$id", "\n");
+    echo sprintf('   <td><input onclick="radio(%d, 1)" type="radio" name="%s" id="%s" value="%d" %s></td>%s',
+        $id, "radio$id", "radio$id"."_0", KW_YES, $yes_checked, "\n"
+    );
+    echo sprintf('   <td><input onclick="radio(%d, 0)" type="radio" name="%s" id="%s" value="%d" %s></td>%s',
+        $id, "radio$id", "radio$id"."_1", KW_MAYBE, $maybe_checked, "\n"
+    );
+    echo sprintf('   <td><input onclick="radio(%d, -1)" type="radio" name="%s" id="%s" value="%d" %s></td>%s',
+        $id, "radio$id", "radio$id"."_2", KW_NO, $no_checked, "\n"
+    );
+    echo "</tr>\n";
+
+    foreach ($kw->children as $k) {
+        generate_html_kw($k, $uprefs);
+    }
+}
+
+function generate_html_category($category, $uprefs) {
+    global $job_keywords;
+    row_heading_array(array(
+        '',
+        tra('Prefer'),
+        tra('As needed'),
+        tra('Never')),
+        null,
+        'bg-default'
+    );
+    foreach ($job_keywords as $id=>$k) {
+        if ($k->category != $category) continue;
+        if ($k->parent) continue;
+        generate_html_kw($id, $uprefs);
+    }
+}
+
+function generate_javascript($uprefs) {
+    global $job_keywords;
+    echo "<script>
+        var ids = new Array;
+        var names = new Array;
+        var levels = new Array;
+        var parent = new Array;
+        var radio_value = new Array;
+";
+    foreach ($job_keywords as $id=>$k) {
+        $val = $uprefs[$id];
+        echo "
+            names[$id] = '$k->name';
+            levels[$id] = $k->level;
+            parent[$id] = $k->parent;
+            radio_value[$id] = $val;
+            ids.push($id);
+        ";
+    }
+    echo sprintf("var nkws = %d;\n", count($job_keywords));
+    echo <<<EOT
+    var rows = new Array;
+    var texts = new Array;
+    var expanded = new Array;
+    var terminal = new Array;
+    var nprefs = new Array;
+
+    // initialize stuff
+
+    for (i=0; i<nkws; i++) {
+        terminal[ids[i]] = true;
+        nprefs[ids[i]] = 0;
+    }
+    for (i=0; i<nkws; i++) {
+        var j = ids[i];
+        var rowid = "row"+j;
+        var textid = "text"+j;
+        rows[j] = document.getElementById(rowid);
+        texts[j] = document.getElementById(textid);
+        if (parent[j]) {
+            terminal[parent[j]] = false;
+        }
+        expanded[j] = false;
+
+        if (radio_value[j]) {
+            k = j;
+            while (1) {
+                k = parent[k];
+                if (!k) break;
+                nprefs[k]++;
+            }
+        }
+    }
+
+    // Firefox doesn't set radio buttons correctly.
+    //
+    for (i=0; i<nkws; i++) {
+        var j = ids[i];
+        if (!terminal[j]) continue;
+        for (k=0; k<3; k++) {
+            id = "radio"+j+"_"+k;
+            r = document.getElementById(id);
+            //console.log(id, r);
+            r.checked= (k == 1-radio_value[j]);
+        }
+    }
+
+    var font_size = [120, 108, 92, 80];
+    var indent = [0, 1.3, 2.6, 3.9];
+    var button_indent = 1.0;
+
+    // -1: show "contract" button
+    // 0: no button
+    // 1: show "expand" button
+    //
+    function set_expand(k, val) {
+        //console.log('set_expand ', k, val);
+        var level = levels[k];
+        var x = '<span style="font-size:'+font_size[level]+'%">';
+        x += '<span style="padding-left:'+indent[level]+'em"/>';
+        if (val < 0) {
+            x += '<a onclick="expand_contract('+k+')" id="t'+k+'" href=#/>&boxminus;</a> ';
+        } else if (val == 0) {
+            x += '<span style="padding-left:'+button_indent+'em"/>';
+        } else {
+            x += '<a onclick="expand_contract('+k+')" id="t'+k+'" href=#/>&boxplus;</a> ';
+        }
+        x += names[k];
+        texts[k].innerHTML = x;
+    }
+
+    function radio(k, val) {
+        //console.log('radio ', k, val, parent[k]);
+        old_val = radio_value[k];
+        radio_value[k] = val;
+        inc = 0;
+        if (val && !old_val) inc = 1;
+        if (!val && old_val) inc = -1;
+        if (inc) {
+            while (1) {
+                k = parent[k];
+                if (!k) break;
+                nprefs[k] += inc;
+            }
+        }
+        render();
+    }
+
+    // click on expand/contract link
+    //
+    function expand_contract(k) {
+        expanded[k] = !expanded[k];
+        set_expand(k, expanded[k]?-1:1);
+        var h = expanded[k]?false:true;
+        //console.log('expand_contract ', k, h);
+        render();
+        return false;
+    }
+
+    // return true if all ancestrors of i are expanded or nprefs>0
+    //
+    function ancestors_expanded(i) {
+        while (1) {
+            i = parent[i];
+            if (!i) break;
+            if (!nprefs[i] && !expanded[i]) return false;
+        }
+        return true;
+    }
+
+    function render() {
+        for (i=0; i<nkws; i++) {
+            j = ids[i];
+            if (terminal[j]) {
+                set_expand(j, 0);
+                if (nprefs[parent[j]]>0 || ancestors_expanded(j)) {
+                    rows[j].hidden = false;
+                } else {
+                    rows[j].hidden = true;
+                }
+            } else {
+                //console.log("nprefs ", j, nprefs[j]);
+                if (nprefs[j]) {
+                    expanded[j] = true;
+                    rows[j].hidden = false;
+                    set_expand(j, 0);
+                } else {
+                    p = parent[j];
+                    if (p) {
+                        if (nprefs[parent[j]]>0 || ancestors_expanded(j)) {
+                            rows[j].hidden = false;
+                            set_expand(j, expanded[j]?-1:1);
+                        } else {
+                            rows[j].hidden = true;
+                        }
+                    } else {
+                        rows[j].hidden = false;
+                        set_expand(j, expanded[j]?-1:1);
+                    }
+                }
+            }
+        }
+    }
+
+    render();
+
+EOT;
+    echo "</script>\n";
+}
+
+function prefs_edit_form($user, $show_saved) {
+    global $job_keywords;
+
+    [$yes, $no] = read_kw_prefs($user);
+
+    // convert user prefs to a map id=>-1/0/1
+    //
+    $uprefs = array();
+    foreach ($job_keywords as $id=>$kw) {
+        $uprefs[$id] = 0;
+    }
+    foreach ($no as $id) {
+        $uprefs[$id] = KW_NO;
+    }
+    foreach ($yes as $id) {
+        $uprefs[$id] = KW_YES;
+    }
+
+    keyword_setup($uprefs);
+
+    page_head(tra("Science and location preferences"));
+
+    if ($show_saved) {
+        echo '<span class="text-success">'.tra("Preferences saved.").'</span><p><p>';
+    }
+
+    echo tra("Select science areas and locations you do or don't want to support.  Click %1 for more detail, %2 for less.  When done, click the Save button at the bottom.",
+        "<a>&boxplus;</a>",
+        "<a>&boxminus;</a>"
+    );
+    echo "
+        <p>
+    ";
+    form_start("kw_prefs.php");
+    form_input_hidden('action', 'submit');
+    echo "<h3>".tra("Science areas")."</h3>\n";
+    start_table("table-striped");
+    generate_html_category(KW_CATEGORY_SCIENCE, $uprefs);
+    end_table();
+    echo "<h3>".tra("Locations")."</h3>\n";
+    start_table("table-striped");
+    generate_html_category(KW_CATEGORY_LOC, $uprefs);
+    end_table();
+    echo sprintf(
+        '<button type="submit" %s class="btn">%s</button>',
+        button_style(),
+        tra("Save")
+    );
+    form_end();
+    generate_javascript($uprefs);
+    page_tail();
+}
+
+function prefs_edit_action($user) {
+    global $job_keywords;
+    $yes = [];
+    $no = [];
+    foreach ($job_keywords as $id=>$kw) {
+        $name = "radio$id";
+        $val = get_int($name, true);
+        switch ($val) {
+        case KW_NO:
+            $no[] = $id;
+            break;
+        case KW_YES:
+            $yes[] = $id;
+            break;
+        }
+    }
+    write_kw_prefs($user, $yes, $no);
+}
+
+$user = get_logged_in_user();
+$action = get_str('action', true);
+
+if ($action == "submit") {
+    prefs_edit_action($user);
+    prefs_edit_form($user, true);
+} else {
+    prefs_edit_form($user, false);
+}
+
+?>
diff --git a/html/user/manage_project.php b/html/user/manage_project.php
index d4e5a1ae593..8110d4a5933 100644
--- a/html/user/manage_project.php
+++ b/html/user/manage_project.php
@@ -23,6 +23,8 @@
 
 require_once("../inc/submit_db.inc");
 require_once("../inc/util.inc");
+require_once("../inc/keywords.inc");
+require_once("../inc/kw_prefs.inc");
 
 function user_row($u) {
     $user = BoincUser::lookup_id($u->user_id);
@@ -49,14 +51,22 @@ function user_row($u) {
         }
         $admin = $names?implode(', ', $names):'---';
     }
+    [$yes, $no] = read_kw_prefs($user);
+    global $job_keywords;
+    $kws = [];
+    foreach ($yes as $id) {
+        $kw = $job_keywords[$id];
+        $kws[] = $kw->name;
+    }
 
     table_row(
         sprintf(
-            '<a href=manage_project.php?action=edit_form&user_id=%d>%s</a>(ID: %d)',
-            $u->user_id, $user->name, $user->id
+            '<a href=manage_project.php?action=edit_form&user_id=%d>%s</a>',
+            $u->user_id, $user->name
         ),
         $sub,
         $admin,
+        implode('<br>', $kws),
         $u->quota,
         $u->max_jobs_in_progress,
         ($u->logical_start_time > time())?local_time_str($u->logical_start_time):'---'
@@ -75,6 +85,7 @@ function handle_list() {
         "User<br><small>Click to change permissions or quota</small>",
         "Can submit jobs to",
         "Can administer apps for",
+        'Keywords',
         "Quota",
         "Max jobs in progress<br><small>0 means no limit</small>",
         "Current priority<br><small>Later time = lower priority</small>"
diff --git a/html/user/workunit.php b/html/user/workunit.php
index 17410b83488..c8adfd2b136 100644
--- a/html/user/workunit.php
+++ b/html/user/workunit.php
@@ -30,16 +30,11 @@ function keyword_string($kwds) {
 
     $ks = explode(" ", $kwds);
     $first = true;
-    $x = "";
+    $x = [];
     foreach ($ks as $k) {
-        if ($first) {
-            $first = false;
-        } else {
-            $x .= ", ";
-        }
-        $x .= $job_keywords[$k]->name;
+        $x[] = $job_keywords[$k]->name;
     }
-    return $x;
+    return implode('<br>', $x);
 }
 
 function show_wu($wu) {
diff --git a/lib/keyword.cpp b/lib/keyword.cpp
index 2a3f20b383f..730511d580d 100644
--- a/lib/keyword.cpp
+++ b/lib/keyword.cpp
@@ -18,6 +18,11 @@
 // utility functions for keywords
 
 #include <stdio.h>
+#include <iostream>
+#include <string>
+#include <sstream>
+
+using namespace std;
 
 #include "parse.h"
 #include "keyword.h"
@@ -102,14 +107,13 @@ void USER_KEYWORDS::write(FILE* f) {
     boinc::fprintf(f, "</user_keywords>\n");
 }
 
-void JOB_KEYWORD_IDS::parse_str(char* buf) {
-    char* p = strtok(buf, " ");
-    if (!p) return;
-    ids.push_back(atoi(p));
-    while (true) {
-        p = strtok(NULL, " ");
-        if (!p) break;
-        ids.push_back(atoi(p));
+// parse space-separated string into vector
+//
+void JOB_KEYWORD_IDS::parse_str(char* p) {
+    stringstream ss(p);
+    string token;
+    while (getline(ss, token, ' ')) {
+        ids.push_back(atoi(token.c_str()));
     }
 }
 
diff --git a/sched/sched_keyword.cpp b/sched/sched_keyword.cpp
index 16cb026f044..8398f11aa66 100644
--- a/sched/sched_keyword.cpp
+++ b/sched/sched_keyword.cpp
@@ -23,14 +23,57 @@
 
 #include <algorithm>
 #include <iterator>
+#include <iostream>
+#include <string>
+#include <sstream>
+using namespace std;
 
+#include "filesys.h"
 #include "sched_main.h"
-#include "keyword.h"
+
+#include "sched_keyword.h"
 
 JOB_KEYWORD_IDS *job_keywords_array;
 
-// compute the score increment for the given job and user keywords
-// (or -1 if the keywords are incompatible)
+// read user's KW prefs from file; return -1 if none
+//
+static void parse_kw_line(const char *buf, vector<int> &kws) {
+    stringstream ss(buf);
+    string item;
+    while (getline(ss, item, ' ')) {
+        kws.push_back(atoi(item.c_str()));
+    }
+}
+
+int read_kw_prefs(int userid, USER_KEYWORDS &ukws) {
+    char path[256], buf[512];
+    snprintf(path, sizeof(path), "../kw_prefs/%d", userid);
+    if (!boinc_file_exists(path)) return -1;
+    FILE *f = boinc::fopen(path, "r");
+    if (!f) return -1;
+    if (config.debug_keyword) {
+        log_messages.printf(MSG_NORMAL,
+            "[keyword] reading user kw prefs from file\n"
+        );
+    }
+    ukws.clear();
+    int ret = 0;
+    if (boinc::fgets(buf, sizeof(buf), f)) {
+        parse_kw_line(buf, ukws.yes);
+    } else {
+        ret = 1;
+    }
+    if (boinc::fgets(buf, sizeof(buf), f)) {
+        parse_kw_line(buf, ukws.no);
+    } else {
+        ret = 1;
+    }
+    boinc::fclose(f);
+    return ret;
+}
+
+// compute the score increment for the given job and user keywords,
+// or -1 if user has "no" for one of the job keywords
 //
 double keyword_score_aux(
     USER_KEYWORDS& uks, JOB_KEYWORD_IDS& jks
@@ -42,6 +85,9 @@ double keyword_score_aux(
         if (std::find(uks.yes.begin(), uks.yes.end(), jk) != uks.yes.end()) {
             score += 1;
         } else if (std::find(uks.no.begin(), uks.no.end(), jk) != uks.no.end()) {
+            add_no_work_message(
+                "Some jobs are ruled out by your science/location preferences"
+            );
             return -1;
         }
     }
@@ -64,7 +110,7 @@ double keyword_score(int i) {
     JOB_KEYWORD_IDS& jk = job_keywords_array[i];
     if (jk.empty()) {
         WU_RESULT& wr = ssp->wu_results[i];
-        if (empty(wr.workunit.keywords)) {
+        if (!strlen(wr.workunit.keywords)) {
             if (config.debug_keyword) {
                 log_messages.printf(MSG_NORMAL, "[keyword] job has no keywords; returning 0\n");
             }
diff --git a/sched/sched_keyword.h b/sched/sched_keyword.h
index 5814e575b18..565999c24ff 100644
--- a/sched/sched_keyword.h
+++ b/sched/sched_keyword.h
@@ -18,8 +18,9 @@
 #ifndef BOINC_SCHED_KEYWORD_H
 #define BOINC_SCHED_KEYWORD_H
 
-// see sched_keywork.cpp
+#include "keyword.h"
 
+extern int read_kw_prefs(int user_id, USER_KEYWORDS&);
 extern double keyword_score(int);
 extern void keyword_sched_remove_job(int index);
 extern void keyword_sched_init();
diff --git a/sched/sched_score.cpp b/sched/sched_score.cpp
index a70282ada72..e008e31bf0d 100644
--- a/sched/sched_score.cpp
+++ b/sched/sched_score.cpp
@@ -393,6 +393,11 @@ void send_work_score_type(int rt) {
 }
 
 void send_work_score() {
+    if (config.keyword_sched) {
+        if (g_request->user_keywords.empty()) {
+            read_kw_prefs(g_request->user_id, g_request->user_keywords);
+        }
+    }
     for (int i=NPROC_TYPES-1; i>= 0; i--) {
         if (g_wreq->need_proc_type(i)) {
             send_work_score_type(i);
diff --git a/sched/sched_send.cpp b/sched/sched_send.cpp
index 4a5fab0b288..7cab66166a4 100644
--- a/sched/sched_send.cpp
+++ b/sched/sched_send.cpp
@@ -566,6 +566,11 @@ static int insert_wu_tags(WORKUNIT& wu, APP& app) {
             wu.keywords
         );
         strcat(buf, buf2);
+        if (config.debug_keyword) {
+            log_messages.printf(MSG_NORMAL,
+                "[keyword] keywords: %s\n", wu.keywords
+            );
+        }
     }
     return insert_after(wu.xml_doc, "<workunit>\n", buf);
 }
diff --git a/sched/sched_shmem.cpp b/sched/sched_shmem.cpp
index bdf6bd89cf3..d825bfd3435 100644
--- a/sched/sched_shmem.cpp
+++ b/sched/sched_shmem.cpp
@@ -413,7 +413,7 @@ void SCHED_SHMEM::show(FILE* f) {
                 "WU ID",
                 "result ID",
                 "batch",
-                "HR class",
+                "kwds",
                 "priority",
                 "in shmem",
                 "size class",
@@ -432,13 +432,13 @@ void SCHED_SHMEM::show(FILE* f) {
             appname = app?app->name:"missing";
             delta_t = dtime() - wu_result.time_added_to_shared_memory;
             boinc::fprintf(f,
-                "%4d %12.12s %10lu %10lu %10d %8d %10d %7ds %9d %12s %9d %9d\n",
+                "%4d %12.12s %10lu %10lu %10d %8s %10d %7ds %9d %12s %9d %9d\n",
                 i,
                 appname,
                 wu_result.workunit.id,
                 wu_result.resultid,
                 wu_result.workunit.batch,
-                wu_result.workunit.hr_class,
+                wu_result.workunit.keywords,
                 wu_result.res_priority,
                 delta_t,
                 wu_result.workunit.size_class,
diff --git a/tools/create_work.cpp b/tools/create_work.cpp
index cbd56ab1aad..aa472800adc 100644
--- a/tools/create_work.cpp
+++ b/tools/create_work.cpp
@@ -489,10 +489,10 @@ int main(int argc, char** argv) {
             int _argc;
             char* _argv[100], value_buf[MAX_QUERY_LEN];
 
-            char sub_appname_buf[256];
-            sub_appname_buf[0] = 0;
+            char additional_xml[256];
+            additional_xml[0] = 0;
             if (strlen(jd.sub_appname)) {
-                snprintf(sub_appname_buf, sizeof(sub_appname_buf),
+                snprintf(additional_xml, sizeof(additional_xml),
                     "   <sub_appname>%s</sub_appname>",
                     jd.sub_appname
                 );
@@ -533,7 +533,7 @@ int main(int argc, char** argv) {
                     jd2.infiles,
                     config,
                     jd2.command_line,
-                    sub_appname_buf,
+                    additional_xml,
                     value_buf
                 );
                 if (retval) {
@@ -590,14 +590,16 @@ int main(int argc, char** argv) {
     boinc_db.close();
 }
 
+// create a single job
+//
 void JOB_DESC::create() {
     if (assign_flag) {
         wu.transitioner_flags = assign_multi?TRANSITION_NONE:TRANSITION_NO_NEW_RESULTS;
     }
-    char sub_appname_buf[256];
-    sub_appname_buf[0] = 0;
+    char additional_xml[256], kwbuf[256];
+    additional_xml[0] = 0;
     if (strlen(sub_appname)) {
-        snprintf(sub_appname_buf, sizeof(sub_appname_buf),
+        snprintf(additional_xml, sizeof(additional_xml),
             "   <sub_appname>%s</sub_appname>",
             sub_appname
         );
@@ -610,7 +612,7 @@ void JOB_DESC::create() {
         infiles,
         config,
         command_line,
-        sub_appname_buf
+        additional_xml
     );
     if (retval) {
         fprintf(stderr, "create_work: %s\n", boincerror(retval));


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR adds support for user-defined keyword preferences across web, scheduler, and job-creation tools, and integrates those preferences into BOINC project and BUDA scheduling.

- Extend `create_work` to accept and embed keyword XML snippets.  
- Modify scheduler components (`sched_*`) to propagate and honor user keywords in job scoring and display.  
- Introduce PHP pages and includes (`kw_prefs.php`, `kw_prefs.inc`) for users to set science/location keyword preferences, and wire them into project management and BUDA submission.

### Reviewed Changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.

<details>
<summary>Show a summary per file</summary>

| File                         | Description                                       |
| ---------------------------- | ------------------------------------------------- |
| tools/create_work.cpp        | Renamed sub_appname buffer, added `additional_xml` argument for keywords |
| sched/sched_shmem.cpp        | Added “kwds” column for keywords in shared-mem display |
| sched/sched_send.cpp         | Debug log output of `wu.keywords` when enabled    |
| sched/sched_score.cpp        | Load user keyword prefs before scoring            |
| sched/sched_keyword.h        | Declare `read_kw_prefs` and include `keyword.h`   |
| sched/sched_keyword.cpp      | Implement `read_kw_prefs` and switch to stringstream parsing |
| lib/keyword.cpp              | Replace `strtok` with stringstream in `JOB_KEYWORD_IDS::parse_str` |
| html/user/workunit.php       | Render job keyword names with `<br>` separators   |
| html/user/manage_project.php | Display user keywords in project management table |
| html/user/kw_prefs.php       | Add full page for editing keyword preferences     |
| html/user/buda_submit.php    | Pass combined user/app keywords to `create_jobs`  |
| html/user/buda.php           | Update BUDA app listing headings                  |
| html/inc/user.inc            | Link to keyword preferences in user prefs menu    |
| html/inc/kw_prefs.inc        | New include for reading/writing user keyword prefs |
</details>



<details>
<summary>Comments suppressed due to low confidence (2)</summary>

**sched/sched_shmem.cpp:416**
* [nitpick] The column header `kwds` is abbreviated and may be unclear; consider using `keywords` for clarity.
```
"kwds",
```
**html/user/manage_project.php:64**
* The user ID (`ID: %d`) was removed from the link text, potentially confusing administrators who need to reference the numeric ID. Consider restoring the ID display or updating the UI to show it elsewhere.
```
'<a href=manage_project.php?action=edit_form&user_id=%d>%s</a>',
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T21:28:11Z

Review comments:
diff_hunk: @@ -489,10 +489,10 @@ int main(int argc, char** argv) {
             int _argc;
             char* _argv[100], value_buf[MAX_QUERY_LEN];
 
-            char sub_appname_buf[256];
-            sub_appname_buf[0] = 0;
+            char additional_xml[256], kwbuf[256];
path: tools/create_work.cpp
user: login: Copilot
body: The variable `kwbuf` is declared but never used; consider removing it to avoid dead code.
```suggestion
            char additional_xml[256];
```
created_at: 2025-05-20T21:28:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -30,16 +30,11 @@ function keyword_string($kwds) {
 
     $ks = explode(" ", $kwds);
     $first = true;
-    $x = "";
+    $x = [];
     foreach ($ks as $k) {
-        if ($first) {
-            $first = false;
-        } else {
-            $x .= ", ";
-        }
-        $x .= $job_keywords[$k]->name;
+        $x[] = $job_keywords[$k]->name;
     }
-    return $x;
+    return implode('<br>', $x);;
path: html/user/workunit.php
user: login: Copilot
body: There is a redundant semicolon (`;;`) after the `return` statement; remove the extra semicolon.
```suggestion
    return implode('<br>', $x);
```
created_at: 2025-05-20T21:28:11Z
author_association: CONTRIBUTOR

PR comments:
user: login: AenBleidd
created_at: 2025-05-20T22:43:50Z
body: @davidpanderson, please check and fix build errors (most of them are about FCGI, but logs are too big to be copied here)
author_association: MEMBER
user: login: codecov[bot]
created_at: 2025-05-20T23:22:57Z
body: ## [Codecov](https://app.codecov.io/gh/BOINC/boinc/pull/6338?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC) Report
Attention: Patch coverage is `0%` with `39 lines` in your changes missing coverage. Please review.
> Project coverage is 11.93%. Comparing base [(`4d3734a`)](https://app.codecov.io/gh/BOINC/boinc/commit/4d3734ad923a21a8b46cb584aae2a94f3a23d69c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC) to head [(`f613775`)](https://app.codecov.io/gh/BOINC/boinc/commit/f6137751bcf9147a0f57666e4e645455861f6b3a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC).
> Report is 39 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/BOINC/boinc/pull/6338?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC) | Patch % | Lines |
|---|---|---|
| [sched/sched\_keyword.cpp](https://app.codecov.io/gh/BOINC/boinc/pull/6338?src=pr&el=tree&filepath=sched%2Fsched_keyword.cpp&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC#diff-c2NoZWQvc2NoZWRfa2V5d29yZC5jcHA=) | 0.00% | [23 Missing :warning: ](https://app.codecov.io/gh/BOINC/boinc/pull/6338?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC) |
| [lib/keyword.cpp](https://app.codecov.io/gh/BOINC/boinc/pull/6338?src=pr&el=tree&filepath=lib%2Fkeyword.cpp&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC#diff-bGliL2tleXdvcmQuY3Bw) | 0.00% | [5 Missing :warning: ](https://app.codecov.io/gh/BOINC/boinc/pull/6338?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC) |
| [tools/create\_work.cpp](https://app.codecov.io/gh/BOINC/boinc/pull/6338?src=pr&el=tree&filepath=tools%2Fcreate_work.cpp&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC#diff-dG9vbHMvY3JlYXRlX3dvcmsuY3Bw) | 0.00% | [4 Missing :warning: ](https://app.codecov.io/gh/BOINC/boinc/pull/6338?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC) |
| [sched/sched\_score.cpp](https://app.codecov.io/gh/BOINC/boinc/pull/6338?src=pr&el=tree&filepath=sched%2Fsched_score.cpp&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC#diff-c2NoZWQvc2NoZWRfc2NvcmUuY3Bw) | 0.00% | [3 Missing :warning: ](https://app.codecov.io/gh/BOINC/boinc/pull/6338?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC) |
| [sched/sched\_send.cpp](https://app.codecov.io/gh/BOINC/boinc/pull/6338?src=pr&el=tree&filepath=sched%2Fsched_send.cpp&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC#diff-c2NoZWQvc2NoZWRfc2VuZC5jcHA=) | 0.00% | [3 Missing :warning: ](https://app.codecov.io/gh/BOINC/boinc/pull/6338?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC) |
| [sched/sched\_shmem.cpp](https://app.codecov.io/gh/BOINC/boinc/pull/6338?src=pr&el=tree&filepath=sched%2Fsched_shmem.cpp&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC#diff-c2NoZWQvc2NoZWRfc2htZW0uY3Bw) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/BOINC/boinc/pull/6338?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff              @@
##             master    #6338      +/-   ##
============================================
- Coverage     11.93%   11.93%   -0.01%     
  Complexity     1068     1068              
============================================
  Files           278      278              
  Lines         36985    37012      +27     
  Branches       8544     8552       +8     
============================================
  Hits           4416     4416              
- Misses        32168    32195      +27     
  Partials        401      401              
```

| [Files with missing lines](https://app.codecov.io/gh/BOINC/boinc/pull/6338?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC) | Coverage Δ | |
|---|---|---|
| [sched/sched\_shmem.cpp](https://app.codecov.io/gh/BOINC/boinc/pull/6338?src=pr&el=tree&filepath=sched%2Fsched_shmem.cpp&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC#diff-c2NoZWQvc2NoZWRfc2htZW0uY3Bw) | `0.00% <0.00%> (ø)` | |
| [sched/sched\_score.cpp](https://app.codecov.io/gh/BOINC/boinc/pull/6338?src=pr&el=tree&filepath=sched%2Fsched_score.cpp&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC#diff-c2NoZWQvc2NoZWRfc2NvcmUuY3Bw) | `0.00% <0.00%> (ø)` | |
| [sched/sched\_send.cpp](https://app.codecov.io/gh/BOINC/boinc/pull/6338?src=pr&el=tree&filepath=sched%2Fsched_send.cpp&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC#diff-c2NoZWQvc2NoZWRfc2VuZC5jcHA=) | `0.00% <0.00%> (ø)` | |
| [tools/create\_work.cpp](https://app.codecov.io/gh/BOINC/boinc/pull/6338?src=pr&el=tree&filepath=tools%2Fcreate_work.cpp&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC#diff-dG9vbHMvY3JlYXRlX3dvcmsuY3Bw) | `0.00% <0.00%> (ø)` | |
| [lib/keyword.cpp](https://app.codecov.io/gh/BOINC/boinc/pull/6338?src=pr&el=tree&filepath=lib%2Fkeyword.cpp&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC#diff-bGliL2tleXdvcmQuY3Bw) | `0.00% <0.00%> (ø)` | |
| [sched/sched\_keyword.cpp](https://app.codecov.io/gh/BOINC/boinc/pull/6338?src=pr&el=tree&filepath=sched%2Fsched_keyword.cpp&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC#diff-c2NoZWQvc2NoZWRfa2V5d29yZC5jcHA=) | `0.00% <0.00%> (ø)` | |

... and [5 files with indirect coverage changes](https://app.codecov.io/gh/BOINC/boinc/pull/6338/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BOINC)

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE