Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • misc/mra
1 result
Show changes
Commits on Source (2)
...@@ -24,9 +24,7 @@ ...@@ -24,9 +24,7 @@
import os.path import os.path
from setuptools import setup from setuptools import setup
from src import __version__
version = '1.1.8'
def parse_requirements(filename): def parse_requirements(filename):
...@@ -42,7 +40,7 @@ reqs = [str(req) for req in parse_requirements(reqs_filename)] ...@@ -42,7 +40,7 @@ reqs = [str(req) for req in parse_requirements(reqs_filename)]
setup( setup(
name="MapServer Rest API", name="MapServer Rest API",
version=version, version=__version__,
description="A RESTFul interface for MapServer", description="A RESTFul interface for MapServer",
author="Neogeo Technologies", author="Neogeo Technologies",
author_email="contact@neogeo.fr", author_email="contact@neogeo.fr",
......
...@@ -20,3 +20,6 @@ ...@@ -20,3 +20,6 @@
# GNU General Public License for more details. # # GNU General Public License for more details. #
# # # #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
__version__ = '1.1.9'
...@@ -96,7 +96,7 @@ OUTPUTFORMAT = { ...@@ -96,7 +96,7 @@ OUTPUTFORMAT = {
imagemode=mapscript.MS_IMAGEMODE_FEATURE, imagemode=mapscript.MS_IMAGEMODE_FEATURE,
transparent=mapscript.MS_OFF, transparent=mapscript.MS_OFF,
options={"FORM": "SIMPLE", "STORAGE": "stream"}) options={"FORM": "SIMPLE", "STORAGE": "stream"})
}, },
'WMS': { 'WMS': {
'PNG8': outputformat( 'PNG8': outputformat(
"AGG/PNG8", "png8", mimetype="image/png; mode=8bit", "AGG/PNG8", "png8", mimetype="image/png; mode=8bit",
...@@ -109,8 +109,8 @@ OUTPUTFORMAT = { ...@@ -109,8 +109,8 @@ OUTPUTFORMAT = {
"AGG/JPEG", "jpeg", mimetype="image/jpeg", "AGG/JPEG", "jpeg", mimetype="image/jpeg",
imagemode=mapscript.MS_IMAGEMODE_RGB, imagemode=mapscript.MS_IMAGEMODE_RGB,
extension="jpg", options={"GAMMA": "0.75"}) extension="jpg", options={"GAMMA": "0.75"})
}
} }
}
class MetadataMixin(object): class MetadataMixin(object):
...@@ -134,7 +134,8 @@ class Layer(MetadataMixin): ...@@ -134,7 +134,8 @@ class Layer(MetadataMixin):
self.ms = backend self.ms = backend
def enable(self, enabled=True): def enable(self, enabled=True):
wms = ("GetCapabilities", "GetMap", "GetFeatureInfo", "GetLegendGraphic", "DescribeLayer", "GetStyles") wms = ("GetCapabilities", "GetMap", "GetFeatureInfo",
"GetLegendGraphic", "DescribeLayer", "GetStyles")
wcs = ("GetCapabilities", "GetCoverage", "DescribeCoverage") wcs = ("GetCapabilities", "GetCoverage", "DescribeCoverage")
wfs = ("GetCapabilities", "GetFeature", "DescribeFeatureType") wfs = ("GetCapabilities", "GetFeature", "DescribeFeatureType")
...@@ -147,11 +148,14 @@ class Layer(MetadataMixin): ...@@ -147,11 +148,14 @@ class Layer(MetadataMixin):
self.set_metadata("wfs_enable_request", " ".join(wfs)) self.set_metadata("wfs_enable_request", " ".join(wfs))
else: else:
self.ms.status = mapscript.MS_OFF self.ms.status = mapscript.MS_OFF
self.set_metadata("wms_enable_request", " ".join(["!%s" % m for m in wms])) self.set_metadata("wms_enable_request",
" ".join(["!%s" % m for m in wms]))
if self.ms.type == 3: if self.ms.type == 3:
self.set_metadata("wcs_enable_request", " ".join(["!%s" % m for m in wcs])) self.set_metadata("wcs_enable_request",
" ".join(["!%s" % m for m in wcs]))
else: else:
self.set_metadata("wfs_enable_request", " ".join(["!%s" % m for m in wfs])) self.set_metadata("wfs_enable_request",
" ".join(["!%s" % m for m in wfs]))
def get_type_name(self): def get_type_name(self):
return { return {
...@@ -160,7 +164,7 @@ class Layer(MetadataMixin): ...@@ -160,7 +164,7 @@ class Layer(MetadataMixin):
2: "POLYGON", 2: "POLYGON",
3: "RASTER", 3: "RASTER",
4: "ANNOTATION", 4: "ANNOTATION",
}[self.ms.type] }[self.ms.type]
def get_proj4(self): def get_proj4(self):
return self.ms.getProjection() return self.ms.getProjection()
...@@ -232,7 +236,8 @@ class Layer(MetadataMixin): ...@@ -232,7 +236,8 @@ class Layer(MetadataMixin):
xmlsld.firstChild.getElementsByTagNameNS("*", "NamedLayer")[0]\ xmlsld.firstChild.getElementsByTagNameNS("*", "NamedLayer")[0]\
.getElementsByTagNameNS("*", "Name")[0].firstChild.data = sld_layer_name .getElementsByTagNameNS("*", "Name")[0].firstChild.data = sld_layer_name
except Exception as e: except Exception as e:
logging.error("mra.py::Layer::add_style_sld: Bad sld (No NamedLayer/Name): %s", e) logging.error(
"mra.py::Layer::add_style_sld: Bad sld (No NamedLayer/Name): %s", e)
raise ValueError("Bad sld (No NamedLayer/Name)") raise ValueError("Bad sld (No NamedLayer/Name)")
new_sld = xmlsld.toxml() new_sld = xmlsld.toxml()
...@@ -245,7 +250,8 @@ class Layer(MetadataMixin): ...@@ -245,7 +250,8 @@ class Layer(MetadataMixin):
try: try:
ms_template_layer.applySLD(new_sld, sld_layer_name) ms_template_layer.applySLD(new_sld, sld_layer_name)
except Exception as e: except Exception as e:
logging.error("mra.py::Layer::add_style_sld: Unable to access storage : %s", e) logging.error(
"mra.py::Layer::add_style_sld: Unable to access storage : %s", e)
raise ValueError("Unable to access storage.") raise ValueError("Unable to access storage.")
for i in range(ms_template_layer.numclasses): for i in range(ms_template_layer.numclasses):
...@@ -270,9 +276,11 @@ class Layer(MetadataMixin): ...@@ -270,9 +276,11 @@ class Layer(MetadataMixin):
return None return None
try: try:
style = open(os.path.join(os.path.dirname(__file__), "%s.sld" % s_name), encoding="utf-8").read() style = open(os.path.join(os.path.dirname(__file__),
"%s.sld" % s_name), encoding="utf-8").read()
except IOError as OSError: except IOError as OSError:
logging.warning("mra.py::Layer::set_default_style IOError %s", OSError) logging.warning(
"mra.py::Layer::set_default_style IOError %s", OSError)
return return
self.add_style_sld(mf, s_name, style) self.add_style_sld(mf, s_name, style)
...@@ -379,7 +387,8 @@ class Mapfile(MetadataMixin): ...@@ -379,7 +387,8 @@ class Mapfile(MetadataMixin):
self.set_metadata("%s_enable_request" % ows, "*") self.set_metadata("%s_enable_request" % ows, "*")
if 'onlineresource' in config: if 'onlineresource' in config:
onlineresource = urljoin(config.get('onlineresource'), self.ms.name) onlineresource = urljoin(config.get(
'onlineresource'), self.ms.name)
self.set_metadata('ows_onlineresource', onlineresource) self.set_metadata('ows_onlineresource', onlineresource)
fontset and self.ms.setFontSet(fontset) fontset and self.ms.setFontSet(fontset)
...@@ -443,7 +452,7 @@ class Mapfile(MetadataMixin): ...@@ -443,7 +452,7 @@ class Mapfile(MetadataMixin):
# Add metadata. # Add metadata.
metadata = { metadata = {
"wms_srs": self.get_metadata("ows_srs"), "wms_srs": self.get_metadata("ows_srs"),
} }
metadata.update(l_metadata) metadata.update(l_metadata)
layer.update_metadatas(metadata) layer.update_metadatas(metadata)
...@@ -534,8 +543,10 @@ class FeatureTypeModel(LayerModel): ...@@ -534,8 +543,10 @@ class FeatureTypeModel(LayerModel):
cparam = info["connectionParameters"] cparam = info["connectionParameters"]
if cparam.get("dbtype", None) in ["postgis", "postgres", "postgresql"]: if cparam.get("dbtype", None) in ["postgis", "postgres", "postgresql"]:
self.ms.connectiontype = mapscript.MS_POSTGIS self.ms.connectiontype = mapscript.MS_POSTGIS
connection = "dbname=%s port=%s host=%s " % (cparam.get("database", "postgres"), cparam.get("port", "5432"), cparam.get("host", "localhost")) connection = "dbname=%s port=%s host=%s " % (cparam.get(
connection += " ".join("%s=%s" % (p, cparam[p]) for p in ["user", "password"] if p in cparam) "database", "postgres"), cparam.get("port", "5432"), cparam.get("host", "localhost"))
connection += " ".join("%s=%s" % (p, cparam[p])
for p in ["user", "password"] if p in cparam)
self.ms.connection = connection self.ms.connection = connection
self.ms.data = '%s FROM %s.%s' % ( self.ms.data = '%s FROM %s.%s' % (
ds[ft_name].get_geometry_column(), ds[ft_name].get_geometry_column(),
...@@ -554,7 +565,8 @@ class FeatureTypeModel(LayerModel): ...@@ -554,7 +565,8 @@ class FeatureTypeModel(LayerModel):
# Update mra metadata, and make sure the mandatory ones are left untouched. # Update mra metadata, and make sure the mandatory ones are left untouched.
self.update_mra_metadatas(metadata) self.update_mra_metadatas(metadata)
self.update_mra_metadatas({"name": ft_name, "type": "featuretype", "storage": ds_name}) self.update_mra_metadatas(
{"name": ft_name, "type": "featuretype", "storage": ds_name})
def configure_layer(self, layer, enabled=True): def configure_layer(self, layer, enabled=True):
ws = self.ws ws = self.ws
...@@ -578,7 +590,7 @@ class FeatureTypeModel(LayerModel): ...@@ -578,7 +590,7 @@ class FeatureTypeModel(LayerModel):
"type": self.get_mra_metadata("type"), "type": self.get_mra_metadata("type"),
"storage": self.get_mra_metadata("storage"), "storage": self.get_mra_metadata("storage"),
"workspace": ws.name, "workspace": ws.name,
}) })
layer.enable(enabled) layer.enable(enabled)
...@@ -596,7 +608,7 @@ class FeatureTypeModel(LayerModel): ...@@ -596,7 +608,7 @@ class FeatureTypeModel(LayerModel):
"gml_%s_type" % field_name: field.get_type_gml(), "gml_%s_type" % field_name: field.get_type_gml(),
# "gml_%s_precision" % field_name # "gml_%s_precision" % field_name
# "gml_%s_width" % field_name # "gml_%s_width" % field_name
}) })
geometry_column = ft.get_geometry_column() geometry_column = ft.get_geometry_column()
if geometry_column is None: if geometry_column is None:
...@@ -611,7 +623,7 @@ class FeatureTypeModel(LayerModel): ...@@ -611,7 +623,7 @@ class FeatureTypeModel(LayerModel):
self.ms.extent.miny, self.ms.extent.miny,
self.ms.extent.maxx, self.ms.extent.maxx,
self.ms.extent.maxy, self.ms.extent.maxy,
), ),
"ows_include_items": ",".join(field_names), "ows_include_items": ",".join(field_names),
"gml_include_items": ",".join(field_names), "gml_include_items": ",".join(field_names),
"gml_geometries": geometry_column, "gml_geometries": geometry_column,
...@@ -619,13 +631,13 @@ class FeatureTypeModel(LayerModel): ...@@ -619,13 +631,13 @@ class FeatureTypeModel(LayerModel):
# TODO: Add gml_<geometry name>_occurances, # TODO: Add gml_<geometry name>_occurances,
"wfs_srs": ws.get_metadata("ows_srs"), "wfs_srs": ws.get_metadata("ows_srs"),
"wfs_getfeature_formatlist": ",".join(list(OUTPUTFORMAT["WFS"].keys())) "wfs_getfeature_formatlist": ",".join(list(OUTPUTFORMAT["WFS"].keys()))
}) })
if ft.get_fid_column() is not None: if ft.get_fid_column() is not None:
layer.set_metadatas({ layer.set_metadatas({
"wfs_featureid": ft.get_fid_column(), "wfs_featureid": ft.get_fid_column(),
"gml_featureid": ft.get_fid_column(), "gml_featureid": ft.get_fid_column(),
}) })
plugins.extend("post_configure_vector_layer", self, ws, ds, ft, layer) plugins.extend("post_configure_vector_layer", self, ws, ds, ft, layer)
...@@ -645,7 +657,8 @@ class CoverageModel(LayerModel): ...@@ -645,7 +657,8 @@ class CoverageModel(LayerModel):
try: try:
crs = metadata.pop("crs") crs = metadata.pop("crs")
proj4 = "+init=%s:%s" % (crs["authority_name"], crs["authority_code"]) proj4 = "+init=%s:%s" % (crs["authority_name"],
crs["authority_code"])
except Exception as e: except Exception as e:
logging.warn('mra.py::MRA::CoverageModel.update error %s', e) logging.warn('mra.py::MRA::CoverageModel.update error %s', e)
proj4 = cs.get_proj4() proj4 = cs.get_proj4()
...@@ -672,12 +685,12 @@ class CoverageModel(LayerModel): ...@@ -672,12 +685,12 @@ class CoverageModel(LayerModel):
url = urlparse(cparam["url"]) url = urlparse(cparam["url"])
filename = self.ws.mra.get_file_path(url.path) filename = self.ws.mra.get_file_path(url.path)
if cs.tindex is None: if cs.tindex is None:
#if cparam["dbtype"] in ["tif", "tiff"]: # if cparam["dbtype"] in ["tif", "tiff"]:
self.ms.data = filename self.ms.data = filename
self.ms.tileindex = None self.ms.tileindex = None
self.ms.tileitem = None self.ms.tileitem = None
# TODO: strip extention. # TODO: strip extention.
#else: # else:
# raise ValueError("Unhandled type \"%s\"." % cparam["dbtype"]) # raise ValueError("Unhandled type \"%s\"." % cparam["dbtype"])
else: else:
self.ms.data = None self.ms.data = None
...@@ -719,7 +732,7 @@ class CoverageModel(LayerModel): ...@@ -719,7 +732,7 @@ class CoverageModel(LayerModel):
"type": self.get_mra_metadata("type"), "type": self.get_mra_metadata("type"),
"storage": self.get_mra_metadata("storage"), "storage": self.get_mra_metadata("storage"),
"workspace": ws.name, "workspace": ws.name,
}) })
layer.set_metadatas({ layer.set_metadatas({
"ows_name": layer_name, "ows_name": layer_name,
...@@ -730,11 +743,11 @@ class CoverageModel(LayerModel): ...@@ -730,11 +743,11 @@ class CoverageModel(LayerModel):
self.ms.extent.miny, self.ms.extent.miny,
self.ms.extent.maxx, self.ms.extent.maxx,
self.ms.extent.maxy, self.ms.extent.maxy,
), ),
"wcs_name": layer.get_metadata("wcs_name", None) or layer_name, "wcs_name": layer.get_metadata("wcs_name", None) or layer_name,
"wcs_label": layer.get_metadata("wcs_label", None) or layer_name, "wcs_label": layer.get_metadata("wcs_label", None) or layer_name,
"wcs_description": layer.get_metadata("wcs_description", None) or layer_name "wcs_description": layer.get_metadata("wcs_description", None) or layer_name
}) })
layer.enable(enabled) layer.enable(enabled)
...@@ -843,7 +856,8 @@ class Workspace(Mapfile): ...@@ -843,7 +856,8 @@ class Workspace(Mapfile):
except (StopIteration, SystemError): except (StopIteration, SystemError):
pass # No layers use our store, all OK. pass # No layers use our store, all OK.
else: else:
raise ValueError("The datastore \"%s\" can't be delete because it is used." % name) raise ValueError(
"The datastore \"%s\" can't be delete because it is used." % name)
return self.delete_store("datastore", name) return self.delete_store("datastore", name)
# Coveragestores (this is c/p from datastores): # Coveragestores (this is c/p from datastores):
...@@ -886,7 +900,8 @@ class Workspace(Mapfile): ...@@ -886,7 +900,8 @@ class Workspace(Mapfile):
except (StopIteration, SystemError): except (StopIteration, SystemError):
pass # No layers use our store, all OK. pass # No layers use our store, all OK.
else: else:
raise ValueError("The coveragestore \"%s\" can't be delete because it is used." % name) raise ValueError(
"The coveragestore \"%s\" can't be delete because it is used." % name)
return self.delete_store("coveragestore", name) return self.delete_store("coveragestore", name)
# LayerModels: # LayerModels:
...@@ -945,7 +960,8 @@ class Workspace(Mapfile): ...@@ -945,7 +960,8 @@ class Workspace(Mapfile):
def delete_layermodel(self, st_type, ds_name, ft_name): def delete_layermodel(self, st_type, ds_name, ft_name):
lm = self.get_layermodel(st_type, ds_name, ft_name) lm = self.get_layermodel(st_type, ds_name, ft_name)
if lm.get_mra_metadata("layers", []): if lm.get_mra_metadata("layers", []):
raise ValueError("The %s \"%s\" can't be delete because it is used." % (st_type, ft_name)) raise ValueError(
"The %s \"%s\" can't be delete because it is used." % (st_type, ft_name))
self.ms.removeLayer(lm.ms.index) self.ms.removeLayer(lm.ms.index)
# Featuretypes # Featuretypes
...@@ -998,7 +1014,7 @@ class MRA(object): ...@@ -998,7 +1014,7 @@ class MRA(object):
self.config = yaml.load( self.config = yaml.load(
open(config_path, "r"), open(config_path, "r"),
# Loader=yaml.FullLoader # Loader=yaml.FullLoader
) )
except yaml.YAMLError as e: except yaml.YAMLError as e:
exit("Error in configuration file: %s" % e) exit("Error in configuration file: %s" % e)
...@@ -1025,7 +1041,8 @@ class MRA(object): ...@@ -1025,7 +1041,8 @@ class MRA(object):
return os.path.relpath(path, self.get_path()) return os.path.relpath(path, self.get_path())
def get_resource_path(self, *args): def get_resource_path(self, *args):
root = self.config["storage"].get("resources", self.get_path("resources")) root = self.config["storage"].get(
"resources", self.get_path("resources"))
return self.get_path(root, *args) return self.get_path(root, *args)
def pub_resource_path(self, path): def pub_resource_path(self, path):
...@@ -1052,7 +1069,8 @@ class MRA(object): ...@@ -1052,7 +1069,8 @@ class MRA(object):
fontset.close fontset.close
def get_font_path(self, *args): def get_font_path(self, *args):
root = self.config["storage"].get("fonts", self.get_resource_path("fonts")) root = self.config["storage"].get(
"fonts", self.get_resource_path("fonts"))
return self.get_resource_path(root, *args) return self.get_resource_path(root, *args)
def create_font(self, name, data=None): def create_font(self, name, data=None):
...@@ -1074,7 +1092,8 @@ class MRA(object): ...@@ -1074,7 +1092,8 @@ class MRA(object):
# Styles: # Styles:
def get_style_path(self, *args): def get_style_path(self, *args):
root = self.config["storage"].get("styles", self.get_resource_path("styles")) root = self.config["storage"].get(
"styles", self.get_resource_path("styles"))
return self.get_resource_path(root, *args) return self.get_resource_path(root, *args)
def pub_style_path(self, path): def pub_style_path(self, path):
...@@ -1118,7 +1137,8 @@ class MRA(object): ...@@ -1118,7 +1137,8 @@ class MRA(object):
# Files: # Files:
def get_file_path(self, *args): def get_file_path(self, *args):
root = self.config["storage"].get("data", self.get_resource_path("data")) root = self.config["storage"].get(
"data", self.get_resource_path("data"))
return self.get_resource_path(root, *args) return self.get_resource_path(root, *args)
def pub_file_path(self, path): def pub_file_path(self, path):
...@@ -1134,7 +1154,8 @@ class MRA(object): ...@@ -1134,7 +1154,8 @@ class MRA(object):
# Available (get): # Available (get):
def get_available_path(self, *args): def get_available_path(self, *args):
root = self.config["storage"].get("available", self.get_path("available")) root = self.config["storage"].get(
"available", self.get_path("available"))
return self.get_path(root, *args) return self.get_path(root, *args)
def pub_available_path(self, path): def pub_available_path(self, path):
...@@ -1169,12 +1190,14 @@ class MRA(object): ...@@ -1169,12 +1190,14 @@ class MRA(object):
def delete_workspace(self, name): def delete_workspace(self, name):
# path = self.get_available_path("%s.ws.map" % name) # path = self.get_available_path("%s.ws.map" % name)
raise NotImplementedError("Method 'delete_workspace' is not yet available. (TODO)") raise NotImplementedError(
"Method 'delete_workspace' is not yet available. (TODO)")
# Services: # Services:
def get_service_path(self, *args): def get_service_path(self, *args):
root = self.config["storage"].get("services", self.get_path("services")) root = self.config["storage"].get(
"services", self.get_path("services"))
return self.get_path(root, *args) return self.get_path(root, *args)
def pub_service_path(self, path): def pub_service_path(self, path):
...@@ -1210,9 +1233,11 @@ class MRA(object): ...@@ -1210,9 +1233,11 @@ class MRA(object):
cparam = info["connectionParameters"] cparam = info["connectionParameters"]
if cparam.get("dbtype", "") == "postgis": if cparam.get("dbtype", "") == "postgis":
# First mandatory # First mandatory
url = "PG:dbname=%s port=%s host=%s " % (cparam["database"], cparam["port"], cparam["host"]) url = "PG:dbname=%s port=%s host=%s " % (
cparam["database"], cparam["port"], cparam["host"])
# Then optionals: # Then optionals:
url += " ".join("%s=%s" % (p, cparam[p]) for p in ["user", "password"] if p in cparam) url += " ".join("%s=%s" % (p, cparam[p])
for p in ["user", "password"] if p in cparam)
return url return url
elif "url" in cparam: elif "url" in cparam:
url = urlparse(cparam["url"]) url = urlparse(cparam["url"])
...@@ -1220,4 +1245,5 @@ class MRA(object): ...@@ -1220,4 +1245,5 @@ class MRA(object):
raise ValueError("Only local files are suported.") raise ValueError("Only local files are suported.")
return self.get_file_path(url.path) return self.get_file_path(url.path)
else: else:
raise ValueError("Unhandled type \"%s\"." % cparam.get("dbtype", "<unknown>")) raise ValueError("Unhandled type \"%s\"." %
cparam.get("dbtype", "<unknown>"))
...@@ -44,6 +44,7 @@ import webapp ...@@ -44,6 +44,7 @@ import webapp
from webapp import get_data from webapp import get_data
from webapp import HTTPCompatible from webapp import HTTPCompatible
from webapp import urlmap from webapp import urlmap
from src import __version__
# Some helper functions first. # Some helper functions first.
...@@ -64,6 +65,7 @@ class index(object): ...@@ -64,6 +65,7 @@ class index(object):
@HTTPCompatible(authorized=["html"]) @HTTPCompatible(authorized=["html"])
def GET(self, format): def GET(self, format):
return { return {
"version": href("version"),
"about/version": href("about/version"), "about/version": href("about/version"),
"workspaces": href("workspaces"), "workspaces": href("workspaces"),
"styles": href("styles"), "styles": href("styles"),
...@@ -76,6 +78,15 @@ class index(object): ...@@ -76,6 +78,15 @@ class index(object):
} }
class app_version(object):
"""To know about application version
"""
@HTTPCompatible()
def GET(self, format):
return {"mra": __version__}
class version(object): class version(object):
"""To know about used versions... """To know about used versions...
...@@ -1661,6 +1672,8 @@ class OWSWorkspaceSettings(object): ...@@ -1661,6 +1672,8 @@ class OWSWorkspaceSettings(object):
# Index: # Index:
urlmap(index, "") urlmap(index, "")
# App Version:
urlmap(app_version, "version")
# About version: # About version:
urlmap(version, "about", "version") urlmap(version, "about", "version")
# Workspaces: # Workspaces:
......