590 lines
22 KiB
Python
590 lines
22 KiB
Python
"""
|
|
Specific functions working on the games.
|
|
"""
|
|
|
|
import re
|
|
import os
|
|
from difflib import SequenceMatcher
|
|
from utils import utils, constants as c
|
|
import lark
|
|
|
|
|
|
class ListingTransformer(lark.Transformer):
|
|
|
|
def number(self, x):
|
|
raise lark.Discard
|
|
|
|
def property(self, x):
|
|
return x[0].value.lower(), x[1].value
|
|
|
|
def name(self, x):
|
|
return 'name', x[0].value
|
|
|
|
def entry(self, x):
|
|
d = {}
|
|
for key, value in x:
|
|
d[key] = value
|
|
return d
|
|
|
|
def header(self, x):
|
|
raise lark.Discard
|
|
|
|
def start(self, x):
|
|
return x
|
|
|
|
|
|
# transformer
|
|
class EntryTransformer(lark.Transformer):
|
|
|
|
def start(self, x):
|
|
d = {}
|
|
for key, value in x:
|
|
d[key] = value
|
|
return d
|
|
|
|
def title(self, x):
|
|
return 'title', x[0].value
|
|
|
|
def description(self, x):
|
|
return 'description', x[0].value
|
|
|
|
def property(self, x):
|
|
return str.casefold(x[0].value), x[1].value
|
|
|
|
def note(self, x):
|
|
return 'note', x[0].value
|
|
|
|
def building(self, x):
|
|
d = {}
|
|
for key, value in x:
|
|
d[key] = value
|
|
return 'building', d
|
|
|
|
|
|
essential_fields = ('Home', 'State', 'Keywords', 'Code repository', 'Code language', 'Code license')
|
|
valid_fields = (
|
|
'Home', 'Media', 'State', 'Play', 'Download', 'Platform', 'Keywords', 'Code repository', 'Code language',
|
|
'Code license', 'Code dependencies', 'Assets license', 'Developer', 'Build system', 'Build instructions')
|
|
valid_platforms = ('Windows', 'Linux', 'macOS', 'Android', 'iOS', 'Web')
|
|
recommended_keywords = (
|
|
'action', 'arcade', 'adventure', 'visual novel', 'sports', 'platform', 'puzzle', 'role playing', 'simulation',
|
|
'strategy', 'cards', 'board', 'music', 'educational', 'tool', 'game engine', 'framework', 'library', 'remake')
|
|
known_languages = (
|
|
'AGS Script', 'ActionScript', 'Ada', 'AngelScript', 'Assembly', 'Basic', 'Blender Script', 'BlitzMax', 'C', 'C#',
|
|
'C++', 'Clojure', 'CoffeeScript', 'ColdFusion', 'D', 'DM', 'Dart', 'Dia', 'Elm', 'Emacs Lisp', 'F#', 'GDScript',
|
|
'Game Maker Script', 'Go', 'Groovy', 'Haskell', 'Haxe', 'Io', 'Java', 'JavaScript', 'Kotlin', 'Lisp', 'Lua',
|
|
'MegaGlest Script', 'MoonScript', 'None', 'OCaml', 'Objective-C', 'PHP', 'Pascal', 'Perl', 'Python', 'QuakeC', 'R',
|
|
"Ren'py", 'Ruby', 'Rust', 'Scala', 'Scheme', 'Script', 'Shell', 'Swift', 'TorqueScript', 'TypeScript', 'Vala',
|
|
'Visual Basic', 'XUL', 'ZenScript', 'ooc')
|
|
known_licenses = (
|
|
'2-clause BSD', '3-clause BSD', 'AFL-3.0', 'AGPL-3.0', 'Apache-2.0', 'Artistic License-1.0', 'Artistic License-2.0',
|
|
'Boost-1.0', 'CC-BY-NC-3.0', 'CC-BY-NC-SA-2.0', 'CC-BY-NC-SA-3.0', 'CC-BY-SA-3.0', 'CC-BY-NC-SA-4.0',
|
|
'CC-BY-SA-4.0',
|
|
'CC0', 'Custom', 'EPL-2.0', 'GPL-2.0', 'GPL-3.0', 'IJG', 'ISC', 'Java Research License', 'LGPL-2.0', 'LGPL-2.1',
|
|
'LGPL-3.0', 'MAME', 'MIT', 'MPL-1.1', 'MPL-2.0', 'MS-PL', 'MS-RL', 'NetHack General Public License', 'None',
|
|
'Proprietary', 'Public domain', 'SWIG license', 'Unlicense', 'WTFPL', 'wxWindows license', 'zlib')
|
|
known_multiplayer_modes = (
|
|
'competitive', 'co-op', 'hotseat', 'LAN', 'local', 'massive', 'matchmaking', 'online', 'split-screen')
|
|
|
|
# TODO put the abbreviations directly in the name line (parenthesis maybe), that is more natural
|
|
code_dependencies_aliases = {'Simple DirectMedia Layer': ('SDL', 'SDL2'), 'Simple and Fast Multimedia Library': 'SFML',
|
|
'Boost (C++ Libraries)': 'Boost', 'SGE Game Engine': 'SGE'}
|
|
code_dependencies_without_entry = {'OpenGL': 'https://www.opengl.org/',
|
|
'GLUT': 'https://www.opengl.org/resources/libraries/',
|
|
'WebGL': 'https://www.khronos.org/webgl/',
|
|
'Unity': 'https://unity.com/solutions/game',
|
|
'.NET': 'https://dotnet.microsoft.com/', 'Vulkan': 'https://www.khronos.org/vulkan/',
|
|
'KDE Frameworks': 'https://kde.org/products/frameworks/'}
|
|
|
|
regex_sanitize_name = re.compile(r"[^A-Za-z 0-9-+]+")
|
|
regex_sanitize_name_space_eater = re.compile(r" +")
|
|
|
|
valid_developer_fields = ('name', 'games', 'contact', 'organization', 'home')
|
|
valid_inspiration_fields = ('name', 'inspired entries')
|
|
|
|
comment_string = '[comment]: # (partly autogenerated content, edit with care, read the manual before)'
|
|
|
|
|
|
def name_similarity(a, b):
|
|
return SequenceMatcher(None, str.casefold(a), str.casefold(b)).ratio()
|
|
|
|
|
|
def split_infos(infos):
|
|
"""
|
|
Split into games, tools, frameworks, libraries
|
|
"""
|
|
games = [x for x in infos if not any([y in x['keywords'] for y in ('tool', 'framework', 'library')])]
|
|
tools = [x for x in infos if 'tool' in x['keywords']]
|
|
frameworks = [x for x in infos if 'framework' in x['keywords']]
|
|
libraries = [x for x in infos if 'library' in x['keywords']]
|
|
return games, tools, frameworks, libraries
|
|
|
|
|
|
def entry_iterator():
|
|
"""
|
|
|
|
"""
|
|
|
|
# get all entries (ignore everything starting with underscore)
|
|
entries = os.listdir(c.entries_path)
|
|
|
|
# iterate over all entries
|
|
for entry in entries:
|
|
entry_path = os.path.join(c.entries_path, entry)
|
|
|
|
# ignore directories ("tocs" for example)
|
|
if os.path.isdir(entry_path):
|
|
continue
|
|
|
|
# read entry
|
|
content = utils.read_text(entry_path)
|
|
|
|
# yield
|
|
yield entry, entry_path, content
|
|
|
|
|
|
def canonical_entry_name(name):
|
|
"""
|
|
Derives a canonical game name from an actual game name (suitable for file names, ...)
|
|
"""
|
|
name = name.casefold()
|
|
name = name.replace('ö', 'o').replace('ä', 'a').replace('ü', 'u')
|
|
name = regex_sanitize_name.sub('', name)
|
|
name = regex_sanitize_name_space_eater.sub('_', name)
|
|
name = name.replace('_-_', '-')
|
|
name = name.replace('--', '-').replace('--', '-')
|
|
|
|
return name
|
|
|
|
|
|
def parse_entry(content):
|
|
"""
|
|
Returns a dictionary of the features of the content.
|
|
|
|
Raises errors when a major error in the structure is expected, prints a warning for minor errors.
|
|
"""
|
|
|
|
info = {}
|
|
|
|
# read name
|
|
regex = re.compile(r"^# (.*)") # start of content, starting with "# " and then everything until the end of line
|
|
matches = regex.findall(content)
|
|
if len(matches) != 1 or not matches[0]: # name must be there
|
|
raise RuntimeError('Name not found in entry "{}" : {}'.format(content, matches))
|
|
info['name'] = matches[0]
|
|
|
|
# read description
|
|
regex = re.compile(r"^.*\n\n_(.*)_\n") # third line from top, everything between underscores
|
|
matches = regex.findall(content)
|
|
if len(matches) != 1 or not matches[0]: # description must be there
|
|
raise RuntimeError('Description not found in entry "{}"'.format(content))
|
|
info['description'] = matches[0]
|
|
|
|
# first read all field names
|
|
regex = re.compile(r"^- (.*?): ",
|
|
re.MULTILINE) # start of each line having "- ", then everything until a colon, then ": "
|
|
fields = regex.findall(content)
|
|
|
|
# check that essential fields are there
|
|
for field in essential_fields:
|
|
if field not in fields: # essential fields must be there
|
|
raise RuntimeError('Essential field "{}" missing in entry "{}"'.format(field, info['name']))
|
|
|
|
# check that all fields are valid fields and are existing in that order
|
|
index = 0
|
|
for field in fields:
|
|
while index < len(valid_fields) and field != valid_fields[index]:
|
|
index += 1
|
|
if index == len(valid_fields): # must be valid fields and must be in the right order
|
|
raise RuntimeError(
|
|
'Field "{}" in entry "{}" either misspelled or in wrong order'.format(field, info['name']))
|
|
|
|
# iterate over found fields
|
|
for field in fields:
|
|
regex = re.compile(r"- {}: (.*)".format(field))
|
|
matches = regex.findall(content)
|
|
if len(matches) != 1: # every field must be present only once
|
|
raise RuntimeError('Field "{}" in entry "{}" exist multiple times.'.format(field, info['name']))
|
|
v = matches[0]
|
|
|
|
# first store as is
|
|
info[field.lower() + '-raw'] = v
|
|
|
|
# remove parenthesis with content
|
|
v = re.sub(r'\([^)]*\)', '', v)
|
|
|
|
# split on ', '
|
|
v = v.split(', ')
|
|
|
|
# strip
|
|
v = [x.strip() for x in v]
|
|
|
|
# remove all being false (empty) that were for example just comments
|
|
v = [x for x in v if x]
|
|
|
|
# if entry is of structure <..> remove <>
|
|
v = [x[1:-1] if x[0] == '<' and x[-1] == '>' else x for x in v]
|
|
|
|
# empty fields will not be stored
|
|
if not v:
|
|
continue
|
|
|
|
# store in info
|
|
info[field.lower()] = v
|
|
|
|
# check again that essential fields made it through
|
|
for field in ('home', 'state', 'keywords', 'code language', 'code license'):
|
|
if field not in info: # essential fields must still be inside
|
|
raise RuntimeError('Essential field "{}" empty in entry "{}"'.format(field, info['name']))
|
|
|
|
# now checks on the content of fields
|
|
|
|
# name and description should not have spaces at the begin or end
|
|
for field in ('name', 'description'):
|
|
v = info[field]
|
|
if len(v) != len(v.strip()): # warning about that
|
|
print('Warning: No leading or trailing spaces in field {} in entry "{}"'.format(field, info['name']))
|
|
|
|
# state (essential field) must contain either beta or mature but not both, but at least one
|
|
v = info['state']
|
|
for t in v:
|
|
if t != 'beta' and t != 'mature' and not t.startswith('inactive since '):
|
|
raise RuntimeError('Unknown state tage "{}" in entry "{}"'.format(t, info['name']))
|
|
if 'beta' in v != 'mature' in v:
|
|
raise RuntimeError('State must be one of <"beta", "mature"> in entry "{}"'.format(info['name']))
|
|
|
|
# extract inactive year
|
|
phrase = 'inactive since '
|
|
inactive_year = [x[len(phrase):] for x in v if x.startswith(phrase)]
|
|
assert len(inactive_year) <= 1
|
|
if inactive_year:
|
|
info['inactive'] = inactive_year[0]
|
|
|
|
# urls in home, download, play and code repositories must start with http or https (or git) and should not contain spaces
|
|
for field in ['home', 'download', 'play', 'code repository']:
|
|
if field in info:
|
|
for url in info[field]:
|
|
if not any(
|
|
[url.startswith(x) for x in ['http://', 'https://', 'git://', 'svn://', 'ftp://', 'bzr://']]):
|
|
raise RuntimeError(
|
|
'URL "{}" in entry "{}" does not start with http/https/git/svn/ftp/bzr'.format(url,
|
|
info['name']))
|
|
if ' ' in url:
|
|
raise RuntimeError('URL "{}" in entry "{}" contains a space'.format(url, info['name']))
|
|
|
|
# github/gitlab repositories should end on .git and should start with https
|
|
if 'code repository' in info:
|
|
for repo in info['code repository']:
|
|
if any((x in repo for x in ('github', 'gitlab', 'git.tuxfamily', 'git.savannah'))):
|
|
if not repo.startswith('https://'):
|
|
print('Warning: Repo {} in entry "{}" should start with https://'.format(repo, info['name']))
|
|
if not repo.endswith('.git'):
|
|
print('Warning: Repo {} in entry "{}" should end on .git.'.format(repo, info['name']))
|
|
|
|
# check that all platform tags are valid tags and are existing in that order
|
|
if 'platform' in info:
|
|
index = 0
|
|
for platform in info['platform']:
|
|
while index < len(valid_platforms) and platform != valid_platforms[index]:
|
|
index += 1
|
|
if index == len(valid_platforms): # must be valid platforms and must be in that order
|
|
raise RuntimeError(
|
|
'Platform tag "{}" in entry "{}" either misspelled or in wrong order'.format(platform,
|
|
info['name']))
|
|
|
|
# there must be at least one keyword
|
|
if 'keywords' not in info:
|
|
raise RuntimeError('Need at least one keyword in entry "{}"'.format(info['name']))
|
|
|
|
# check for existence of at least one recommended keywords
|
|
fail = True
|
|
for recommended_keyword in recommended_keywords:
|
|
if recommended_keyword in info['keywords']:
|
|
fail = False
|
|
break
|
|
if fail: # must be at least one recommended keyword
|
|
raise RuntimeError('Entry "{}" contains no recommended keyword'.format(info['name']))
|
|
|
|
# languages should be known
|
|
languages = info['code language']
|
|
for language in languages:
|
|
if language not in known_languages:
|
|
print('Warning: Language {} in entry "{}" is not a known language. Misspelled or new?'.format(language,
|
|
info['name']))
|
|
|
|
# licenses should be known
|
|
licenses = info['code license']
|
|
for license in licenses:
|
|
if license not in known_licenses:
|
|
print('Warning: License {} in entry "{}" is not a known license. Misspelled or new?'.format(license,
|
|
info['name']))
|
|
|
|
return info
|
|
|
|
|
|
def assemble_infos():
|
|
"""
|
|
Parses all entries and assembles interesting infos about them.
|
|
"""
|
|
|
|
print('assemble game infos')
|
|
|
|
# a database of all important infos about the entries
|
|
infos = []
|
|
|
|
# iterate over all entries
|
|
for entry, _, content in entry_iterator():
|
|
|
|
# parse entry
|
|
info = parse_entry(content)
|
|
|
|
# add file information
|
|
info['file'] = entry
|
|
|
|
# check canonical file name
|
|
canonical_file_name = canonical_entry_name(info['name']) + '.md'
|
|
# we also allow -X with X =2..9 as possible extension (because of duplicate canonical file names)
|
|
if canonical_file_name != entry and canonical_file_name != entry[:-5] + '.md':
|
|
print('Warning: file {} should be {}'.format(entry, canonical_file_name))
|
|
source_file = os.path.join(c.entries_path, entry)
|
|
target_file = os.path.join(c.entries_path, canonical_file_name)
|
|
if not os.path.isfile(target_file):
|
|
pass
|
|
# os.rename(source_file, target_file)
|
|
|
|
# add to list
|
|
infos.append(info)
|
|
|
|
return infos
|
|
|
|
|
|
def extract_links():
|
|
"""
|
|
Parses all entries and extracts http(s) links from them
|
|
"""
|
|
|
|
# regex for finding urls (can be in <> or in ]() or after a whitespace
|
|
regex = re.compile(r"[\s\n]<(http.+?)>|]\((http.+?)\)|[\s\n](http[^\s\n,]+?)[\s\n,]")
|
|
|
|
# iterate over all entries
|
|
urls = set()
|
|
for _, _, content in entry_iterator():
|
|
|
|
# apply regex
|
|
matches = regex.findall(content)
|
|
|
|
# for each match
|
|
for match in matches:
|
|
|
|
# for each possible clause
|
|
for url in match:
|
|
|
|
# if there was something (and not a sourceforge git url)
|
|
if url:
|
|
urls.add(url)
|
|
urls = sorted(list(urls), key=str.casefold)
|
|
return urls
|
|
|
|
|
|
def read_and_parse(content_file, grammar_file, transformer):
|
|
"""
|
|
|
|
:param content_file:
|
|
:param grammar_file:
|
|
:param transformer:
|
|
:return:
|
|
"""
|
|
content = utils.read_text(content_file)
|
|
grammar = utils.read_text(grammar_file)
|
|
parser = lark.Lark(grammar, debug=False)
|
|
tree = parser.parse(content)
|
|
return transformer.transform(tree)
|
|
|
|
|
|
def read_developer_info():
|
|
"""
|
|
|
|
:return:
|
|
"""
|
|
developer_file = os.path.join(c.root_path, 'developer.md')
|
|
grammar_file = os.path.join(c.code_path, 'grammar_listing.lark')
|
|
transformer = ListingTransformer()
|
|
developers = read_and_parse(developer_file, grammar_file, transformer)
|
|
# now transform a bit more
|
|
for index, dev in enumerate(developers):
|
|
# check for valid keys
|
|
for field in dev.keys():
|
|
if field not in valid_developer_fields:
|
|
raise RuntimeError('Unknown developer field "{}" for developer: {}.'.format(field, dev['name']))
|
|
# strip from name or organization (just in case)
|
|
for field in ('name', 'organization'):
|
|
if field in dev:
|
|
dev[field] = dev[field].strip()
|
|
# split games, contact (are lists)
|
|
for field in ('games', 'contact'):
|
|
if field in dev:
|
|
content = dev[field]
|
|
content = content.split(',')
|
|
content = [x.strip() for x in content]
|
|
dev[field] = content
|
|
# check for duplicate names entries
|
|
names = [dev['name'] for dev in developers]
|
|
duplicate_names = (name for name in names if names.count(name) > 1)
|
|
duplicate_names = set(duplicate_names) # to avoid duplicates in duplicate_names
|
|
if duplicate_names:
|
|
print('Warning: duplicate developer names: {}'.format(', '.join(duplicate_names)))
|
|
return developers
|
|
|
|
|
|
def write_developer_info(developers):
|
|
"""
|
|
|
|
:return:
|
|
"""
|
|
# comment
|
|
content = '{}\n'.format(comment_string)
|
|
|
|
# number of developer
|
|
content += '# Developer ({})\n\n'.format(len(developers))
|
|
|
|
# sort by name
|
|
developers.sort(key=lambda x: str.casefold(x['name']))
|
|
|
|
# iterate over them
|
|
for dev in developers:
|
|
# developer name
|
|
content += '## {} ({})\n\n'.format(dev['name'], len(dev['games']))
|
|
|
|
# games
|
|
content += '- Games: {}\n'.format(', '.join(sorted(dev['games'], key=str.casefold)))
|
|
|
|
# all the remaining in alphabetical order
|
|
for field in sorted(dev.keys()):
|
|
if field not in ('name', 'games'):
|
|
value = dev[field]
|
|
field = field.capitalize()
|
|
if isinstance(value, str):
|
|
content += '- {}: {}\n'.format(field, value)
|
|
else:
|
|
content += '- {}: {}\n'.format(field, ', '.join(sorted(value, key=str.casefold)))
|
|
content += '\n'
|
|
|
|
# write
|
|
developer_file = os.path.join(c.root_path, 'developer.md')
|
|
utils.write_text(developer_file, content)
|
|
|
|
|
|
def read_inspirations_info():
|
|
"""
|
|
|
|
:return:
|
|
"""
|
|
inspirations_file = os.path.join(c.root_path, 'inspirations.md')
|
|
grammar_file = os.path.join(c.code_path, 'grammar_listing.lark')
|
|
transformer = ListingTransformer()
|
|
inspirations = read_and_parse(inspirations_file, grammar_file, transformer)
|
|
# now transform a bit more
|
|
for index, inspiration in enumerate(inspirations):
|
|
# check for valid keys
|
|
for field in inspiration.keys():
|
|
if field not in valid_inspiration_fields:
|
|
raise RuntimeError('Unknown field "{}" for inspiration: {}.'.format(field, inspiration['name']))
|
|
# split lists
|
|
for field in ('inspired entries',):
|
|
if field in inspiration:
|
|
content = inspiration[field]
|
|
content = content.split(',')
|
|
content = [x.strip() for x in content]
|
|
inspiration[field] = content
|
|
# check for duplicate names entries
|
|
names = [inspiration['name'] for inspiration in inspirations]
|
|
duplicate_names = (name for name in names if names.count(name) > 1)
|
|
duplicate_names = set(duplicate_names) # to avoid duplicates in duplicate_names
|
|
if duplicate_names:
|
|
print('Warning: duplicate inspiration names: {}'.format(', '.join(duplicate_names)))
|
|
return inspirations
|
|
|
|
|
|
def write_inspirations_info(inspirations):
|
|
"""
|
|
|
|
:param inspirations:
|
|
:return:
|
|
"""
|
|
# comment
|
|
content = '{}\n'.format(comment_string)
|
|
|
|
# number of developer
|
|
content += '# Inspirations ({})\n\n'.format(len(inspirations))
|
|
|
|
# sort by name
|
|
inspirations.sort(key=lambda x: str.casefold(x['name']))
|
|
|
|
# iterate over them
|
|
for inspiration in inspirations:
|
|
# inspiration name
|
|
content += '## {} ({})\n\n'.format(inspiration['name'], len(inspiration['inspired entries']))
|
|
|
|
# games
|
|
content += '- Inspired entries: {}\n'.format(
|
|
', '.join(sorted(inspiration['inspired entries'], key=str.casefold)))
|
|
|
|
# all the remaining in alphabetical order
|
|
for field in sorted(inspiration.keys()):
|
|
if field not in ('name', 'inspired entries'):
|
|
value = inspiration[field]
|
|
field = field.capitalize()
|
|
if isinstance(value, str):
|
|
content += '- {}: {}\n'.format(field, value)
|
|
else:
|
|
content += '- {}: {}\n'.format(field, ', '.join(sorted(value, key=str.casefold)))
|
|
content += '\n'
|
|
|
|
# write
|
|
inspirations_file = os.path.join(c.root_path, 'inspirations2.md')
|
|
utils.write_text(inspirations_file, content)
|
|
|
|
|
|
def compare_entries_developers(entries, developers):
|
|
"""
|
|
Cross checks the game entries lists and the developers lists.
|
|
:param entries: List of game entries
|
|
:param developers: List of developers
|
|
"""
|
|
|
|
# from the entries create a dictionary with developer names
|
|
devs1 = {}
|
|
for entry in entries:
|
|
name = entry['name']
|
|
for dev in entry.get('developer', []):
|
|
if dev in devs1:
|
|
devs1[dev].append(name)
|
|
else:
|
|
devs1[dev] = [name]
|
|
devs1_names = set(devs1.keys())
|
|
|
|
# from the developers create a dictionary with developer names
|
|
devs2 = dict(zip((dev['name'] for dev in developers), (dev['games'] for dev in developers)))
|
|
devs2_names = set(devs2.keys())
|
|
|
|
# devs only in entries
|
|
for dev in devs1_names - devs2_names:
|
|
print('Warning: dev "{}" only in entries ({}), not in developers'.format(dev, ','.join(devs1[dev])))
|
|
# devs only in developers
|
|
for dev in devs2_names - devs1_names:
|
|
print('Warning: dev "{}" only in developers ({}), not in entries'.format(dev, ','.join(devs2[dev])))
|
|
# for those in both, check that the games lists are equal
|
|
for dev in devs1_names.intersection(devs2_names):
|
|
games1 = set(devs1[dev])
|
|
games2 = set(devs2[dev])
|
|
delta = games1 - games2
|
|
if delta:
|
|
print('Warning: dev "{}" has games in entries ({}) that are not present in developers'.format(dev,
|
|
', '.join(
|
|
delta)))
|
|
delta = games2 - games1
|
|
if delta:
|
|
print('Warning: dev "{}" has games in developers ({}) that are not present in entries'.format(dev,
|
|
', '.join(
|
|
delta)))
|