parser.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. import logging
  2. import re
  3. from pathlib import Path
  4. import cv2
  5. import yaml
  6. from fastapi import HTTPException
  7. from markdown_it import MarkdownIt
  8. from mdit_py_plugins.anchors import anchors_plugin as section_header
  9. from pydantic import ValidationError
  10. from app.git import git_is_file_tracked, git_ls_files
  11. from app.schema import (
  12. DocumentCheckout,
  13. DocumentEmail,
  14. DocumentIndex,
  15. DocumentOrders,
  16. DocumentPage,
  17. DocumentProduct,
  18. DocumentProductIndex,
  19. DocumentSite,
  20. )
  21. logger = logging.getLogger(__name__)
  22. def md(text: str) -> str:
  23. """Helper function to convert parsed markdown-formatted text to HTML."""
  24. md = (
  25. MarkdownIt("commonmark", {"breaks": True, "html": True})
  26. .enable("table")
  27. .use(section_header)
  28. )
  29. return md.render(text)
  30. def set_default_product_meta_fields(block) -> dict[str, str]:
  31. """
  32. Helper function to make sure all necessary fields expected by the
  33. document schema are present in dictionary. This prevents errors
  34. when in the text document some keys have been omitted (because
  35. unnecessary / empty), but are needed by the document scheme.
  36. """
  37. if block['template'] == 'product':
  38. if 'code' not in block:
  39. block['code'] = ''
  40. elif 'product_id' not in block:
  41. block['product_id'] = ''
  42. elif 'price' not in block:
  43. block['price'] = 0
  44. elif 'weight' not in block:
  45. block['weight'] = 0
  46. elif 'customs_tariff_code' not in block:
  47. block['customs_tariff_code'] = ''
  48. elif 'origin_country_iso' not in block:
  49. block['origin_country_iso'] = ''
  50. elif 'category' not in block:
  51. block['category'] = ''
  52. if 'options' in block and len(block['options']) > 0:
  53. if 'size' not in block['options']:
  54. block['options']['size'] = None
  55. if 'style' not in block['options']:
  56. block['options']['style'] = None
  57. else:
  58. block['options'] = {
  59. 'size':None,
  60. 'style': None,
  61. }
  62. if 'inventory' in block and len(block['inventory']) > 0:
  63. for item in block['inventory']:
  64. if 'size' not in item:
  65. item['size'] = None
  66. if 'style' not in item:
  67. item['style'] = None
  68. if 'amount' not in item:
  69. item['amount'] = 0
  70. else:
  71. block['inventory'] = [
  72. {
  73. 'size': None, 'style': None, 'amount': 0
  74. }
  75. ]
  76. return block
  77. def make_block_md(block: str) -> dict[str]:
  78. """Helper function to prepare a dictionary object for a block of type
  79. text. Block texts are free-form strings and not formatted with
  80. YAML, so we prepare the dictionary here.
  81. """
  82. block.strip()
  83. if block != "":
  84. return {"value": block, "type": "text"}
  85. def make_block_image(
  86. repo_path: str, meta_path: str, block: dict[str, str]
  87. ) -> dict[str, int]:
  88. """Helper function to enrich the image block with a few more info
  89. about the file it points to. We make use of this when displaying
  90. the image in the frontend template and prepare the correct srcset
  91. list of sizes we can display for the given image.
  92. """
  93. filepath = None
  94. if (meta_path != '.'):
  95. filepath = f"{repo_path}/{meta_path}/{block['url']}"
  96. else:
  97. filepath = f"{repo_path}/{block['url']}"
  98. img = cv2.imread(filepath)
  99. if img is not None and img.any():
  100. if 'caption' not in block:
  101. block['caption'] = ""
  102. block["info"] = {
  103. "width": round(img.shape[1]),
  104. "height": round(img.shape[0]),
  105. }
  106. return block
  107. else:
  108. return {
  109. "type": "image",
  110. "value": "",
  111. "url": "",
  112. "caption": "",
  113. "info": {
  114. "width": 0,
  115. "height": 0
  116. },
  117. }
  118. def parse_file(
  119. repo_path: str, input_path: str | Path, block_types: list[str]
  120. ) -> (
  121. DocumentSite
  122. | DocumentIndex
  123. | DocumentPage
  124. | DocumentProductIndex
  125. | DocumentProduct
  126. | DocumentCheckout
  127. | DocumentOrders
  128. | DocumentEmail
  129. ):
  130. """Parse file at given filepath: return a dictionary with meta
  131. dictionary and list of blocks.
  132. """
  133. document = {
  134. "meta": {
  135. "type": "",
  136. "title": "",
  137. "template": "",
  138. "path": "",
  139. },
  140. "blocks": [],
  141. }
  142. try:
  143. p = Path(input_path)
  144. except KeyError:
  145. document["meta"]["path"] = ""
  146. data = input_path.read_text()
  147. block_marker = "\n---\n"
  148. blocks = data.split(block_marker)
  149. blocks = [re.sub(r"^---\n", "", block) for block in blocks]
  150. # set meta_path to default value
  151. meta_path = document["meta"]["path"]
  152. for block in blocks:
  153. # we check if current block is a potential
  154. # yaml block, then check if any other block
  155. # has a field type in the ones "allowed".
  156. # in case the block.type has no matches
  157. # or it fails the yaml parser, we take it
  158. # as a markdown block
  159. try:
  160. block_yaml = yaml.safe_load(block)
  161. if block_yaml is not None:
  162. if type(block_yaml) is dict:
  163. # check if "valid" YAML dict is actually
  164. # a long string with a colon in the middle;
  165. # somehow this trips up the YAML parser and
  166. # we need to take care of it ourselves
  167. for k in block_yaml.keys():
  168. if len(k.split()) > 1 and ":" in k:
  169. block_txt = make_block_md(block)
  170. document["blocks"].append(block_txt)
  171. if "type" in block_yaml:
  172. if block_yaml["type"] == "meta":
  173. document["meta"] = set_default_product_meta_fields(block_yaml)
  174. for k, v in block_yaml.items():
  175. document["meta"][k] = v
  176. # set meta-path
  177. if "template" in block_yaml:
  178. doc_path = Path(p).relative_to(repo_path).parent
  179. document["meta"]["path"] = str(doc_path)
  180. meta_path = document["meta"]["path"]
  181. elif (
  182. "type" in block_yaml
  183. and block_yaml["type"].lower() in block_types
  184. ):
  185. if block_yaml["type"] == "image":
  186. make_block_image(repo_path, meta_path, block_yaml)
  187. document["blocks"].append(block_yaml)
  188. else:
  189. block_md = make_block_md(block)
  190. document["blocks"].append(block_md)
  191. except yaml.YAMLError:
  192. block_md = make_block_md(block)
  193. document["blocks"].append(block_md)
  194. try:
  195. if document["meta"]["template"] == "site":
  196. return DocumentSite(**document)
  197. elif document["meta"]["template"] == "index":
  198. return DocumentIndex(**document)
  199. elif document["meta"]["template"] == "page":
  200. return DocumentPage(**document)
  201. elif document["meta"]["template"] == "product-index":
  202. return DocumentProductIndex(**document)
  203. elif document["meta"]["template"] == "product":
  204. return DocumentProduct(**document)
  205. elif document["meta"]["template"] == "checkout":
  206. return DocumentCheckout(**document)
  207. elif document["meta"]["template"] == "orders":
  208. return DocumentOrders(**document)
  209. elif document["meta"]["template"] == "email":
  210. return DocumentEmail(**document)
  211. except ValidationError as e:
  212. logger.error("pydantic validation errors =>")
  213. for e in e.errors():
  214. logger.error(f"error => {e}")
  215. raise FileNotFoundError
  216. def read_dir(
  217. repo_path: str,
  218. document_match: list[str],
  219. document_exclude: list[str],
  220. block_types: list[str],
  221. exclude_doc: str | None = None,
  222. tree: str | None = None,
  223. ):
  224. """Run git ls-files with appropriate pattern matching to return list
  225. of desired git-tracked filepaths.
  226. """
  227. d = Path(repo_path).expanduser()
  228. pattern_matching = []
  229. [pattern_matching.append(f"*{ext}") for ext in document_match]
  230. [pattern_matching.append(f":!:{ext}") for ext in document_exclude]
  231. paths = git_ls_files(d, pattern_matching)
  232. paths = [p for p in paths if tree in str(p)]
  233. docs = []
  234. for p in paths:
  235. doc = parse_file(d, p, block_types)
  236. if doc.meta.path != exclude_doc:
  237. docs.append(doc)
  238. return docs
  239. def read_file(
  240. repo_path: str,
  241. filename: str,
  242. file_match: list[str],
  243. block_types: list[str],
  244. tree: str | None = None,
  245. ):
  246. """Helper function to read file from disk. Check if filepath's
  247. extension is allowed (by settings.toml) and filepath is tracked by
  248. git, then read its content.
  249. """
  250. filepath = None
  251. ext = file_match[0]
  252. if tree:
  253. p = f"{Path(repo_path)}/{tree}/{filename}"
  254. else:
  255. p = f"{Path(repo_path)}/{filename}"
  256. if Path(p).is_dir():
  257. file_path = f"{p}/index{ext}"
  258. if Path(file_path).is_file():
  259. filepath = file_path
  260. else:
  261. raise FileNotFoundError
  262. elif Path(f"{p}{ext}").is_file():
  263. filepath = f"{p}{ext}"
  264. if filepath:
  265. # get file path relative to the content repo
  266. # eg <path/to/repo/dir/file> => <dir>/<file>
  267. filename = str(Path(filepath).relative_to(repo_path))
  268. if git_is_file_tracked(repo_path, filename):
  269. doc = parse_file(repo_path, Path(filepath), block_types)
  270. return doc
  271. else:
  272. raise HTTPException(status_code=404, detail="Nothing was found here.")