Module control.static
Expand source code Browse git
import re
from copy import deepcopy
from traceback import format_exception
from markdown import markdown
from .files import (
fileNm,
dirNm,
dirUpdate,
dirAllFiles,
dirContents,
dirMake,
stripExt,
readJson,
readYaml,
writeJson,
)
from .generic import AttrDict, deepAttrDict, deepdict
from .helpers import prettify, genViewerSelector, ucFirst
from .precheck import Precheck as PrecheckCls
COMMENT_RE = re.compile(r"""\{\{!--.*?--}}""", re.S)
CONFIG_FILE = "client.yml"
class Static:
def __init__(self, Settings, Messages, Content, Viewers, Tailwind, Handlebars):
"""All about generating static pages."""
self.Settings = Settings
self.Content = Content
self.Tailwind = Tailwind
self.Handlebars = Handlebars
self.Messages = Messages
Messages.debugAdd(self)
self.Precheck = PrecheckCls(Settings, Messages, Content, Viewers)
yamlDir = Settings.yamlDir
yamlFile = f"{yamlDir}/{CONFIG_FILE}"
cfg = readYaml(asFile=yamlFile)
self.cfg = cfg
self.data = AttrDict()
self.dbData = AttrDict()
def sanitizeMeta(self, table, record):
"""Checks for missing (sub)-fields in the Dublin Core.
Any field that is missing will be supplied with a default value, most of the
times it will be an empty list or string, but the licence will be an
"All rights reserved" licence and the peer review kind will be "No peer review".
Ideally the defaults should come from configuration, but because an admin can
change the keywords, the defaults should then also be editable by an admin,
but that goes to far for now.
Strings in list fields will be converted to singleton lists,
and markdown texts will be converted to html.
Parameters
----------
table: string
The kind of info: site, project, or edition. This influences
which fields should be present.
record: dict
The record with metadata
Returns
-------
void
The dict is changed in place.
"""
Content = self.Content
Settings = self.Settings
H = Settings.H
fields = Content.getMetaFields(table, None, asDict=True)
listKeys = Content.getListFields()
markdownKeys = Content.getMarkdownFields()
for key in fields:
F = Content.makeField(key, table)
value = F.logical(record)
if value is None:
value = fields[key].default
if key in listKeys:
if type(value) not in (list, tuple):
value = [value] if value else []
if key in markdownKeys:
value = (
""
if value is None
else (
H.br().join(markdown(e) for e in value)
if type(value) in {list, tuple}
else markdown(value)
)
)
F.setLogical(record, value)
def genPages(self, pPubNum, ePubNum, featured=[1, 2, 3]):
"""Generate html pages for a published edition.
We assume the data of the projects and editions is already in place.
As to the viewers: we compare the viewers and versions in the
`data/viewers` directory with the viewers and versions in the
`published/viewers` directory, and we copy viewer versions that are missing
in the latter from the former.
Exactly what will be generated depends on the parameters.
There are the following things to generate:
* **S**: site wide files, outside projects
* **P**: project wide files, outside editions
* **E**: edition pages
**S** will always be (re)generated.
If a particular project is specified, the **P** for that project will
also be (re)generated.
If a particular edition is specified, the **E** for that edition will
also be (re)generated.
Parameters
----------
pPubNUm, ePubNUm: integer or boolean or void
Specifies which project and edition must be (re)generated, if they are
integers.
The integers is the numbers of the published project and edition.
The following combinations are possible:
* `None`, `None`: only **S** is (re)generated;
* `p`, `None`: **S** and **P** for project with number `p` are
(re)generated;
* `p`, `e`: **S** and **P** and **E** are (re)generated for project
with number `p` and edition with number `e` within that project;
* `True`, `True`: everything will be regenerated.
featured: list of integer
The list of publication numbers of featured projects. They will appear
in a special display on the home page.
Returns
-------
boolean
Whether the generation was successful.
"""
Messages = self.Messages
Settings = self.Settings
Tailwind = self.Tailwind
Handlebars = self.Handlebars
viewerDir = Settings.viewerDir
pubModeDir = Settings.pubModeDir
dataOutDir = f"{pubModeDir}/json"
templateDir = Settings.templateDir
partialsIn = Settings.partialsIn
jsDir = Settings.jsDir
imageDir = Settings.imageDir
partials = {}
compiledTemplates = {}
if type(featured) is list:
msg = "skipping featured project '{}'"
featuredParsed = set()
for f in featured:
if type(f) is int:
featuredParsed.add(f)
elif type(f) is str:
if f.isdecimal():
featuredParsed.add(int(f))
else:
Messages.warning(msg=msg.format(f))
else:
Messages.warning(msg=msg.format(f))
featured = sorted(featuredParsed)
else:
Messages.warning(
msg="The featured projects are not given as list, will be set to 1,2,3"
)
featured = [1, 2, 3]
Messages.special(
msg=f"Featured projects: {', '.join(str(f) for f in featured)}"
)
self.featured = featured
def updateStatic(fileType, srcDr):
"""Copy over static files.
We are careful: instead of copying a folder, we merge, recursively,
the source folder into the destination folder, and we do not delete
anything from the destination.
Hence the parameters `delete=False` and `level=-1` to
`dirUpdate()`.
We do this, because older parts of the site may depend on older
static files.
"""
dstDir = f"{pubModeDir}/{fileType}"
(good, c, d) = dirUpdate(srcDr, dstDir, level=-1, delete=False)
report = f"{c:>3} copied, {d:>3} deleted"
Messages.info(
msg=f"{fileType} {c} copied",
logmsg=f"{'updated':<10} {fileType:<12} {report:<24} to {dstDir}",
)
return good
def updateViewers():
"""Copy over viewer versions.
We are careful: instead of copying the folder with viewers from source to
destination, we merge the source viewers with the destination viewers,
without deleting destination viewers.
And per viewer, instead of copying the viewer folder from source
to destination, we merge the source versions of that viewer with the
destination versions of that viewer, without deleting destination versions.
But per version we just copy, and stop the recursive merging, because each
viewer version is an integral whole, and we do not support that the same
version of the same viewer is different between source and destination.
"""
srcDr = viewerDir
dstDir = f"{pubModeDir}/viewers"
(good, c, d) = dirUpdate(
srcDr, dstDir, level=2, conservative=True, delete=False
)
report = f"{c:>3} copied, {d:>3} deleted"
Messages.info(
msg=f"viewers {c} copied",
logmsg=f"{'updated':<10} {'viewers':<12} {report:<24} to {dstDir}",
)
nViewerVersions = 0
for viewer in dirContents(dstDir)[1]:
nViewerVersions += len(dirContents(f"{dstDir}/{viewer}")[1])
msg = f"there are {nViewerVersions} viewer-version combinations"
Messages.info(msg=msg, logmsg=msg)
return (nViewerVersions, good)
def registerPartials():
good = True
for partialFile in dirAllFiles(partialsIn):
pDir = dirNm(partialFile).replace(partialsIn, "").strip("/")
pFile = fileNm(partialFile)
if pFile.startswith("."):
continue
pName = stripExt(pFile)
sep = "" if pDir == "" else "/"
partial = f"{pDir}{sep}{pName}"
with open(partialFile) as fh:
pContent = COMMENT_RE.sub("", fh.read())
try:
partials[partial] = Handlebars.compile(pContent)
except Exception as e:
Messages.error(
logmsg=(
f"Error in register partial {partial} : "
f"{''.join(format_exception(e))}"
)
)
good = False
report = f"{len(partials):<3} pieces"
Messages.info(
msg=f"{report} compiled",
logmsg=f"{'compiled':<10} {'partials':<12} {report:<24} to memory",
)
return good
def genTarget(target, pNum, eNum, nvv=1):
items = self.getData(target, pNum, eNum)
success = 0
failure = 0
good = True
for item in items:
templateFile = f"{templateDir}/{item.template}"
if templateFile in compiledTemplates:
template = compiledTemplates[templateFile]
else:
with open(templateFile) as fh:
tContent = COMMENT_RE.sub("", fh.read())
try:
template = Handlebars.compile(tContent)
except Exception as e:
Messages.error(
logmsg=(
f"Error compiling template {templateFile} : "
f"{''.join(format_exception(e))}"
)
)
template = None
compiledTemplates[templateFile] = template
if template is None:
failure += 1
good = False
continue
try:
result = template(item, partials=partials)
except Exception as e:
Messages.error(
logmsg=(
f"Error filling template {item.template} : "
f"{''.join(format_exception(e))}"
)
)
failure += 1
good = False
continue
for genDir, asData in ((pubModeDir, False), (dataOutDir, True)):
path = f"{genDir}/{item.fileName}"
if asData:
ext = ".json"
path = path.rsplit(".", 1)[0] + ext
dirPart = dirNm(path)
dirMake(dirPart)
if asData:
writeJson(deepdict(item), asFile=path)
else:
with open(path, "w") as fh:
fh.write(result)
success += 1
goodStr = f"{success:>3} ok"
badStr = f"{failure:>3} XX" if failure else ""
sep = ";" if failure else " "
report = f"{goodStr}{sep} {badStr}"
if target == "editionpages":
report += (
f" = {(success + failure) // (nvv + 1)} eds x " f"(1 + {nvv} v-v)"
)
Messages.info(
msg=f"generated {target} {report}",
logmsg=f"{'generated':<10} {target:<12} {report:<24} to {pubModeDir}",
)
return good
pType = type(pPubNum)
eType = type(ePubNum)
pIsInt = pType is int
eIsInt = eType is int
pNum = pPubNum is None
eNum = ePubNum is None
pAll = pPubNum is True
eAll = ePubNum is True
task = (
("site",)
if pNum and eNum
else (
("project", pPubNum)
if pIsInt and eNum
else (
("edition", pPubNum, ePubNum)
if pIsInt and eIsInt
else ("all",) if pAll and eAll else ("none",)
)
)
)
if task[0] == "none":
Messages.error(
msg="Page generation failed",
logmsg=(
"Page generation failed because of illegal parameter combination: "
f"project {pPubNum}: {pType} and edition {ePubNum}: {eType}"
),
)
return
# site
# project p
# edition p e
# all
# none
kind = task[0]
targets = []
targets.append(("site", None, None))
targets.append(("textpages", None, None))
targets.append(("projects", None, None))
targets.append(("editions", None, None))
if kind == "all":
targets.append(("projectpages", None, None))
targets.append(("editionpages", None, None))
elif kind in {"project", "edition"}:
targets.append(("projectpages", pPubNum, None))
if kind == "edition":
targets.append(("editionpages", pPubNum, ePubNum))
good = True
for upd, srcDir in (("js", jsDir), ("images", imageDir)):
if not updateStatic(upd, srcDir):
good = False
(nvv, thisGood) = updateViewers()
if not thisGood:
good = False
if not registerPartials():
good = False
if not Tailwind.generate():
good = False
self.getDbData()
for target in targets:
if not genTarget(*target, nvv=nvv):
good = False
if good:
msg = "All tasks successful"
Messages.info(logmsg=msg)
else:
msg = "Page generation failed"
Messages.error(logmsg=msg, msg=msg)
return good
def getData(self, kind, pNumGiven, eNumGiven):
"""Prepares page data of a certain kind.
Pages are generated by filling in templates and partials on the basis of
JSON data. Pages may require several kinds of data.
For example, the index page needs data to fill in a list of projects
and editions. Other pages may need the same kind of data.
So we store the gathered data under the kinds they have been gathered.
For some kinds we may restrict the data fetching to specified items:
for `projectpages` and `editionpages`.
When an edition has changed, we want to restrict the regeneration of
pages to only those pages that need to change. And we also update things outside
the projects and editions.
Still, when an edition changes, the page with All editions also has to change.
And if the edition was the first in a project to be published, a new project
will be published as well, and hence the `All projects` page needs to change.
If an edition is published next to other editions in a project, the project
page needs to change, since it contains thumbnails of all its editions.
So, the general rule is that we will always regenerate the thumbnails and the
All-projects and All-edition pages, but not all of the project pages and edition
pages.
!!! note "Not all kinds will be restricted"
The kinds `viewers`, `textpages`, `site` will never be restricted.
The kinds `projects`, `editions` are needed for thumbnails, and are
never restricted.
The kinds `project`, `edition` are called by the collection of kinds
`project` and `edition`, and are also not restricted.
That leaves only the `projectpages` and `editionpages` needing to be
restricted.
Parameters
----------
kind: string
The kind of data we need to prepare.
pNumGiven: integer or void
Restricts the data fetching to projects with this publication number
eNumGiven: integer or void
Restricts the data fetching to editions with this publication number
Returns
-------
dict or array
The data itself.
It is also stored in the member `data` of this object, under key
`kind`. It will not be computed twice.
"""
Settings = self.Settings
H = Settings.H
Messages = self.Messages
Content = self.Content
Precheck = self.Precheck
authorUrl = Settings.authorUrl
backPrefix = Settings.backPrefix
authorRoot = f"{authorUrl}/{backPrefix}/"
cfg = self.cfg
generation1 = cfg.generation
dbData = self.dbData
data = self.data
if kind in data:
return data[kind]
texts = Content.getTexts()
def get_viewers():
defaultViewer = Settings.viewerDefault
result = []
for viewer, viewerConfig in Settings.viewers.items():
versions = viewerConfig.versions
element = viewerConfig.modes.read.element
isDefault = viewer == defaultViewer
result.append(
AttrDict(
name=viewer,
element=element,
isDefault=isDefault,
versions=[AttrDict(name=version) for version in versions],
)
)
result[-1].versions[0].isDefault = True
return result
def get_site():
featured = self.featured
Ftitle = Content.makeField("title", "site")
info = dbData[kind]
self.sanitizeMeta("site", info)
bp = info.boilerplate
self.bp = bp
r = AttrDict()
r.boilerplate = dict(
leftText=bp.homeLeftText,
rightText=bp.homeRightText,
rightUrl=bp.homeRightUrl,
)
r.isHome = True
r.template = "home.html"
r.fileName = "index.html"
r.authorLink = authorRoot
r.name = Ftitle.logical(info)
r.dc = info.dc
projects = self.getData("project", None, None)
projectsIndex = {p.num: p for p in projects}
projectsFeatured = []
for p in featured:
if p not in projectsIndex:
Messages.warning(f"featured project {p} does not exist")
continue
projectsFeatured.append(projectsIndex[p])
r.projects = projectsFeatured
return [r]
def get_textpages():
info = dbData["site"]
bp = self.bp
result = []
for textName, key in texts.items():
F = Content.makeField(key, "site")
r = AttrDict()
r.boilerplate = dict(
leftText=bp[f"{textName}LeftText"],
rightText=bp[f"{textName}RightText"],
rightUrl=bp[f"{textName}RightUrl"],
)
r.template = "text.html"
r.authorLink = authorRoot
r.name = prettify(textName)
r[f"is{ucFirst(r.name)}"] = True
r.fileName = f"{textName}.html"
r.content = F.formatted(info)
result.append(r)
return result
def get_projects():
bp = self.bp
r = AttrDict()
r.boilerplate = dict(
leftText=bp.projectsLeftText,
rightText=bp.projectsRightText,
rightUrl=bp.projectsRightUrl,
)
r.isProjects = True
r.name = "All Projects"
r.template = "projects.html"
r.fileName = "projects.html"
r.authorLink = authorRoot
r.projects = self.getData("project", None, None)
return [r]
def get_editions():
bp = self.bp
r = AttrDict()
r.boilerplate = dict(
leftText=bp.editionsLeftText,
rightText=bp.editionsRightText,
rightUrl=bp.editionsRightUrl,
)
r.isEditions = True
r.name = "All Editions"
r.template = "editions.html"
r.fileName = "editions.html"
r.authorLink = authorRoot
r.editions = self.getData("edition", None, None)
return [r]
def get_project():
info = dbData[kind]
Fcreator = Content.makeField("creator", "project")
Fabstract = Content.makeField("abstract", "project")
Fdescription = Content.makeField("description", "project")
result = []
for num, item in info.items():
self.sanitizeMeta("project", item)
r = AttrDict()
r.name = item.title
r.num = num
r.fileName = f"project/{num}/index.html"
r.creator = Fcreator.bare(item, joined="; ")
r.abstract = Fabstract.logical(item)
r.description = Fdescription.logical(item)
r.visible = item.isVisible or False
result.append(r)
return result
def get_edition():
info = dbData[kind]
Fcreator = Content.makeField("creator", "edition")
Fabstract = Content.makeField("abstract", "edition")
Fdescription = Content.makeField("description", "edition")
Fsubject = Content.makeField("subject", "edition")
result = []
for pNum, eNums in info.items():
for eNum, item in eNums.items():
self.sanitizeMeta("edition", item)
r = AttrDict()
r.projectNum = pNum
r.projectFileName = f"project/{pNum}/index.html"
r.name = item.title
r.num = eNum
r.fileName = f"project/{pNum}/edition/{eNum}/index.html"
r.creator = Fcreator.bare(item, joined="; ")
r.abstract = Fabstract.logical(item)
r.description = Fdescription.logical(item)
r.subject = Fsubject.logical(item)
r.published = item.isPublished or False
result.append(r)
return result
def get_projectpages():
bp = self.bp
pInfo = dbData["project"]
eInfo = dbData["edition"]
result = []
for pNum in sorted(pInfo):
if pNumGiven is not None and pNum != pNumGiven:
continue
pItem = pInfo[pNum]
self.sanitizeMeta("project", pItem)
pId = pItem._id
pdc = pItem.dc
fileName = f"project/{pNum}/index.html"
pr = AttrDict()
pr.boilerplate = dict(
leftText=bp.projectLeftText,
rightText=bp.projectRightText,
rightUrl=bp.projectRightUrl,
)
pr.template = "project.html"
pr.fileName = fileName
pr.num = pNum
pr.name = pItem.title
pr.authorLink = f"{authorRoot}{pId}"
pr.visible = pItem.isVisible or False
pr.dc = pdc
pr.editions = []
thisEInfo = eInfo.get(pNum, {})
for eNum in sorted(thisEInfo):
eItem = thisEInfo[eNum]
self.sanitizeMeta("edition", eItem)
edc = eItem.dc
er = AttrDict()
er.projectNum = pNum
er.projectFileName = f"project/{pNum}/index.html"
er.fileName = f"project/{pNum}/edition/{eNum}/index.html"
er.num = eNum
er.name = eItem.title
er.dc = edc
er.published = eItem.isPublished or False
pr.editions.append(er)
result.append(pr)
return result
def wrapCitation(er):
dc = er.dc
doi = dc.doi
creator = dc.creator
published = dc.datePublished
title = dc.title
authorRep = ", ".join(creator)
yearRep = published.split("-", 1)[0]
hasDoi = doi and sum(1 for d in doi if d) > 0
myUrl = er.url
citeLink = (
", ".join(H.a(d, f"https://doi.org/{d}") for d in doi if d)
if hasDoi
else H.a(myUrl, myUrl)
)
citeText = f"""{authorRep}. {yearRep}. {title}. PURE3D. {citeLink}"""
return H.p(H.small(H.code(citeText)))
def get_editionpages():
viewers = self.getData("viewers", None, None)
viewersLean = tuple(
(
vw.name,
vw.isDefault,
tuple((vv.name, vv.isDefault) for vv in vw.versions),
)
for vw in viewers
)
bp = self.bp
pInfo = dbData["project"]
eInfo = dbData["edition"]
result = []
for pNum in sorted(pInfo):
if pNumGiven is not None and pNum != pNumGiven:
continue
pItem = pInfo[pNum]
pId = pItem._id
projectFileName = f"project/{pNum}/index.html"
projectName = pItem.get("title", pNum)
thisEInfo = eInfo.get(pNum, {})
for eNum in sorted(thisEInfo):
if eNumGiven is not None and eNum != eNumGiven:
continue
eItem = thisEInfo[eNum]
self.sanitizeMeta("edition", eItem)
eId = eItem._id
edc = eItem.dc
er = AttrDict()
er.boilerplate = dict(
leftText=bp.editionLeftText,
rightText=bp.editionRightText,
rightUrl=bp.editionRightUrl,
)
er.template = "edition.html"
er.projectNum = pNum
er.projectName = projectName
er.projectFileName = projectFileName
er.authorLink = f"{authorRoot}{pId}/{eId}"
fileBase = f"project/{pNum}/edition/{eNum}/index"
er.url = f"{Settings.pubUrl}/{fileBase}.html"
er.num = eNum
er.name = eItem.title
er.dc = edc
er.isPublished = eItem.ispublished or False
settings = eItem.settings
authorTool = settings.authorTool
origViewer = authorTool.name
origVersion = authorTool.name
er.sceneFile = authorTool.sceneFile
(
er.toc,
er.obfuscated,
er.peerInfo,
er.peerLogo,
) = Precheck.checkEdition(None, pNum, eNum, eItem, asPublished=True)
er.citation = wrapCitation(er)
for viewerInfo in viewers:
viewer = viewerInfo.name
element = viewerInfo.element
versions = viewerInfo.versions
isDefaultViewer = viewerInfo.isDefault
for versionInfo in versions:
version = versionInfo.name
isDefault = versionInfo.isDefault
ver = deepAttrDict(deepcopy(deepdict(er)))
ver.viewer = viewer
ver.version = version
ver.element = element
ver.fileName = f"{fileBase}-{viewer}-{version}.html"
isDefault = isDefaultViewer and isDefault
viewerSelector = genViewerSelector(
viewersLean,
viewer,
version,
origViewer,
origVersion,
fileBase,
)
ver.viewerSelector = viewerSelector
result.append(ver)
if isDefault:
ver = deepAttrDict(deepcopy(deepdict(ver)))
ver.fileName = f"{fileBase}.html"
result.append(ver)
return result
getFunc = locals().get(f"get_{kind}", None)
result = getFunc() if getFunc is not None else []
data[kind] = result
return result
def getDbData(self):
"""Get the raw data contained in the json export from Mongo DB.
This is the metadata of the site, the projects, and the editions.
We store them as is in member `dbData`.
Later we distil page data from this, i.e. the data that is ready to fill
in the variables of the templates.
We assume this data has been exported when projects and editions got published,
into files named `db.json`.
"""
Settings = self.Settings
dbFile = Settings.dbFile
dbData = self.dbData
pubModeDir = Settings.pubModeDir
projectDir = f"{pubModeDir}/project"
dbData["site"] = readJson(asFile=f"{pubModeDir}/{dbFile}")
rProjects = {}
dbData["project"] = rProjects
rEditions = {}
dbData["edition"] = rEditions
for p in dirContents(projectDir)[1]:
if not p.isdecimal():
continue
p = int(p)
pPath = f"{projectDir}/{p}"
rProjects[p] = readJson(asFile=f"{pPath}/{dbFile}")
for e in dirContents(f"{pPath}/edition")[1]:
if not e.isdecimal():
continue
e = int(e)
ePath = f"{pPath}/edition/{e}"
rEditions.setdefault(p, {})[e] = readJson(asFile=f"{ePath}/{dbFile}")
Classes
class Static (Settings, Messages, Content, Viewers, Tailwind, Handlebars)
-
All about generating static pages.
Expand source code Browse git
class Static: def __init__(self, Settings, Messages, Content, Viewers, Tailwind, Handlebars): """All about generating static pages.""" self.Settings = Settings self.Content = Content self.Tailwind = Tailwind self.Handlebars = Handlebars self.Messages = Messages Messages.debugAdd(self) self.Precheck = PrecheckCls(Settings, Messages, Content, Viewers) yamlDir = Settings.yamlDir yamlFile = f"{yamlDir}/{CONFIG_FILE}" cfg = readYaml(asFile=yamlFile) self.cfg = cfg self.data = AttrDict() self.dbData = AttrDict() def sanitizeMeta(self, table, record): """Checks for missing (sub)-fields in the Dublin Core. Any field that is missing will be supplied with a default value, most of the times it will be an empty list or string, but the licence will be an "All rights reserved" licence and the peer review kind will be "No peer review". Ideally the defaults should come from configuration, but because an admin can change the keywords, the defaults should then also be editable by an admin, but that goes to far for now. Strings in list fields will be converted to singleton lists, and markdown texts will be converted to html. Parameters ---------- table: string The kind of info: site, project, or edition. This influences which fields should be present. record: dict The record with metadata Returns ------- void The dict is changed in place. """ Content = self.Content Settings = self.Settings H = Settings.H fields = Content.getMetaFields(table, None, asDict=True) listKeys = Content.getListFields() markdownKeys = Content.getMarkdownFields() for key in fields: F = Content.makeField(key, table) value = F.logical(record) if value is None: value = fields[key].default if key in listKeys: if type(value) not in (list, tuple): value = [value] if value else [] if key in markdownKeys: value = ( "" if value is None else ( H.br().join(markdown(e) for e in value) if type(value) in {list, tuple} else markdown(value) ) ) F.setLogical(record, value) def genPages(self, pPubNum, ePubNum, featured=[1, 2, 3]): """Generate html pages for a published edition. We assume the data of the projects and editions is already in place. As to the viewers: we compare the viewers and versions in the `data/viewers` directory with the viewers and versions in the `published/viewers` directory, and we copy viewer versions that are missing in the latter from the former. Exactly what will be generated depends on the parameters. There are the following things to generate: * **S**: site wide files, outside projects * **P**: project wide files, outside editions * **E**: edition pages **S** will always be (re)generated. If a particular project is specified, the **P** for that project will also be (re)generated. If a particular edition is specified, the **E** for that edition will also be (re)generated. Parameters ---------- pPubNUm, ePubNUm: integer or boolean or void Specifies which project and edition must be (re)generated, if they are integers. The integers is the numbers of the published project and edition. The following combinations are possible: * `None`, `None`: only **S** is (re)generated; * `p`, `None`: **S** and **P** for project with number `p` are (re)generated; * `p`, `e`: **S** and **P** and **E** are (re)generated for project with number `p` and edition with number `e` within that project; * `True`, `True`: everything will be regenerated. featured: list of integer The list of publication numbers of featured projects. They will appear in a special display on the home page. Returns ------- boolean Whether the generation was successful. """ Messages = self.Messages Settings = self.Settings Tailwind = self.Tailwind Handlebars = self.Handlebars viewerDir = Settings.viewerDir pubModeDir = Settings.pubModeDir dataOutDir = f"{pubModeDir}/json" templateDir = Settings.templateDir partialsIn = Settings.partialsIn jsDir = Settings.jsDir imageDir = Settings.imageDir partials = {} compiledTemplates = {} if type(featured) is list: msg = "skipping featured project '{}'" featuredParsed = set() for f in featured: if type(f) is int: featuredParsed.add(f) elif type(f) is str: if f.isdecimal(): featuredParsed.add(int(f)) else: Messages.warning(msg=msg.format(f)) else: Messages.warning(msg=msg.format(f)) featured = sorted(featuredParsed) else: Messages.warning( msg="The featured projects are not given as list, will be set to 1,2,3" ) featured = [1, 2, 3] Messages.special( msg=f"Featured projects: {', '.join(str(f) for f in featured)}" ) self.featured = featured def updateStatic(fileType, srcDr): """Copy over static files. We are careful: instead of copying a folder, we merge, recursively, the source folder into the destination folder, and we do not delete anything from the destination. Hence the parameters `delete=False` and `level=-1` to `dirUpdate()`. We do this, because older parts of the site may depend on older static files. """ dstDir = f"{pubModeDir}/{fileType}" (good, c, d) = dirUpdate(srcDr, dstDir, level=-1, delete=False) report = f"{c:>3} copied, {d:>3} deleted" Messages.info( msg=f"{fileType} {c} copied", logmsg=f"{'updated':<10} {fileType:<12} {report:<24} to {dstDir}", ) return good def updateViewers(): """Copy over viewer versions. We are careful: instead of copying the folder with viewers from source to destination, we merge the source viewers with the destination viewers, without deleting destination viewers. And per viewer, instead of copying the viewer folder from source to destination, we merge the source versions of that viewer with the destination versions of that viewer, without deleting destination versions. But per version we just copy, and stop the recursive merging, because each viewer version is an integral whole, and we do not support that the same version of the same viewer is different between source and destination. """ srcDr = viewerDir dstDir = f"{pubModeDir}/viewers" (good, c, d) = dirUpdate( srcDr, dstDir, level=2, conservative=True, delete=False ) report = f"{c:>3} copied, {d:>3} deleted" Messages.info( msg=f"viewers {c} copied", logmsg=f"{'updated':<10} {'viewers':<12} {report:<24} to {dstDir}", ) nViewerVersions = 0 for viewer in dirContents(dstDir)[1]: nViewerVersions += len(dirContents(f"{dstDir}/{viewer}")[1]) msg = f"there are {nViewerVersions} viewer-version combinations" Messages.info(msg=msg, logmsg=msg) return (nViewerVersions, good) def registerPartials(): good = True for partialFile in dirAllFiles(partialsIn): pDir = dirNm(partialFile).replace(partialsIn, "").strip("/") pFile = fileNm(partialFile) if pFile.startswith("."): continue pName = stripExt(pFile) sep = "" if pDir == "" else "/" partial = f"{pDir}{sep}{pName}" with open(partialFile) as fh: pContent = COMMENT_RE.sub("", fh.read()) try: partials[partial] = Handlebars.compile(pContent) except Exception as e: Messages.error( logmsg=( f"Error in register partial {partial} : " f"{''.join(format_exception(e))}" ) ) good = False report = f"{len(partials):<3} pieces" Messages.info( msg=f"{report} compiled", logmsg=f"{'compiled':<10} {'partials':<12} {report:<24} to memory", ) return good def genTarget(target, pNum, eNum, nvv=1): items = self.getData(target, pNum, eNum) success = 0 failure = 0 good = True for item in items: templateFile = f"{templateDir}/{item.template}" if templateFile in compiledTemplates: template = compiledTemplates[templateFile] else: with open(templateFile) as fh: tContent = COMMENT_RE.sub("", fh.read()) try: template = Handlebars.compile(tContent) except Exception as e: Messages.error( logmsg=( f"Error compiling template {templateFile} : " f"{''.join(format_exception(e))}" ) ) template = None compiledTemplates[templateFile] = template if template is None: failure += 1 good = False continue try: result = template(item, partials=partials) except Exception as e: Messages.error( logmsg=( f"Error filling template {item.template} : " f"{''.join(format_exception(e))}" ) ) failure += 1 good = False continue for genDir, asData in ((pubModeDir, False), (dataOutDir, True)): path = f"{genDir}/{item.fileName}" if asData: ext = ".json" path = path.rsplit(".", 1)[0] + ext dirPart = dirNm(path) dirMake(dirPart) if asData: writeJson(deepdict(item), asFile=path) else: with open(path, "w") as fh: fh.write(result) success += 1 goodStr = f"{success:>3} ok" badStr = f"{failure:>3} XX" if failure else "" sep = ";" if failure else " " report = f"{goodStr}{sep} {badStr}" if target == "editionpages": report += ( f" = {(success + failure) // (nvv + 1)} eds x " f"(1 + {nvv} v-v)" ) Messages.info( msg=f"generated {target} {report}", logmsg=f"{'generated':<10} {target:<12} {report:<24} to {pubModeDir}", ) return good pType = type(pPubNum) eType = type(ePubNum) pIsInt = pType is int eIsInt = eType is int pNum = pPubNum is None eNum = ePubNum is None pAll = pPubNum is True eAll = ePubNum is True task = ( ("site",) if pNum and eNum else ( ("project", pPubNum) if pIsInt and eNum else ( ("edition", pPubNum, ePubNum) if pIsInt and eIsInt else ("all",) if pAll and eAll else ("none",) ) ) ) if task[0] == "none": Messages.error( msg="Page generation failed", logmsg=( "Page generation failed because of illegal parameter combination: " f"project {pPubNum}: {pType} and edition {ePubNum}: {eType}" ), ) return # site # project p # edition p e # all # none kind = task[0] targets = [] targets.append(("site", None, None)) targets.append(("textpages", None, None)) targets.append(("projects", None, None)) targets.append(("editions", None, None)) if kind == "all": targets.append(("projectpages", None, None)) targets.append(("editionpages", None, None)) elif kind in {"project", "edition"}: targets.append(("projectpages", pPubNum, None)) if kind == "edition": targets.append(("editionpages", pPubNum, ePubNum)) good = True for upd, srcDir in (("js", jsDir), ("images", imageDir)): if not updateStatic(upd, srcDir): good = False (nvv, thisGood) = updateViewers() if not thisGood: good = False if not registerPartials(): good = False if not Tailwind.generate(): good = False self.getDbData() for target in targets: if not genTarget(*target, nvv=nvv): good = False if good: msg = "All tasks successful" Messages.info(logmsg=msg) else: msg = "Page generation failed" Messages.error(logmsg=msg, msg=msg) return good def getData(self, kind, pNumGiven, eNumGiven): """Prepares page data of a certain kind. Pages are generated by filling in templates and partials on the basis of JSON data. Pages may require several kinds of data. For example, the index page needs data to fill in a list of projects and editions. Other pages may need the same kind of data. So we store the gathered data under the kinds they have been gathered. For some kinds we may restrict the data fetching to specified items: for `projectpages` and `editionpages`. When an edition has changed, we want to restrict the regeneration of pages to only those pages that need to change. And we also update things outside the projects and editions. Still, when an edition changes, the page with All editions also has to change. And if the edition was the first in a project to be published, a new project will be published as well, and hence the `All projects` page needs to change. If an edition is published next to other editions in a project, the project page needs to change, since it contains thumbnails of all its editions. So, the general rule is that we will always regenerate the thumbnails and the All-projects and All-edition pages, but not all of the project pages and edition pages. !!! note "Not all kinds will be restricted" The kinds `viewers`, `textpages`, `site` will never be restricted. The kinds `projects`, `editions` are needed for thumbnails, and are never restricted. The kinds `project`, `edition` are called by the collection of kinds `project` and `edition`, and are also not restricted. That leaves only the `projectpages` and `editionpages` needing to be restricted. Parameters ---------- kind: string The kind of data we need to prepare. pNumGiven: integer or void Restricts the data fetching to projects with this publication number eNumGiven: integer or void Restricts the data fetching to editions with this publication number Returns ------- dict or array The data itself. It is also stored in the member `data` of this object, under key `kind`. It will not be computed twice. """ Settings = self.Settings H = Settings.H Messages = self.Messages Content = self.Content Precheck = self.Precheck authorUrl = Settings.authorUrl backPrefix = Settings.backPrefix authorRoot = f"{authorUrl}/{backPrefix}/" cfg = self.cfg generation1 = cfg.generation dbData = self.dbData data = self.data if kind in data: return data[kind] texts = Content.getTexts() def get_viewers(): defaultViewer = Settings.viewerDefault result = [] for viewer, viewerConfig in Settings.viewers.items(): versions = viewerConfig.versions element = viewerConfig.modes.read.element isDefault = viewer == defaultViewer result.append( AttrDict( name=viewer, element=element, isDefault=isDefault, versions=[AttrDict(name=version) for version in versions], ) ) result[-1].versions[0].isDefault = True return result def get_site(): featured = self.featured Ftitle = Content.makeField("title", "site") info = dbData[kind] self.sanitizeMeta("site", info) bp = info.boilerplate self.bp = bp r = AttrDict() r.boilerplate = dict( leftText=bp.homeLeftText, rightText=bp.homeRightText, rightUrl=bp.homeRightUrl, ) r.isHome = True r.template = "home.html" r.fileName = "index.html" r.authorLink = authorRoot r.name = Ftitle.logical(info) r.dc = info.dc projects = self.getData("project", None, None) projectsIndex = {p.num: p for p in projects} projectsFeatured = [] for p in featured: if p not in projectsIndex: Messages.warning(f"featured project {p} does not exist") continue projectsFeatured.append(projectsIndex[p]) r.projects = projectsFeatured return [r] def get_textpages(): info = dbData["site"] bp = self.bp result = [] for textName, key in texts.items(): F = Content.makeField(key, "site") r = AttrDict() r.boilerplate = dict( leftText=bp[f"{textName}LeftText"], rightText=bp[f"{textName}RightText"], rightUrl=bp[f"{textName}RightUrl"], ) r.template = "text.html" r.authorLink = authorRoot r.name = prettify(textName) r[f"is{ucFirst(r.name)}"] = True r.fileName = f"{textName}.html" r.content = F.formatted(info) result.append(r) return result def get_projects(): bp = self.bp r = AttrDict() r.boilerplate = dict( leftText=bp.projectsLeftText, rightText=bp.projectsRightText, rightUrl=bp.projectsRightUrl, ) r.isProjects = True r.name = "All Projects" r.template = "projects.html" r.fileName = "projects.html" r.authorLink = authorRoot r.projects = self.getData("project", None, None) return [r] def get_editions(): bp = self.bp r = AttrDict() r.boilerplate = dict( leftText=bp.editionsLeftText, rightText=bp.editionsRightText, rightUrl=bp.editionsRightUrl, ) r.isEditions = True r.name = "All Editions" r.template = "editions.html" r.fileName = "editions.html" r.authorLink = authorRoot r.editions = self.getData("edition", None, None) return [r] def get_project(): info = dbData[kind] Fcreator = Content.makeField("creator", "project") Fabstract = Content.makeField("abstract", "project") Fdescription = Content.makeField("description", "project") result = [] for num, item in info.items(): self.sanitizeMeta("project", item) r = AttrDict() r.name = item.title r.num = num r.fileName = f"project/{num}/index.html" r.creator = Fcreator.bare(item, joined="; ") r.abstract = Fabstract.logical(item) r.description = Fdescription.logical(item) r.visible = item.isVisible or False result.append(r) return result def get_edition(): info = dbData[kind] Fcreator = Content.makeField("creator", "edition") Fabstract = Content.makeField("abstract", "edition") Fdescription = Content.makeField("description", "edition") Fsubject = Content.makeField("subject", "edition") result = [] for pNum, eNums in info.items(): for eNum, item in eNums.items(): self.sanitizeMeta("edition", item) r = AttrDict() r.projectNum = pNum r.projectFileName = f"project/{pNum}/index.html" r.name = item.title r.num = eNum r.fileName = f"project/{pNum}/edition/{eNum}/index.html" r.creator = Fcreator.bare(item, joined="; ") r.abstract = Fabstract.logical(item) r.description = Fdescription.logical(item) r.subject = Fsubject.logical(item) r.published = item.isPublished or False result.append(r) return result def get_projectpages(): bp = self.bp pInfo = dbData["project"] eInfo = dbData["edition"] result = [] for pNum in sorted(pInfo): if pNumGiven is not None and pNum != pNumGiven: continue pItem = pInfo[pNum] self.sanitizeMeta("project", pItem) pId = pItem._id pdc = pItem.dc fileName = f"project/{pNum}/index.html" pr = AttrDict() pr.boilerplate = dict( leftText=bp.projectLeftText, rightText=bp.projectRightText, rightUrl=bp.projectRightUrl, ) pr.template = "project.html" pr.fileName = fileName pr.num = pNum pr.name = pItem.title pr.authorLink = f"{authorRoot}{pId}" pr.visible = pItem.isVisible or False pr.dc = pdc pr.editions = [] thisEInfo = eInfo.get(pNum, {}) for eNum in sorted(thisEInfo): eItem = thisEInfo[eNum] self.sanitizeMeta("edition", eItem) edc = eItem.dc er = AttrDict() er.projectNum = pNum er.projectFileName = f"project/{pNum}/index.html" er.fileName = f"project/{pNum}/edition/{eNum}/index.html" er.num = eNum er.name = eItem.title er.dc = edc er.published = eItem.isPublished or False pr.editions.append(er) result.append(pr) return result def wrapCitation(er): dc = er.dc doi = dc.doi creator = dc.creator published = dc.datePublished title = dc.title authorRep = ", ".join(creator) yearRep = published.split("-", 1)[0] hasDoi = doi and sum(1 for d in doi if d) > 0 myUrl = er.url citeLink = ( ", ".join(H.a(d, f"https://doi.org/{d}") for d in doi if d) if hasDoi else H.a(myUrl, myUrl) ) citeText = f"""{authorRep}. {yearRep}. {title}. PURE3D. {citeLink}""" return H.p(H.small(H.code(citeText))) def get_editionpages(): viewers = self.getData("viewers", None, None) viewersLean = tuple( ( vw.name, vw.isDefault, tuple((vv.name, vv.isDefault) for vv in vw.versions), ) for vw in viewers ) bp = self.bp pInfo = dbData["project"] eInfo = dbData["edition"] result = [] for pNum in sorted(pInfo): if pNumGiven is not None and pNum != pNumGiven: continue pItem = pInfo[pNum] pId = pItem._id projectFileName = f"project/{pNum}/index.html" projectName = pItem.get("title", pNum) thisEInfo = eInfo.get(pNum, {}) for eNum in sorted(thisEInfo): if eNumGiven is not None and eNum != eNumGiven: continue eItem = thisEInfo[eNum] self.sanitizeMeta("edition", eItem) eId = eItem._id edc = eItem.dc er = AttrDict() er.boilerplate = dict( leftText=bp.editionLeftText, rightText=bp.editionRightText, rightUrl=bp.editionRightUrl, ) er.template = "edition.html" er.projectNum = pNum er.projectName = projectName er.projectFileName = projectFileName er.authorLink = f"{authorRoot}{pId}/{eId}" fileBase = f"project/{pNum}/edition/{eNum}/index" er.url = f"{Settings.pubUrl}/{fileBase}.html" er.num = eNum er.name = eItem.title er.dc = edc er.isPublished = eItem.ispublished or False settings = eItem.settings authorTool = settings.authorTool origViewer = authorTool.name origVersion = authorTool.name er.sceneFile = authorTool.sceneFile ( er.toc, er.obfuscated, er.peerInfo, er.peerLogo, ) = Precheck.checkEdition(None, pNum, eNum, eItem, asPublished=True) er.citation = wrapCitation(er) for viewerInfo in viewers: viewer = viewerInfo.name element = viewerInfo.element versions = viewerInfo.versions isDefaultViewer = viewerInfo.isDefault for versionInfo in versions: version = versionInfo.name isDefault = versionInfo.isDefault ver = deepAttrDict(deepcopy(deepdict(er))) ver.viewer = viewer ver.version = version ver.element = element ver.fileName = f"{fileBase}-{viewer}-{version}.html" isDefault = isDefaultViewer and isDefault viewerSelector = genViewerSelector( viewersLean, viewer, version, origViewer, origVersion, fileBase, ) ver.viewerSelector = viewerSelector result.append(ver) if isDefault: ver = deepAttrDict(deepcopy(deepdict(ver))) ver.fileName = f"{fileBase}.html" result.append(ver) return result getFunc = locals().get(f"get_{kind}", None) result = getFunc() if getFunc is not None else [] data[kind] = result return result def getDbData(self): """Get the raw data contained in the json export from Mongo DB. This is the metadata of the site, the projects, and the editions. We store them as is in member `dbData`. Later we distil page data from this, i.e. the data that is ready to fill in the variables of the templates. We assume this data has been exported when projects and editions got published, into files named `db.json`. """ Settings = self.Settings dbFile = Settings.dbFile dbData = self.dbData pubModeDir = Settings.pubModeDir projectDir = f"{pubModeDir}/project" dbData["site"] = readJson(asFile=f"{pubModeDir}/{dbFile}") rProjects = {} dbData["project"] = rProjects rEditions = {} dbData["edition"] = rEditions for p in dirContents(projectDir)[1]: if not p.isdecimal(): continue p = int(p) pPath = f"{projectDir}/{p}" rProjects[p] = readJson(asFile=f"{pPath}/{dbFile}") for e in dirContents(f"{pPath}/edition")[1]: if not e.isdecimal(): continue e = int(e) ePath = f"{pPath}/edition/{e}" rEditions.setdefault(p, {})[e] = readJson(asFile=f"{ePath}/{dbFile}")
Methods
def genPages(self, pPubNum, ePubNum, featured=[1, 2, 3])
-
Generate html pages for a published edition.
We assume the data of the projects and editions is already in place. As to the viewers: we compare the viewers and versions in the
data/viewers
directory with the viewers and versions in thepublished/viewers
directory, and we copy viewer versions that are missing in the latter from the former.Exactly what will be generated depends on the parameters.
There are the following things to generate:
- S: site wide files, outside projects
- P: project wide files, outside editions
- E: edition pages
S will always be (re)generated.
If a particular project is specified, the P for that project will also be (re)generated.
If a particular edition is specified, the E for that edition will also be (re)generated.
Parameters
pPubNUm
,ePubNUm
:integer
orboolean
orvoid
-
Specifies which project and edition must be (re)generated, if they are integers. The integers is the numbers of the published project and edition.
The following combinations are possible:
None
,None
: only S is (re)generated;p
,None
: S and P for project with numberp
are (re)generated;p
,e
: S and P and E are (re)generated for project with numberp
and edition with numbere
within that project;True
,True
: everything will be regenerated.
featured
:list
ofinteger
- The list of publication numbers of featured projects. They will appear in a special display on the home page.
Returns
boolean
- Whether the generation was successful.
def getData(self, kind, pNumGiven, eNumGiven)
-
Prepares page data of a certain kind.
Pages are generated by filling in templates and partials on the basis of JSON data. Pages may require several kinds of data. For example, the index page needs data to fill in a list of projects and editions. Other pages may need the same kind of data. So we store the gathered data under the kinds they have been gathered.
For some kinds we may restrict the data fetching to specified items: for
projectpages
andeditionpages
.When an edition has changed, we want to restrict the regeneration of pages to only those pages that need to change. And we also update things outside the projects and editions.
Still, when an edition changes, the page with All editions also has to change. And if the edition was the first in a project to be published, a new project will be published as well, and hence the
All projects
page needs to change.If an edition is published next to other editions in a project, the project page needs to change, since it contains thumbnails of all its editions.
So, the general rule is that we will always regenerate the thumbnails and the All-projects and All-edition pages, but not all of the project pages and edition pages.
Not all kinds will be restricted
The kinds
viewers
,textpages
,site
will never be restricted.The kinds
projects
,editions
are needed for thumbnails, and are never restricted.The kinds
project
,edition
are called by the collection of kindsproject
andedition
, and are also not restricted.That leaves only the
projectpages
andeditionpages
needing to be restricted.Parameters
kind
:string
- The kind of data we need to prepare.
pNumGiven
:integer
orvoid
- Restricts the data fetching to projects with this publication number
eNumGiven
:integer
orvoid
- Restricts the data fetching to editions with this publication number
Returns
dict
orarray
- The data itself.
It is also stored in the member
data
of this object, under keykind
. It will not be computed twice.
def getDbData(self)
-
Get the raw data contained in the json export from Mongo DB.
This is the metadata of the site, the projects, and the editions. We store them as is in member
dbData
.Later we distil page data from this, i.e. the data that is ready to fill in the variables of the templates.
We assume this data has been exported when projects and editions got published, into files named
db.json
. def sanitizeMeta(self, table, record)
-
Checks for missing (sub)-fields in the Dublin Core.
Any field that is missing will be supplied with a default value, most of the times it will be an empty list or string, but the licence will be an "All rights reserved" licence and the peer review kind will be "No peer review". Ideally the defaults should come from configuration, but because an admin can change the keywords, the defaults should then also be editable by an admin, but that goes to far for now.
Strings in list fields will be converted to singleton lists, and markdown texts will be converted to html.
Parameters
table
:string
- The kind of info: site, project, or edition. This influences which fields should be present.
record
:dict
- The record with metadata
Returns
void
- The dict is changed in place.