Piki is a minimal wiki
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. from django.conf import settings
  2. from django.contrib.auth.models import User, Group
  3. from django.db import models
  4. from django.utils.translation import gettext as _
  5. from simple_history.models import HistoricalRecords
  6. from datetime import datetime
  7. import difflib
  8. import logging
  9. import os
  10. from zoneinfo import ZoneInfo
  11. from users.models import get_userprofile
  12. from pages import url_page
  13. import mycreole
  14. logger = logging.getLogger(settings.ROOT_LOGGER_NAME).getChild(__name__)
  15. class PikiPage(models.Model):
  16. SAVE_ON_CHANGE_FIELDS = ["rel_path", "page_txt", "tags", "deleted", "owner", "group"]
  17. #
  18. rel_path = models.CharField(unique=True, max_length=1000)
  19. page_txt = models.TextField(max_length=50000)
  20. tags = models.CharField(max_length=1000, null=True, blank=True)
  21. deleted = models.BooleanField(default=False)
  22. #
  23. creation_time = models.DateTimeField(null=True, blank=True)
  24. creation_user = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name="creation_user")
  25. modified_time = models.DateTimeField(null=True, blank=True)
  26. modified_user = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name="modified_user")
  27. #
  28. owner = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name="owner")
  29. group = models.ForeignKey(Group, null=True, blank=True, on_delete=models.SET_NULL, related_name="group")
  30. # owner_perms
  31. # group_perms
  32. # other_perms
  33. #
  34. history = HistoricalRecords()
  35. def __init__(self, *args, **kwargs):
  36. super().__init__(*args, **kwargs)
  37. def prepare_save(self, request):
  38. # Set date
  39. tmd = datetime.now(tz=ZoneInfo("UTC")).replace(microsecond=0)
  40. self.creation_time = self.creation_time or tmd
  41. self.modified_time = tmd
  42. # Set user
  43. self.creation_user = self.creation_user or request.user
  44. self.owner = self.owner or request.user
  45. self.modified_user = request.user
  46. def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
  47. if self.id and not force_update:
  48. orig = PikiPage.objects.get(id=self.id)
  49. for key in self.SAVE_ON_CHANGE_FIELDS:
  50. if getattr(self, key) != getattr(orig, key):
  51. break
  52. else:
  53. self.save_needed = False
  54. return False
  55. self.save_needed = True
  56. return models.Model.save(self, force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields)
  57. #
  58. # Set history datetime to modified datetime
  59. #
  60. @property
  61. def _history_date(self):
  62. return self.modified_time
  63. @_history_date.setter
  64. def _history_date(self, value):
  65. self.modified_time = value
  66. #
  67. # My information
  68. #
  69. @property
  70. def title(self):
  71. return self.rel_path.split("/")[-1]
  72. #
  73. # My methods
  74. #
  75. def render_to_html(self, request, history=None):
  76. if history:
  77. h = self.history.get(history_id=history)
  78. return self.render_text(request, h.page_txt)
  79. else:
  80. return self.render_text(request, self.page_txt)
  81. def user_datetime(self, request, dtm):
  82. try:
  83. up = get_userprofile(request.user)
  84. except AttributeError:
  85. tz = ZoneInfo("UTC")
  86. else:
  87. tz = ZoneInfo(up.timezone)
  88. #
  89. return datetime.astimezone(dtm, tz)
  90. def render_meta(self, request, history):
  91. # Page information
  92. meta = f'= {_("Meta data")}\n'
  93. meta += f'|{_("Created by")}:|{self.creation_user}|\n'
  94. meta += f'|{_("Created at")}:|{self.user_datetime(request, self.creation_time)}|\n'
  95. meta += f'|{_("Modified by")}:|{self.modified_user}|\n'
  96. meta += f'|{_("Modified at")}:|{self.user_datetime(request, self.modified_time)}|\n'
  97. meta += f'|{_("Owner")}:|{self.owner or "---"}|\n'
  98. meta += f'|{_("Group")}:|{self.group or "---"}|\n'
  99. meta += f'|{_("Tags")}|{self.tags or "---"}|\n'
  100. #
  101. # List of history page versions
  102. #
  103. hl = self.history.all()[1:]
  104. if len(hl) > 0:
  105. meta += f'= {_("History")}\n'
  106. meta += f'| ={_("Version")} | ={_("Date")} | ={_("Page")} | ={_("Meta data")} | ={_("Page changed")} | ={_("Tags changed")} | \n'
  107. # Current
  108. name = _("Current")
  109. meta += f"| {name} \
  110. | {self.user_datetime(request, self.modified_time)} \
  111. | [[{url_page(self.rel_path)} | Page]] \
  112. | [[{url_page(self.rel_path, meta=None)} | Meta]] |"
  113. page_content = self.page_txt.replace("\r\n", "\n").strip("\n")
  114. tags = self.tags
  115. for h_page in hl:
  116. page_changed = page_content != h_page.page_txt.replace("\r\n", "\n").strip("\n")
  117. tags_changed = tags != h_page.tags
  118. if page_changed or tags_changed:
  119. meta += " %s |" % ("Yes" if page_changed else "No")
  120. meta += " %s |" % ("Yes" if tags_changed else "No")
  121. meta += "\n"
  122. meta += f"| {h_page.history_id} \
  123. | {self.user_datetime(request, h_page.modified_time)} \
  124. | [[{url_page(self.rel_path, history=h_page.history_id)} | Page]] \
  125. | [[{url_page(self.rel_path, meta=None, history=h_page.history_id)} | Meta]] (with diff to current) |"
  126. page_content = h_page.page_txt[:].replace("\r\n", "\n").strip("\n")
  127. tags = h_page.tags
  128. meta += " --- | --- |\n"
  129. # Diff
  130. html_diff = ""
  131. if history:
  132. h_page = self.history.get(history_id=history)
  133. #
  134. meta += f'= {_("Page differences")}\n'
  135. #
  136. left_lines = self.page_txt.splitlines()
  137. right_lines = h_page.page_txt.splitlines()
  138. html_diff = difflib.HtmlDiff(wrapcolumn=80).make_table(left_lines, right_lines, "Current page", "Page Version %d" % history)
  139. #
  140. return mycreole.render_simple(meta) + html_diff
  141. #
  142. # Creole stuff
  143. #
  144. def render_text(self, request, txt):
  145. macros = {
  146. "subpages": self.macro_subpages,
  147. "allpages": self.macro_allpages,
  148. "subpagetree": self.macro_subpagetree,
  149. "allpagestree": self.macro_allpagestree,
  150. }
  151. return mycreole.render(request, txt, self.rel_path, macros=macros)
  152. def macro_subpages(self, *args, **kwargs):
  153. return self.macro_pages(*args, **kwargs)
  154. def macro_allpages(self, *args, **kwargs):
  155. kwargs["allpages"] = True
  156. return self.macro_pages(*args, **kwargs)
  157. def macro_allpagestree(self, *args, **kwargs):
  158. kwargs["allpages"] = True
  159. kwargs["tree"] = True
  160. return self.macro_pages(*args, **kwargs)
  161. def macro_subpagetree(self, * args, **kwargs):
  162. kwargs["tree"] = True
  163. return self.macro_pages(*args, **kwargs)
  164. def macro_pages(self, *args, **kwargs):
  165. allpages = kwargs.pop("allpages", False)
  166. tree = kwargs.pop("tree", False)
  167. #
  168. def parse_depth(s: str):
  169. try:
  170. return int(s)
  171. except ValueError:
  172. pass
  173. params = kwargs.get('', '')
  174. filter_str = ''
  175. depth = parse_depth(params)
  176. if depth is None:
  177. params = params.split(",")
  178. depth = parse_depth(params[0])
  179. if len(params) == 2:
  180. filter_str = params[1]
  181. elif depth is None:
  182. filter_str = params[0]
  183. #
  184. if not allpages:
  185. filter_str = os.path.join(self.rel_path, filter_str)
  186. #
  187. pages = PikiPage.objects.filter(rel_path__contains=filter_str)
  188. pl = page_list([p for p in pages if not p.deleted])
  189. #
  190. if tree:
  191. return "<pre>\n" + page_tree(pl).html() + "</pre>\n"
  192. else:
  193. return pl.html_list(depth=depth, filter_str=filter_str, parent_rel_path='' if allpages else self.rel_path)
  194. class page_list(list):
  195. def __init__(self, *args, **kwargs):
  196. return super().__init__(*args, **kwargs)
  197. def sort_basename(self):
  198. return list.sort(self, key=lambda x: os.path.basename(x.rel_path))
  199. def creole_list(self, depth=None, filter_str='', parent_rel_path=''):
  200. self.sort_basename()
  201. depth = depth or 9999 # set a random high value if None
  202. #
  203. rv = ""
  204. last_char = None
  205. for page in self:
  206. if page.rel_path.startswith(filter_str) and page.rel_path != filter_str:
  207. name = page.rel_path[len(parent_rel_path):].lstrip("/")
  208. if name.count('/') < depth:
  209. first_char = os.path.basename(name)[0].upper()
  210. if last_char != first_char:
  211. last_char = first_char
  212. rv += f"=== {first_char}\n"
  213. rv += f"* [[{url_page(page.rel_path)} | {name} ]]\n"
  214. return rv
  215. def html_list(self, depth=9999, filter_str='', parent_rel_path=''):
  216. return mycreole.render_simple(self.creole_list(depth, filter_str, parent_rel_path))
  217. class page_tree(dict):
  218. T_PATTERN = "├── "
  219. L_PATTERN = "└── "
  220. I_PATTERN = "│ "
  221. D_PATTERN = " "
  222. def __init__(self, pl: page_list):
  223. super().__init__()
  224. for page in pl:
  225. store_item = self
  226. for entry in page.rel_path.split("/"):
  227. if not entry in store_item:
  228. store_item[entry] = {}
  229. store_item = store_item[entry]
  230. def html(self, rel_path=None, fill=""):
  231. base = self
  232. try:
  233. for key in rel_path.split("/"):
  234. base = base[key]
  235. except AttributeError:
  236. rel_path = ''
  237. #
  238. rv = ""
  239. #
  240. l = len(base)
  241. for entry in sorted(list(base.keys())):
  242. l -= 1
  243. page_path = os.path.join(rel_path, entry)
  244. try:
  245. PikiPage.objects.get(rel_path=page_path)
  246. except PikiPage.DoesNotExist:
  247. pass
  248. else:
  249. entry = f'<a href="{url_page(page_path)}">{entry}</a>'
  250. rv += fill + (self.L_PATTERN if l == 0 else self.T_PATTERN) + entry + "\n"
  251. rv += self.html(page_path, fill=fill+(self.D_PATTERN if l == 0 else self.I_PATTERN))
  252. return rv