]>
Commit | Line | Data |
---|---|---|
181d08f3 MT |
1 | #!/usr/bin/python3 |
2 | ||
4ed1dadb | 3 | import difflib |
181d08f3 | 4 | import logging |
6ac7e934 | 5 | import os.path |
181d08f3 | 6 | import re |
9e90e800 | 7 | import urllib.parse |
181d08f3 MT |
8 | |
9 | from . import misc | |
9523790a | 10 | from . import util |
181d08f3 MT |
11 | from .decorators import * |
12 | ||
181d08f3 MT |
13 | class Wiki(misc.Object): |
14 | def _get_pages(self, query, *args): | |
15 | res = self.db.query(query, *args) | |
16 | ||
17 | for row in res: | |
18 | yield Page(self.backend, row.id, data=row) | |
19 | ||
d398ca08 MT |
20 | def _get_page(self, query, *args): |
21 | res = self.db.get(query, *args) | |
22 | ||
23 | if res: | |
24 | return Page(self.backend, res.id, data=res) | |
25 | ||
86368c12 MT |
26 | def __iter__(self): |
27 | return self._get_pages( | |
28 | "SELECT wiki.* FROM wiki_current current \ | |
29 | LEFT JOIN wiki ON current.id = wiki.id \ | |
30 | WHERE current.deleted IS FALSE \ | |
31 | ORDER BY page", | |
32 | ) | |
33 | ||
c78ad26e MT |
34 | def make_path(self, page, path): |
35 | # Nothing to do for absolute links | |
36 | if path.startswith("/"): | |
37 | pass | |
38 | ||
39 | # Relative links (one-level down) | |
40 | elif path.startswith("./"): | |
41 | path = os.path.join(page, path) | |
42 | ||
43 | # All other relative links | |
44 | else: | |
45 | p = os.path.dirname(page) | |
46 | path = os.path.join(p, path) | |
47 | ||
48 | # Normalise links | |
49 | return os.path.normpath(path) | |
50 | ||
9ff59d70 MT |
51 | def page_exists(self, path): |
52 | page = self.get_page(path) | |
53 | ||
54 | # Page must have been found and not deleted | |
55 | return page and not page.was_deleted() | |
56 | ||
6ac7e934 | 57 | def get_page_title(self, page, default=None): |
50c8dc11 MT |
58 | # Try to retrieve title from cache |
59 | title = self.memcache.get("wiki:title:%s" % page) | |
60 | if title: | |
61 | return title | |
62 | ||
63 | # If the title has not been in the cache, we will | |
64 | # have to look it up | |
6ac7e934 MT |
65 | doc = self.get_page(page) |
66 | if doc: | |
50c8dc11 MT |
67 | title = doc.title |
68 | else: | |
69 | title = os.path.basename(page) | |
6ac7e934 | 70 | |
50c8dc11 MT |
71 | # Save in cache for forever |
72 | self.memcache.set("wiki:title:%s" % page, title) | |
73 | ||
74 | return title | |
6ac7e934 | 75 | |
181d08f3 MT |
76 | def get_page(self, page, revision=None): |
77 | page = Page.sanitise_page_name(page) | |
947224b4 MT |
78 | |
79 | # Split the path into parts | |
80 | parts = page.split("/") | |
81 | ||
82 | # Check if this is an action | |
83 | if any((part.startswith("_") for part in parts)): | |
84 | return | |
181d08f3 MT |
85 | |
86 | if revision: | |
d398ca08 | 87 | return self._get_page("SELECT * FROM wiki WHERE page = %s \ |
181d08f3 MT |
88 | AND timestamp = %s", page, revision) |
89 | else: | |
d398ca08 | 90 | return self._get_page("SELECT * FROM wiki WHERE page = %s \ |
181d08f3 MT |
91 | ORDER BY timestamp DESC LIMIT 1", page) |
92 | ||
11afe905 MT |
93 | def get_recent_changes(self, account, limit=None): |
94 | pages = self._get_pages("SELECT * FROM wiki \ | |
11afe905 MT |
95 | ORDER BY timestamp DESC") |
96 | ||
97 | for page in pages: | |
98 | if not page.check_acl(account): | |
99 | continue | |
100 | ||
101 | yield page | |
102 | ||
103 | limit -= 1 | |
104 | if not limit: | |
105 | break | |
181d08f3 | 106 | |
495e9dc4 | 107 | def create_page(self, page, author, content, changes=None, address=None): |
181d08f3 MT |
108 | page = Page.sanitise_page_name(page) |
109 | ||
aba5e58a MT |
110 | # Write page to the database |
111 | page = self._get_page("INSERT INTO wiki(page, author_uid, markdown, changes, address) \ | |
df01767e | 112 | VALUES(%s, %s, %s, %s, %s) RETURNING *", page, author.uid, content or None, changes, address) |
181d08f3 | 113 | |
50c8dc11 | 114 | # Update cache |
980e486d | 115 | self.memcache.set("wiki:title:%s" % page.page, page.title) |
50c8dc11 | 116 | |
aba5e58a MT |
117 | # Send email to all watchers |
118 | page._send_watcher_emails(excludes=[author]) | |
119 | ||
120 | return page | |
121 | ||
495e9dc4 | 122 | def delete_page(self, page, author, **kwargs): |
181d08f3 MT |
123 | # Do nothing if the page does not exist |
124 | if not self.get_page(page): | |
125 | return | |
126 | ||
127 | # Just creates a blank last version of the page | |
495e9dc4 | 128 | self.create_page(page, author=author, content=None, **kwargs) |
181d08f3 | 129 | |
3168788e MT |
130 | def make_breadcrumbs(self, url): |
131 | # Split and strip all empty elements (double slashes) | |
181d08f3 MT |
132 | parts = list(e for e in url.split("/") if e) |
133 | ||
3168788e | 134 | ret = [] |
b1bf7d48 | 135 | for part in ("/".join(parts[:i]) for i in range(1, len(parts))): |
3168788e | 136 | ret.append(("/%s" % part, self.get_page_title(part, os.path.basename(part)))) |
181d08f3 | 137 | |
3168788e | 138 | return ret |
181d08f3 | 139 | |
11afe905 | 140 | def search(self, query, account=None, limit=None): |
9523790a MT |
141 | res = self._get_pages("SELECT wiki.* FROM wiki_search_index search_index \ |
142 | LEFT JOIN wiki ON search_index.wiki_id = wiki.id \ | |
22e56c4a MT |
143 | WHERE search_index.document @@ websearch_to_tsquery('english', %s) \ |
144 | ORDER BY ts_rank(search_index.document, websearch_to_tsquery('english', %s)) DESC", | |
11afe905 | 145 | query, query) |
9523790a | 146 | |
df80be2c | 147 | pages = [] |
11afe905 MT |
148 | for page in res: |
149 | # Skip any pages the user doesn't have permission for | |
150 | if not page.check_acl(account): | |
151 | continue | |
152 | ||
153 | # Return any other pages | |
df80be2c | 154 | pages.append(page) |
11afe905 | 155 | |
df80be2c MT |
156 | # Break when we have found enough pages |
157 | if limit and len(pages) >= limit: | |
11afe905 | 158 | break |
9523790a | 159 | |
df80be2c MT |
160 | return pages |
161 | ||
9523790a MT |
162 | def refresh(self): |
163 | """ | |
164 | Needs to be called after a page has been changed | |
165 | """ | |
166 | self.db.execute("REFRESH MATERIALIZED VIEW wiki_search_index") | |
167 | ||
2f23c558 MT |
168 | def get_watchlist(self, account): |
169 | pages = self._get_pages( | |
170 | "WITH pages AS (SELECT * FROM wiki_current \ | |
171 | LEFT JOIN wiki ON wiki_current.id = wiki.id) \ | |
172 | SELECT * FROM wiki_watchlist watchlist \ | |
173 | LEFT JOIN pages ON watchlist.page = pages.page \ | |
174 | WHERE watchlist.uid = %s", | |
175 | account.uid, | |
176 | ) | |
177 | ||
178 | return sorted(pages) | |
179 | ||
11afe905 MT |
180 | # ACL |
181 | ||
182 | def check_acl(self, page, account): | |
183 | res = self.db.query("SELECT * FROM wiki_acls \ | |
184 | WHERE %s ILIKE (path || '%%') ORDER BY LENGTH(path) DESC LIMIT 1", page) | |
185 | ||
186 | for row in res: | |
187 | # Access not permitted when user is not logged in | |
188 | if not account: | |
189 | return False | |
190 | ||
191 | # If user is in a matching group, we grant permission | |
192 | for group in row.groups: | |
93402e56 | 193 | if account.is_member_of_group(group): |
11afe905 MT |
194 | return True |
195 | ||
196 | # Otherwise access is not permitted | |
197 | return False | |
198 | ||
199 | # If no ACLs are found, we permit access | |
200 | return True | |
201 | ||
f2cfd873 MT |
202 | # Files |
203 | ||
204 | def _get_files(self, query, *args): | |
205 | res = self.db.query(query, *args) | |
206 | ||
207 | for row in res: | |
208 | yield File(self.backend, row.id, data=row) | |
209 | ||
210 | def _get_file(self, query, *args): | |
211 | res = self.db.get(query, *args) | |
212 | ||
213 | if res: | |
214 | return File(self.backend, res.id, data=res) | |
215 | ||
216 | def get_files(self, path): | |
217 | files = self._get_files("SELECT * FROM wiki_files \ | |
218 | WHERE path = %s AND deleted_at IS NULL ORDER BY filename", path) | |
219 | ||
220 | return list(files) | |
221 | ||
ff14dea3 | 222 | def get_file_by_path(self, path, revision=None): |
f2cfd873 MT |
223 | path, filename = os.path.dirname(path), os.path.basename(path) |
224 | ||
ff14dea3 MT |
225 | if revision: |
226 | # Fetch a specific revision | |
227 | return self._get_file("SELECT * FROM wiki_files \ | |
228 | WHERE path = %s AND filename = %s AND created_at <= %s \ | |
229 | ORDER BY created_at DESC LIMIT 1", path, filename, revision) | |
230 | ||
231 | # Fetch latest version | |
232 | return self._get_file("SELECT * FROM wiki_files \ | |
233 | WHERE path = %s AND filename = %s AND deleted_at IS NULL", | |
234 | path, filename) | |
235 | ||
236 | def get_file_by_path_and_filename(self, path, filename): | |
f2cfd873 | 237 | return self._get_file("SELECT * FROM wiki_files \ |
ff14dea3 MT |
238 | WHERE path = %s AND filename = %s AND deleted_at IS NULL", |
239 | path, filename) | |
f2cfd873 MT |
240 | |
241 | def upload(self, path, filename, data, mimetype, author, address): | |
ff14dea3 MT |
242 | # Replace any existing files |
243 | file = self.get_file_by_path_and_filename(path, filename) | |
244 | if file: | |
245 | file.delete(author) | |
246 | ||
f2cfd873 | 247 | # Upload the blob first |
a3a8a163 MT |
248 | blob = self.db.get("INSERT INTO wiki_blobs(data) VALUES(%s) \ |
249 | ON CONFLICT (digest(data, %s)) DO UPDATE SET data = EXCLUDED.data \ | |
250 | RETURNING id", data, "MD5") | |
f2cfd873 MT |
251 | |
252 | # Create entry for file | |
253 | return self._get_file("INSERT INTO wiki_files(path, filename, author_uid, address, \ | |
254 | mimetype, blob_id, size) VALUES(%s, %s, %s, %s, %s, %s, %s) RETURNING *", path, | |
255 | filename, author.uid, address, mimetype, blob.id, len(data)) | |
256 | ||
2901b734 MT |
257 | def render(self, path, text): |
258 | r = WikiRenderer(self.backend, path) | |
181d08f3 | 259 | |
2901b734 | 260 | return r.render(text) |
e2205cff | 261 | |
154f6179 | 262 | |
2901b734 | 263 | class Page(misc.Object): |
181d08f3 MT |
264 | def init(self, id, data=None): |
265 | self.id = id | |
266 | self.data = data | |
267 | ||
dc847af5 MT |
268 | def __repr__(self): |
269 | return "<%s %s %s>" % (self.__class__.__name__, self.page, self.timestamp) | |
270 | ||
c21ffadb MT |
271 | def __eq__(self, other): |
272 | if isinstance(other, self.__class__): | |
273 | return self.id == other.id | |
274 | ||
0713d9ae MT |
275 | return NotImplemented |
276 | ||
181d08f3 MT |
277 | def __lt__(self, other): |
278 | if isinstance(other, self.__class__): | |
279 | if self.page == other.page: | |
280 | return self.timestamp < other.timestamp | |
281 | ||
282 | return self.page < other.page | |
283 | ||
0713d9ae MT |
284 | return NotImplemented |
285 | ||
181d08f3 MT |
286 | @staticmethod |
287 | def sanitise_page_name(page): | |
288 | if not page: | |
289 | return "/" | |
290 | ||
291 | # Make sure that the page name does NOT end with a / | |
292 | if page.endswith("/"): | |
293 | page = page[:-1] | |
294 | ||
295 | # Make sure the page name starts with a / | |
296 | if not page.startswith("/"): | |
297 | page = "/%s" % page | |
298 | ||
299 | # Remove any double slashes | |
300 | page = page.replace("//", "/") | |
301 | ||
302 | return page | |
303 | ||
304 | @property | |
305 | def url(self): | |
46b77977 | 306 | return urllib.parse.urljoin("/docs", self.page) |
181d08f3 | 307 | |
4ed1dadb MT |
308 | @property |
309 | def full_url(self): | |
46b77977 | 310 | return "https://www.ipfire.org/docs%s" % self.url |
4ed1dadb | 311 | |
181d08f3 MT |
312 | @property |
313 | def page(self): | |
314 | return self.data.page | |
315 | ||
316 | @property | |
317 | def title(self): | |
51e7a876 | 318 | return self._title or os.path.basename(self.page[1:]) |
181d08f3 MT |
319 | |
320 | @property | |
321 | def _title(self): | |
322 | if not self.markdown: | |
323 | return | |
324 | ||
325 | # Find first H1 headline in markdown | |
326 | markdown = self.markdown.splitlines() | |
327 | ||
0074e919 | 328 | m = re.match(r"^#\s*(.*)( #)?$", markdown[0]) |
181d08f3 MT |
329 | if m: |
330 | return m.group(1) | |
331 | ||
3b05ef6e MT |
332 | @lazy_property |
333 | def author(self): | |
334 | if self.data.author_uid: | |
335 | return self.backend.accounts.get_by_uid(self.data.author_uid) | |
336 | ||
181d08f3 MT |
337 | @property |
338 | def markdown(self): | |
c21ffadb | 339 | return self.data.markdown or "" |
181d08f3 MT |
340 | |
341 | @property | |
342 | def html(self): | |
2901b734 | 343 | return self.backend.wiki.render(self.page, self.markdown) |
addc18d5 | 344 | |
181d08f3 MT |
345 | @property |
346 | def timestamp(self): | |
347 | return self.data.timestamp | |
348 | ||
349 | def was_deleted(self): | |
4c13230c | 350 | return not self.markdown |
181d08f3 MT |
351 | |
352 | @lazy_property | |
353 | def breadcrumbs(self): | |
354 | return self.backend.wiki.make_breadcrumbs(self.page) | |
355 | ||
d4c68c5c MT |
356 | def is_latest_revision(self): |
357 | return self.get_latest_revision() == self | |
358 | ||
181d08f3 | 359 | def get_latest_revision(self): |
7d699684 MT |
360 | revisions = self.get_revisions() |
361 | ||
362 | # Return first object | |
363 | for rev in revisions: | |
364 | return rev | |
365 | ||
366 | def get_revisions(self): | |
367 | return self.backend.wiki._get_pages("SELECT * FROM wiki \ | |
368 | WHERE page = %s ORDER BY timestamp DESC", self.page) | |
091ac36b | 369 | |
c21ffadb MT |
370 | @lazy_property |
371 | def previous_revision(self): | |
372 | return self.backend.wiki._get_page("SELECT * FROM wiki \ | |
373 | WHERE page = %s AND timestamp < %s ORDER BY timestamp DESC \ | |
374 | LIMIT 1", self.page, self.timestamp) | |
375 | ||
d398ca08 MT |
376 | @property |
377 | def changes(self): | |
378 | return self.data.changes | |
379 | ||
11afe905 MT |
380 | # ACL |
381 | ||
382 | def check_acl(self, account): | |
383 | return self.backend.wiki.check_acl(self.page, account) | |
384 | ||
091ac36b MT |
385 | # Sidebar |
386 | ||
387 | @lazy_property | |
388 | def sidebar(self): | |
389 | parts = self.page.split("/") | |
390 | ||
391 | while parts: | |
3cc5f666 | 392 | sidebar = self.backend.wiki.get_page("%s/sidebar" % os.path.join(*parts)) |
091ac36b MT |
393 | if sidebar: |
394 | return sidebar | |
395 | ||
396 | parts.pop() | |
f2cfd873 | 397 | |
d64a1e35 MT |
398 | # Watchers |
399 | ||
4ed1dadb MT |
400 | @lazy_property |
401 | def diff(self): | |
402 | if self.previous_revision: | |
403 | diff = difflib.unified_diff( | |
404 | self.previous_revision.markdown.splitlines(), | |
405 | self.markdown.splitlines(), | |
406 | ) | |
407 | ||
408 | return "\n".join(diff) | |
409 | ||
aba5e58a MT |
410 | @property |
411 | def watchers(self): | |
412 | res = self.db.query("SELECT uid FROM wiki_watchlist \ | |
413 | WHERE page = %s", self.page) | |
414 | ||
415 | for row in res: | |
416 | # Search for account by UID and skip if none was found | |
417 | account = self.backend.accounts.get_by_uid(row.uid) | |
418 | if not account: | |
419 | continue | |
420 | ||
421 | # Return the account | |
422 | yield account | |
423 | ||
f2e25ded | 424 | def is_watched_by(self, account): |
d64a1e35 MT |
425 | res = self.db.get("SELECT 1 FROM wiki_watchlist \ |
426 | WHERE page = %s AND uid = %s", self.page, account.uid) | |
427 | ||
428 | if res: | |
429 | return True | |
430 | ||
431 | return False | |
432 | ||
433 | def add_watcher(self, account): | |
f2e25ded | 434 | if self.is_watched_by(account): |
d64a1e35 MT |
435 | return |
436 | ||
437 | self.db.execute("INSERT INTO wiki_watchlist(page, uid) \ | |
438 | VALUES(%s, %s)", self.page, account.uid) | |
439 | ||
440 | def remove_watcher(self, account): | |
441 | self.db.execute("DELETE FROM wiki_watchlist \ | |
442 | WHERE page = %s AND uid = %s", self.page, account.uid) | |
443 | ||
aba5e58a MT |
444 | def _send_watcher_emails(self, excludes=[]): |
445 | # Nothing to do if there was no previous revision | |
446 | if not self.previous_revision: | |
447 | return | |
448 | ||
449 | for watcher in self.watchers: | |
450 | # Skip everyone who is excluded | |
451 | if watcher in excludes: | |
452 | logging.debug("Excluding %s" % watcher) | |
453 | continue | |
454 | ||
516da0a9 MT |
455 | # Check permissions |
456 | if not self.backend.wiki.check_acl(self.page, watcher): | |
457 | logging.debug("Watcher %s does not have permissions" % watcher) | |
458 | continue | |
459 | ||
aba5e58a MT |
460 | logging.debug("Sending watcher email to %s" % watcher) |
461 | ||
4ed1dadb MT |
462 | # Compose message |
463 | self.backend.messages.send_template("wiki/messages/page-changed", | |
ba14044c | 464 | account=watcher, page=self, priority=-10) |
aba5e58a | 465 | |
9f1cfab7 | 466 | def restore(self, author, address, comment=None): |
d4c68c5c MT |
467 | changes = "Restore to revision from %s" % self.timestamp.isoformat() |
468 | ||
9f1cfab7 MT |
469 | # Append comment |
470 | if comment: | |
471 | changes = "%s: %s" % (changes, comment) | |
472 | ||
d4c68c5c MT |
473 | return self.backend.wiki.create_page(self.page, |
474 | author, self.markdown, changes=changes, address=address) | |
475 | ||
f2cfd873 MT |
476 | |
477 | class File(misc.Object): | |
478 | def init(self, id, data): | |
479 | self.id = id | |
480 | self.data = data | |
481 | ||
ff14dea3 MT |
482 | def __eq__(self, other): |
483 | if isinstance(other, self.__class__): | |
484 | return self.id == other.id | |
485 | ||
f2cfd873 MT |
486 | @property |
487 | def url(self): | |
488 | return os.path.join(self.path, self.filename) | |
489 | ||
490 | @property | |
491 | def path(self): | |
492 | return self.data.path | |
493 | ||
494 | @property | |
495 | def filename(self): | |
496 | return self.data.filename | |
497 | ||
498 | @property | |
499 | def mimetype(self): | |
500 | return self.data.mimetype | |
501 | ||
502 | @property | |
503 | def size(self): | |
504 | return self.data.size | |
505 | ||
8cb0bea4 MT |
506 | @lazy_property |
507 | def author(self): | |
508 | if self.data.author_uid: | |
509 | return self.backend.accounts.get_by_uid(self.data.author_uid) | |
510 | ||
511 | @property | |
512 | def created_at(self): | |
513 | return self.data.created_at | |
514 | ||
b26c705a MT |
515 | def delete(self, author=None): |
516 | self.db.execute("UPDATE wiki_files SET deleted_at = NOW(), deleted_by = %s \ | |
517 | WHERE id = %s", author.uid if author else None, self.id) | |
ff14dea3 MT |
518 | |
519 | @property | |
520 | def deleted_at(self): | |
521 | return self.data.deleted_at | |
522 | ||
523 | def get_latest_revision(self): | |
524 | revisions = self.get_revisions() | |
525 | ||
526 | # Return first object | |
527 | for rev in revisions: | |
528 | return rev | |
529 | ||
530 | def get_revisions(self): | |
531 | revisions = self.backend.wiki._get_files("SELECT * FROM wiki_files \ | |
2225edd9 | 532 | WHERE path = %s AND filename = %s ORDER BY created_at DESC", self.path, self.filename) |
ff14dea3 MT |
533 | |
534 | return list(revisions) | |
535 | ||
8cb0bea4 MT |
536 | def is_pdf(self): |
537 | return self.mimetype in ("application/pdf", "application/x-pdf") | |
538 | ||
f2cfd873 MT |
539 | def is_image(self): |
540 | return self.mimetype.startswith("image/") | |
541 | ||
8a62e589 MT |
542 | def is_vector_image(self): |
543 | return self.mimetype in ("image/svg+xml",) | |
544 | ||
545 | def is_bitmap_image(self): | |
546 | return self.is_image() and not self.is_vector_image() | |
547 | ||
f2cfd873 MT |
548 | @lazy_property |
549 | def blob(self): | |
550 | res = self.db.get("SELECT data FROM wiki_blobs \ | |
551 | WHERE id = %s", self.data.blob_id) | |
552 | ||
553 | if res: | |
554 | return bytes(res.data) | |
79dd9a0f MT |
555 | |
556 | def get_thumbnail(self, size): | |
8a62e589 MT |
557 | assert self.is_bitmap_image() |
558 | ||
75d9b3da MT |
559 | cache_key = "-".join((self.path, util.normalize(self.filename), self.created_at.isoformat(), "%spx" % size)) |
560 | ||
561 | # Try to fetch the data from the cache | |
562 | thumbnail = self.memcache.get(cache_key) | |
563 | if thumbnail: | |
564 | return thumbnail | |
565 | ||
566 | # Generate the thumbnail | |
5ef115cd | 567 | thumbnail = util.generate_thumbnail(self.blob, size) |
75d9b3da MT |
568 | |
569 | # Put it into the cache for forever | |
570 | self.memcache.set(cache_key, thumbnail) | |
571 | ||
572 | return thumbnail | |
2901b734 MT |
573 | |
574 | ||
575 | class WikiRenderer(misc.Object): | |
4ddad3e5 MT |
576 | schemas = ( |
577 | "ftp://", | |
578 | "git://", | |
579 | "http://", | |
580 | "https://", | |
581 | "rsync://", | |
582 | "sftp://", | |
583 | "ssh://", | |
584 | "webcal://", | |
585 | ) | |
586 | ||
587 | # Links | |
588 | links = re.compile(r"<a href=\"(.*?)\">(.*?)</a>") | |
2901b734 | 589 | |
c78ad26e | 590 | # Images |
e9c6d581 | 591 | images = re.compile(r"<img alt(?:=\"(.*?)\")? src=\"(.*?)\" (?:title=\"(.*?)\" )?/>") |
c78ad26e | 592 | |
2901b734 MT |
593 | def init(self, path): |
594 | self.path = path | |
595 | ||
4ddad3e5 MT |
596 | def _render_link(self, m): |
597 | url, text = m.groups() | |
2901b734 | 598 | |
e50a437a MT |
599 | # External Links |
600 | for schema in self.schemas: | |
601 | if url.startswith(schema): | |
602 | return """<a class="link-external" href="%s">%s</a>""" % \ | |
603 | (url, text or url) | |
604 | ||
4ddad3e5 MT |
605 | # Emails |
606 | if "@" in url: | |
607 | # Strip mailto: | |
608 | if url.startswith("mailto:"): | |
609 | url = url[7:] | |
2901b734 | 610 | |
4ddad3e5 MT |
611 | return """<a class="link-external" href="mailto:%s">%s</a>""" % \ |
612 | (url, text or url) | |
2901b734 | 613 | |
4ddad3e5 MT |
614 | # Everything else must be an internal link |
615 | path = self.backend.wiki.make_path(self.path, url) | |
2901b734 | 616 | |
46b77977 | 617 | return """<a href="/docs%s">%s</a>""" % \ |
4ddad3e5 | 618 | (path, text or self.backend.wiki.get_page_title(path)) |
2901b734 | 619 | |
c78ad26e | 620 | def _render_image(self, m): |
e9c6d581 | 621 | alt_text, url, caption = m.groups() |
2901b734 | 622 | |
4a1bfdd5 | 623 | html = """ |
3ae53eac MT |
624 | <div class="columns is-centered"> |
625 | <div class="column is-8"> | |
626 | <figure class="image"> | |
627 | <img src="/docs%s" alt="%s"> | |
628 | <figcaption class="figure-caption">%s</figcaption> | |
629 | </figure> | |
630 | </div> | |
631 | </div> | |
4a1bfdd5 MT |
632 | """ |
633 | ||
c78ad26e MT |
634 | # Skip any absolute and external URLs |
635 | if url.startswith("/") or url.startswith("https://") or url.startswith("http://"): | |
4a1bfdd5 | 636 | return html % (url, alt_text, caption or "") |
2901b734 | 637 | |
c78ad26e MT |
638 | # Try to split query string |
639 | url, delimiter, qs = url.partition("?") | |
2901b734 | 640 | |
c78ad26e MT |
641 | # Parse query arguments |
642 | args = urllib.parse.parse_qs(qs) | |
2901b734 | 643 | |
c78ad26e MT |
644 | # Build absolute path |
645 | url = self.backend.wiki.make_path(self.path, url) | |
2901b734 | 646 | |
c78ad26e MT |
647 | # Find image |
648 | file = self.backend.wiki.get_file_by_path(url) | |
649 | if not file or not file.is_image(): | |
650 | return "<!-- Could not find image %s in %s -->" % (url, self.path) | |
2901b734 | 651 | |
c78ad26e MT |
652 | # Scale down the image if not already done |
653 | if not "s" in args: | |
9ce45afb | 654 | args["s"] = "920" |
2901b734 | 655 | |
4a1bfdd5 MT |
656 | # Append arguments to the URL |
657 | if args: | |
658 | url = "%s?%s" % (url, urllib.parse.urlencode(args)) | |
659 | ||
660 | return html % (url, caption, caption or "") | |
2901b734 | 661 | |
c78ad26e MT |
662 | def render(self, text): |
663 | logging.debug("Rendering %s" % self.path) | |
2901b734 | 664 | |
9881e9ef MT |
665 | # Borrow this from the blog |
666 | text = self.backend.blog._render_text(text, lang="markdown") | |
667 | ||
4ddad3e5 MT |
668 | # Postprocess links |
669 | text = self.links.sub(self._render_link, text) | |
670 | ||
9881e9ef | 671 | # Postprocess images to <figure> |
c78ad26e MT |
672 | text = self.images.sub(self._render_image, text) |
673 | ||
9881e9ef | 674 | return text |