New FileHelp system to create help entries from external files

This commit is contained in:
Griatch 2021-05-08 14:03:50 +02:00
parent 8a7e19db16
commit f5fd398480
10 changed files with 432 additions and 290 deletions

View file

@ -8,229 +8,53 @@ creation of other help topics such as RP help or game-world aides.
"""
import re
from dataclasses import dataclass
from django.conf import settings
from collections import defaultdict
from evennia.utils.utils import fill, dedent
from evennia.help.models import HelpEntry
from evennia.utils import create, evmore
from evennia.utils.ansi import ANSIString
from evennia.help.filehelp import FILE_HELP_ENTRIES
from evennia.utils.eveditor import EvEditor
from evennia.utils.utils import (
class_from_module,
inherits_from,
format_grid, pad
)
from evennia.help.utils import help_search_with_index, parse_entry_for_subcategories
COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
HELP_MORE = settings.HELP_MORE
CMD_IGNORE_PREFIXES = settings.CMD_IGNORE_PREFIXES
_RE_HELP_SUBTOPICS_START = re.compile(
r"^\s*?#\s*?subtopics\s*?$", re.I + re.M)
_RE_HELP_SUBTOPIC_SPLIT = re.compile(r"^\s*?(\#{2,6}\s*?\w+?[a-z0-9 \-\?!,\.]*?)$", re.M + re.I)
_RE_HELP_SUBTOPIC_PARSE = re.compile(
r"^(?P<nesting>\#{2,6})\s*?(?P<name>.*?)$", re.I + re.M)
MAX_SUBTOPIC_NESTING = 5
# limit symbol import for API
__all__ = ("CmdHelp", "CmdSetHelp")
_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
_SEP = "|C" + "-" * _DEFAULT_WIDTH + "|n"
_LUNR = None
_LUNR_EXCEPTION = None
@dataclass
class HelpCategory:
def __init__(self, key):
self.key = key
"""
Mock 'help entry' to search categories with the same code.
"""
key: str
@property
def search_index_entry(self):
return {
"key": str(self),
"key": self.key,
"aliases": "",
"category": self.key,
"tags": "",
"text": "",
}
def __str__(self):
return f"Category: {self.key}"
def __eq__(self, other):
return str(self).lower() == str(other).lower()
def __hash__(self):
return id(self)
def help_search_with_index(query, candidate_entries, suggestion_maxnum=5):
"""
Lunr-powered fast index search and suggestion wrapper
"""
global _LUNR, _LUNR_EXCEPTION
if not _LUNR:
# we have to delay-load lunr because it messes with logging if it's imported
# before twisted's logging has been set up
from lunr import lunr as _LUNR
from lunr.exceptions import QueryParseError as _LUNR_EXCEPTION
indx = [cnd.search_index_entry for cnd in candidate_entries]
mapping = {indx[ix]["key"]: cand for ix, cand in enumerate(candidate_entries)}
search_index = _LUNR(
ref="key",
fields=[
{"field_name": "key", "boost": 10},
{"field_name": "aliases", "boost": 9},
{"field_name": "category", "boost": 8},
{"field_name": "tags", "boost": 5},
{"field_name": "text", "boost": 1},
],
documents=indx,
)
try:
matches = search_index.search(query)[:suggestion_maxnum]
except _LUNR_EXCEPTION:
# this is a user-input problem
matches = []
# matches (objs), suggestions (strs)
return (
[mapping[match["ref"]] for match in matches],
[str(match["ref"]) for match in matches], # + f" (score {match['score']})") # good debug
)
def parse_entry_for_subcategories(entry):
"""
Parse a command docstring for special sub-category blocks:
Args:
entry (str): A help entry to parse
Returns:
dict: The dict is a mapping that splits the entry into subcategories. This
will always hold a key `None` for the main help entry and
zero or more keys holding the subcategories. Each is itself
a dict with a key `None` for the main text of that subcategory
followed by any sub-sub-categories down to a max-depth of 5.
Example:
::
'''
Main topic text
# SUBTOPICS
## foo
A subcategory of the main entry, accessible as `help topic foo`
(or using /, like `help topic/foo`)
## bar
Another subcategory, accessed as `help topic bar`
(or `help topic/bar`)
### moo
A subcategory of bar, accessed as `help bar moo`
(or `help bar/moo`)
#### dum
A subcategory of moo, accessed `help bar moo dum`
(or `help bar/moo/dum`)
'''
This will result in this returned entry structure:
::
{
None: "Main topic text":
"foo": {
None: "main topic/foo text"
},
"bar": {
None: "Main topic/bar text",
"moo": {
None: "topic/bar/moo text"
"dum": {
None: "topic/bar/moo/dum text"
}
}
}
}
Apart from making
sub-categories at the bottom of the entry.
This will be applied both to command docstrings and database-based help
entries.
"""
topic, *subtopics = _RE_HELP_SUBTOPICS_START.split(entry, maxsplit=1)
structure = {None: topic.strip()}
if subtopics:
subtopics = subtopics[0]
else:
return structure
keypath = []
current_nesting = 0
subtopic = None
# from evennia import set_trace;set_trace()
for part in _RE_HELP_SUBTOPIC_SPLIT.split(subtopics.strip()):
subtopic_match = _RE_HELP_SUBTOPIC_PARSE.match(part.strip())
if subtopic_match:
# a new sub(-sub..) category starts.
mdict = subtopic_match.groupdict()
subtopic = mdict['name'].lower().strip()
new_nesting = len(mdict['nesting']) - 1
if new_nesting > MAX_SUBTOPIC_NESTING:
raise RuntimeError(
f"Can have max {MAX_SUBTOPIC_NESTING} levels of nested help subtopics.")
nestdiff = new_nesting - current_nesting
if nestdiff < 0:
# jumping back up in nesting
for _ in range(abs(nestdiff) + 1):
try:
keypath.pop()
except IndexError:
pass
elif nestdiff == 0:
# don't add a deeper nesting but replace the current
try:
keypath.pop()
except IndexError:
pass
keypath.append(subtopic)
current_nesting = new_nesting
else:
# an entry belonging to a subtopic - find the nested location
dct = structure
if not keypath and subtopic is not None:
structure[subtopic] = dedent(part.strip())
else:
for key in keypath:
if key in dct:
dct = dct[key]
else:
dct[key] = {
None: dedent(part.strip())
}
return structure
return hash(id(self))
class CmdHelp(COMMAND_DEFAULT_CLASS):
@ -329,7 +153,7 @@ class CmdHelp(COMMAND_DEFAULT_CLASS):
else:
aliases = ''
help_text = f"\n\n{dedent(' ' + help_text.strip())}\n" if help_text else ""
help_text = "\n\n" + dedent(help_text.strip(), indent=0) + "\n" if help_text else ""
if subtopics:
subtopics = (
@ -503,11 +327,17 @@ class CmdHelp(COMMAND_DEFAULT_CLASS):
# having to allow doublet commands to manage exits etc.
cmdset.make_unique(caller)
# retrieve all available commands and database topics
# retrieve all available commands and database / file-help topics
all_cmds = [cmd for cmd in cmdset if self.check_show_help(cmd, caller)]
all_db_topics = [
topic for topic in HelpEntry.objects.all() if topic.access(caller, "view", default=True)
]
# we group the file-help topics with the db ones, giving the db ones priority
file_help_topics = FILE_HELP_ENTRIES.all(return_dict=True)
db_topics = {
topic.key.lower().strip(): topic for topic in HelpEntry.objects.all()
if topic.access(caller, "view", default=True)
}
all_db_topics = list({**file_help_topics, **db_topics}.values())
all_categories = list(set(
[HelpCategory(cmd.help_category) for cmd in all_cmds]
+ [HelpCategory(topic.help_category) for topic in all_db_topics]
@ -539,7 +369,7 @@ class CmdHelp(COMMAND_DEFAULT_CLASS):
# Lunr search engine
# all available options
entries = [cmd for cmd in all_cmds if cmd] + list(HelpEntry.objects.all()) + all_categories
entries = [cmd for cmd in all_cmds if cmd] + all_db_topics + all_categories
match, suggestions = None, None
for match_query in [f"{query}~1", f"{query}*"]:
@ -590,10 +420,10 @@ class CmdHelp(COMMAND_DEFAULT_CLASS):
aliases = match.aliases
suggested = suggestions[1:]
else:
# a database match
# a database (or file-help) match
topic = match.key
help_text = match.entrytext
aliases = match.aliases.all()
aliases = match.aliases if isinstance(match.aliases, list) else match.aliases.all()
suggested = suggestions[1:]
# parse for subtopics. The subtopic_map is a dict with the current topic/subtopic