Markdown support for description fields added; Tests updated
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 1m44s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 6s
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 1m44s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 6s
This commit is contained in:
99
AGENTS.md
99
AGENTS.md
@@ -218,7 +218,7 @@ labhelper/
|
||||
│ │ ├── search.html # Search page with AJAX
|
||||
│ │ └── thing_detail.html # Thing details view with tags
|
||||
│ ├── templatetags/
|
||||
│ │ └── dict_extras.py # Custom template filter: get_item
|
||||
│ │ └── dict_extras.py # Custom template filters: get_item, render_markdown, truncate_markdown
|
||||
│ ├── admin.py # Admin configuration
|
||||
│ ├── apps.py # App configuration
|
||||
│ ├── forms.py # All forms and formsets
|
||||
@@ -260,7 +260,7 @@ labhelper/
|
||||
| **Box** | A storage box in the lab | `id` (CharField PK, max 10), `box_type` (FK) |
|
||||
| **Facet** | A category of tags (e.g., Priority, Category) | `name`, `slug`, `color`, `cardinality` (single/multiple) |
|
||||
| **Tag** | A tag value for a specific facet | `facet` (FK), `name` |
|
||||
| **Thing** | An item stored in a box | `name`, `box` (FK), `description`, `picture`, `tags` (M2M) |
|
||||
| **Thing** | An item stored in a box | `name`, `box` (FK), `description` (Markdown), `picture`, `tags` (M2M) |
|
||||
| **ThingFile** | File attachment for a Thing | `thing` (FK), `file`, `title`, `uploaded_at` |
|
||||
| **ThingLink** | Hyperlink for a Thing | `thing` (FK), `url`, `title`, `uploaded_at` |
|
||||
|
||||
@@ -397,41 +397,86 @@ The project uses a base template system at `labhelper/templates/base.html`. All
|
||||
### Template Best Practices
|
||||
|
||||
1. **Always extend base template**
|
||||
```django
|
||||
{% extends "base.html" %}
|
||||
```
|
||||
```django
|
||||
{% extends "base.html" %}
|
||||
```
|
||||
|
||||
2. **Use block system for content injection**
|
||||
- `title`: Page title tag
|
||||
- `page_header`: Page header with breadcrumbs
|
||||
- `content`: Main page content
|
||||
- `extra_css`: Additional styles
|
||||
- `extra_js`: Additional JavaScript
|
||||
- `title`: Page title tag
|
||||
- `page_header`: Page header with breadcrumbs
|
||||
- `content`: Main page content
|
||||
- `extra_css`: Additional styles
|
||||
- `extra_js`: Additional JavaScript
|
||||
|
||||
3. **Load required template tags**
|
||||
```django
|
||||
{% load static %}
|
||||
{% load mptt_tags %}
|
||||
{% load thumbnail %}
|
||||
```
|
||||
```django
|
||||
{% load static %}
|
||||
{% load mptt_tags %}
|
||||
{% load thumbnail %}
|
||||
{% load dict_extras %}
|
||||
```
|
||||
|
||||
4. **Use URL names for links**
|
||||
```django
|
||||
<a href="{% url 'box_detail' box.id %}">
|
||||
```
|
||||
```django
|
||||
<a href="{% url 'box_detail' box.id %}">
|
||||
```
|
||||
|
||||
5. **Use icons with Font Awesome**
|
||||
```django
|
||||
<i class="fas fa-box"></i>
|
||||
```
|
||||
```django
|
||||
<i class="fas fa-box"></i>
|
||||
```
|
||||
|
||||
6. **Add breadcrumbs for navigation**
|
||||
```django
|
||||
<p class="breadcrumb">
|
||||
<a href="/"><i class="fas fa-home"></i> Home</a> /
|
||||
<a href="/box/{{ box.id }}/"><i class="fas fa-box"></i> Box {{ box.id }}</a>
|
||||
</p>
|
||||
```
|
||||
```django
|
||||
<p class="breadcrumb">
|
||||
<a href="/"><i class="fas fa-home"></i> Home</a> /
|
||||
<a href="/box/{{ box.id }}/"><i class="fas fa-box"></i> Box {{ box.id }}</a>
|
||||
</p>
|
||||
```
|
||||
|
||||
### Markdown Support
|
||||
|
||||
The `Thing.description` field supports Markdown formatting with HTML sanitization for security.
|
||||
|
||||
**Available Template Filters:**
|
||||
|
||||
- `render_markdown`: Converts Markdown text to sanitized HTML with automatic link handling
|
||||
- Converts Markdown syntax (headers, lists, bold, italic, links, code, tables, etc.)
|
||||
- Sanitizes HTML using `bleach` to prevent XSS attacks
|
||||
- Automatically adds `target="_blank"` and `rel="noopener noreferrer"` to external links
|
||||
- Use in `thing_detail.html` for full rendered Markdown
|
||||
|
||||
- `truncate_markdown`: Converts Markdown to plain text and truncates
|
||||
- Strips HTML tags after Markdown conversion
|
||||
- Adds ellipsis (`...`) if text exceeds specified length (default: 100)
|
||||
- Use in `box_detail.html` or search API previews where space is limited
|
||||
|
||||
**Usage Examples:**
|
||||
```django
|
||||
<!-- Full Markdown rendering -->
|
||||
<div class="markdown-content">
|
||||
{{ thing.description|render_markdown }}
|
||||
</div>
|
||||
|
||||
<!-- Truncated plain text preview -->
|
||||
{{ thing.description|truncate_markdown:100 }}
|
||||
```
|
||||
|
||||
**Supported Markdown Features:**
|
||||
- Bold: `**text**` or `__text__`
|
||||
- Italic: `*text*` or `_text_`
|
||||
- Headers: `# Header 1`, `## Header 2`, etc.
|
||||
- Lists: `- item` or `1. item`
|
||||
- Links: `[text](url)`
|
||||
- Code: `` `code` `` or ` ```code block```
|
||||
- Blockquotes: `> quote`
|
||||
- Tables: `| A | B |\n|---|---|`
|
||||
|
||||
**Security:**
|
||||
- All Markdown is sanitized before rendering
|
||||
- Dangerous HTML tags (`<script>`, `<iframe>`, etc.) are stripped
|
||||
- Only safe HTML tags and attributes are allowed
|
||||
- External links automatically get `target="_blank"` and security attributes
|
||||
|
||||
## Forms
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ spec:
|
||||
mountPath: /data
|
||||
containers:
|
||||
- name: web
|
||||
image: git.baumann.gr/adebaumann/labhelper:0.049
|
||||
image: git.baumann.gr/adebaumann/labhelper:0.050
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% load thumbnail %}
|
||||
{% load dict_extras %}
|
||||
|
||||
{% block title %}Box {{ box.id }} - LabHelper{% endblock %}
|
||||
|
||||
@@ -65,7 +66,7 @@
|
||||
<span style="color: #999; font-style: italic; font-size: 13px;">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 15px 20px; color: #777;">{{ thing.description|default:"-" }}</td>
|
||||
<td style="padding: 15px 20px; color: #777;">{{ thing.description|truncate_markdown:100|default:"-" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% load thumbnail %}
|
||||
{% load dict_extras %}
|
||||
|
||||
{% block title %}{{ thing.name }} - LabHelper{% endblock %}
|
||||
|
||||
@@ -95,8 +96,8 @@
|
||||
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
|
||||
<i class="fas fa-align-left"></i> Description
|
||||
</div>
|
||||
<div style="font-size: 16px; color: #555; line-height: 1.6; white-space: pre-wrap;">
|
||||
{% spaceless %}{{ thing.description }}{% endspaceless %}
|
||||
<div class="markdown-content" style="font-size: 16px; color: #555; line-height: 1.6;">
|
||||
{{ thing.description|render_markdown }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -273,6 +274,85 @@
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.markdown-content p {
|
||||
margin: 0 0 1em 0;
|
||||
}
|
||||
.markdown-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.markdown-content h1, .markdown-content h2, .markdown-content h3,
|
||||
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
|
||||
margin: 1.5em 0 0.5em 0;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
.markdown-content h1:first-child, .markdown-content h2:first-child,
|
||||
.markdown-content h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.markdown-content ul, .markdown-content ol {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 2em;
|
||||
}
|
||||
.markdown-content li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
.markdown-content code {
|
||||
background: #f4f4f4;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.markdown-content pre {
|
||||
background: #f4f4f4;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.markdown-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.markdown-content blockquote {
|
||||
border-left: 4px solid #667eea;
|
||||
margin: 1em 0;
|
||||
padding: 0.5em 1em;
|
||||
background: #f8f9fa;
|
||||
color: #666;
|
||||
}
|
||||
.markdown-content a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
.markdown-content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.markdown-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.markdown-content th, .markdown-content td {
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.markdown-content th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
.markdown-content hr {
|
||||
border: none;
|
||||
border-top: 2px solid #e0e0e0;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$('#new_box').on('focus', function() {
|
||||
|
||||
@@ -1,7 +1,95 @@
|
||||
import bleach
|
||||
import markdown
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_item(dictionary, key):
|
||||
return dictionary.get(key)
|
||||
|
||||
|
||||
@register.filter
|
||||
def render_markdown(text):
|
||||
"""
|
||||
Convert Markdown text to sanitized HTML.
|
||||
|
||||
Uses bleach to sanitize HTML output and prevent XSS attacks.
|
||||
Allows common formatting tags: bold, italic, links, lists, code, etc.
|
||||
"""
|
||||
if not text:
|
||||
return ''
|
||||
|
||||
# Convert Markdown to HTML
|
||||
html = markdown.markdown(
|
||||
text,
|
||||
extensions=[
|
||||
'markdown.extensions.fenced_code',
|
||||
'markdown.extensions.tables',
|
||||
'markdown.extensions.nl2br',
|
||||
]
|
||||
)
|
||||
|
||||
# Allowed HTML tags and attributes for sanitization
|
||||
allowed_tags = [
|
||||
'p', 'br', 'strong', 'em', 'b', 'i', 'u',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'ul', 'ol', 'li',
|
||||
'a', 'code', 'pre',
|
||||
'blockquote', 'hr',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||
]
|
||||
allowed_attrs = {
|
||||
'a': ['href', 'title', 'target'],
|
||||
'th': ['align'],
|
||||
'td': ['align'],
|
||||
}
|
||||
|
||||
# Sanitize HTML
|
||||
clean_html = bleach.clean(
|
||||
html,
|
||||
tags=allowed_tags,
|
||||
attributes=allowed_attrs,
|
||||
strip=True
|
||||
)
|
||||
|
||||
# Add target="_blank" and rel="noopener" to external links
|
||||
clean_html = bleach.linkify(
|
||||
clean_html,
|
||||
callbacks=[_add_target_blank],
|
||||
skip_tags=['pre', 'code']
|
||||
)
|
||||
|
||||
return mark_safe(clean_html)
|
||||
|
||||
|
||||
def _add_target_blank(attrs, new=False):
|
||||
"""Add target="_blank" and rel="noopener noreferrer" to links."""
|
||||
attrs[(None, 'target')] = '_blank'
|
||||
attrs[(None, 'rel')] = 'noopener noreferrer'
|
||||
return attrs
|
||||
|
||||
|
||||
@register.filter
|
||||
def truncate_markdown(text, length=100):
|
||||
"""
|
||||
Convert Markdown to plain text and truncate.
|
||||
|
||||
Useful for showing a preview of Markdown content in listings.
|
||||
"""
|
||||
if not text:
|
||||
return ''
|
||||
|
||||
# Convert Markdown to HTML, then strip tags
|
||||
html = markdown.markdown(text)
|
||||
plain_text = bleach.clean(html, tags=[], strip=True)
|
||||
|
||||
# Normalize whitespace
|
||||
plain_text = ' '.join(plain_text.split())
|
||||
|
||||
# Truncate
|
||||
if len(plain_text) > length:
|
||||
return plain_text[:length].rsplit(' ', 1)[0] + '...'
|
||||
return plain_text
|
||||
178
boxes/tests.py
178
boxes/tests.py
@@ -577,7 +577,8 @@ class SearchApiTests(AuthTestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
results = response.json()['results']
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertLessEqual(len(results[0]['description']), 100)
|
||||
# Truncated text + '...' should be around 100 chars
|
||||
self.assertLessEqual(len(results[0]['description']), 105)
|
||||
|
||||
def test_search_api_limits_results(self):
|
||||
"""Search API should limit results to 50."""
|
||||
@@ -1708,3 +1709,178 @@ class ThingTagTests(AuthTestCase):
|
||||
results = response.json()['results']
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]['name'], 'Arduino Uno')
|
||||
|
||||
|
||||
class MarkdownRenderingTests(TestCase):
|
||||
"""Tests for Markdown rendering in templates."""
|
||||
|
||||
def test_render_markdown_basic(self):
|
||||
"""render_markdown filter should convert basic Markdown to HTML."""
|
||||
from boxes.templatetags.dict_extras import render_markdown
|
||||
result = render_markdown('**bold** and *italic*')
|
||||
self.assertIn('<strong>bold</strong>', result)
|
||||
self.assertIn('<em>italic</em>', result)
|
||||
|
||||
def test_render_markdown_links(self):
|
||||
"""render_markdown filter should convert links."""
|
||||
from boxes.templatetags.dict_extras import render_markdown
|
||||
result = render_markdown('[Example](https://example.com)')
|
||||
self.assertIn('href="https://example.com"', result)
|
||||
self.assertIn('target="_blank"', result)
|
||||
self.assertIn('rel="noopener noreferrer"', result)
|
||||
|
||||
def test_render_markdown_code(self):
|
||||
"""render_markdown filter should convert code blocks."""
|
||||
from boxes.templatetags.dict_extras import render_markdown
|
||||
result = render_markdown('`inline code`')
|
||||
self.assertIn('<code>inline code</code>', result)
|
||||
|
||||
def test_render_markdown_lists(self):
|
||||
"""render_markdown filter should convert lists."""
|
||||
from boxes.templatetags.dict_extras import render_markdown
|
||||
result = render_markdown('- item 1\n- item 2')
|
||||
self.assertIn('<ul>', result)
|
||||
self.assertIn('<li>item 1</li>', result)
|
||||
|
||||
def test_render_markdown_sanitizes_script(self):
|
||||
"""render_markdown filter should sanitize script tags."""
|
||||
from boxes.templatetags.dict_extras import render_markdown
|
||||
result = render_markdown('<script>alert("xss")</script>')
|
||||
self.assertNotIn('<script>', result)
|
||||
self.assertNotIn('</script>', result)
|
||||
|
||||
def test_render_markdown_empty_string(self):
|
||||
"""render_markdown filter should handle empty strings."""
|
||||
from boxes.templatetags.dict_extras import render_markdown
|
||||
result = render_markdown('')
|
||||
self.assertEqual(result, '')
|
||||
|
||||
def test_render_markdown_none(self):
|
||||
"""render_markdown filter should handle None."""
|
||||
from boxes.templatetags.dict_extras import render_markdown
|
||||
result = render_markdown(None)
|
||||
self.assertEqual(result, '')
|
||||
|
||||
def test_render_markdown_tables(self):
|
||||
"""render_markdown filter should convert tables."""
|
||||
from boxes.templatetags.dict_extras import render_markdown
|
||||
md = '| A | B |\n|---|---|\n| 1 | 2 |'
|
||||
result = render_markdown(md)
|
||||
self.assertIn('<table>', result)
|
||||
self.assertIn('<th>', result)
|
||||
self.assertIn('<td>', result)
|
||||
|
||||
def test_truncate_markdown_basic(self):
|
||||
"""truncate_markdown filter should truncate and strip Markdown."""
|
||||
from boxes.templatetags.dict_extras import truncate_markdown
|
||||
result = truncate_markdown('**bold** text here', 10)
|
||||
self.assertNotIn('**', result)
|
||||
self.assertNotIn('<strong>', result)
|
||||
|
||||
def test_truncate_markdown_long_text(self):
|
||||
"""truncate_markdown filter should add ellipsis for long text."""
|
||||
from boxes.templatetags.dict_extras import truncate_markdown
|
||||
long_text = 'This is a very long text that should be truncated'
|
||||
result = truncate_markdown(long_text, 20)
|
||||
self.assertTrue(result.endswith('...'))
|
||||
self.assertLessEqual(len(result), 25)
|
||||
|
||||
def test_truncate_markdown_empty(self):
|
||||
"""truncate_markdown filter should handle empty strings."""
|
||||
from boxes.templatetags.dict_extras import truncate_markdown
|
||||
result = truncate_markdown('')
|
||||
self.assertEqual(result, '')
|
||||
|
||||
def test_truncate_markdown_short_text(self):
|
||||
"""truncate_markdown filter should not truncate short text."""
|
||||
from boxes.templatetags.dict_extras import truncate_markdown
|
||||
result = truncate_markdown('Short', 100)
|
||||
self.assertEqual(result, 'Short')
|
||||
self.assertNotIn('...', result)
|
||||
|
||||
|
||||
class MarkdownInViewsTests(AuthTestCase):
|
||||
"""Tests for Markdown rendering in views."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
super().setUp()
|
||||
self.box_type = BoxType.objects.create(
|
||||
name='Standard Box',
|
||||
width=200,
|
||||
height=100,
|
||||
length=300
|
||||
)
|
||||
self.box = Box.objects.create(
|
||||
id='BOX001',
|
||||
box_type=self.box_type
|
||||
)
|
||||
|
||||
def test_thing_detail_renders_markdown(self):
|
||||
"""Thing detail page should render Markdown in description."""
|
||||
thing = Thing.objects.create(
|
||||
name='Test Item',
|
||||
box=self.box,
|
||||
description='**Bold text** and *italic*'
|
||||
)
|
||||
response = self.client.get(f'/thing/{thing.id}/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, '<strong>Bold text</strong>')
|
||||
self.assertContains(response, '<em>italic</em>')
|
||||
|
||||
def test_thing_detail_renders_markdown_links(self):
|
||||
"""Thing detail page should render Markdown links with target blank."""
|
||||
thing = Thing.objects.create(
|
||||
name='Test Item',
|
||||
box=self.box,
|
||||
description='Check [this link](https://example.com)'
|
||||
)
|
||||
response = self.client.get(f'/thing/{thing.id}/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'href="https://example.com"')
|
||||
self.assertContains(response, 'target="_blank"')
|
||||
|
||||
def test_thing_detail_sanitizes_html(self):
|
||||
"""Thing detail page should sanitize dangerous HTML in description."""
|
||||
thing = Thing.objects.create(
|
||||
name='Test Item',
|
||||
box=self.box,
|
||||
description='<script>alert("xss")</script>Normal text'
|
||||
)
|
||||
response = self.client.get(f'/thing/{thing.id}/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# The description should not contain script tags (they are stripped)
|
||||
# Note: The base template has a <script> tag for jQuery, so we check
|
||||
# specifically that the malicious script content is not executed
|
||||
self.assertContains(response, 'Normal text')
|
||||
# Check the markdown-content div doesn't have script tags
|
||||
self.assertNotContains(response, '<script>alert')
|
||||
|
||||
def test_box_detail_truncates_markdown(self):
|
||||
"""Box detail page should show truncated plain text description."""
|
||||
thing = Thing.objects.create(
|
||||
name='Test Item',
|
||||
box=self.box,
|
||||
description='**Bold** and a very long description that should be truncated in the box detail view'
|
||||
)
|
||||
response = self.client.get(f'/box/{self.box.id}/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# Should not contain raw Markdown syntax
|
||||
self.assertNotContains(response, '**Bold**')
|
||||
# Should contain the plain text (truncated)
|
||||
self.assertContains(response, 'Bold')
|
||||
|
||||
def test_search_api_strips_markdown(self):
|
||||
"""Search API should return plain text description."""
|
||||
thing = Thing.objects.create(
|
||||
name='Searchable Item',
|
||||
box=self.box,
|
||||
description='**Bold text** in description'
|
||||
)
|
||||
response = self.client.get('/search/api/?q=Searchable')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
results = response.json()['results']
|
||||
self.assertEqual(len(results), 1)
|
||||
# Description should be plain text without Markdown
|
||||
self.assertNotIn('**', results[0]['description'])
|
||||
self.assertIn('Bold text', results[0]['description'])
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import bleach
|
||||
import markdown
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
@@ -14,6 +17,18 @@ from .forms import (
|
||||
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
|
||||
|
||||
|
||||
def _strip_markdown(text, max_length=100):
|
||||
"""Convert Markdown to plain text and truncate."""
|
||||
if not text:
|
||||
return ''
|
||||
html = markdown.markdown(text)
|
||||
plain_text = bleach.clean(html, tags=[], strip=True)
|
||||
plain_text = ' '.join(plain_text.split())
|
||||
if len(plain_text) > max_length:
|
||||
return plain_text[:max_length].rsplit(' ', 1)[0] + '...'
|
||||
return plain_text
|
||||
|
||||
|
||||
@login_required
|
||||
def index(request):
|
||||
"""Home page with boxes and tags."""
|
||||
@@ -198,7 +213,7 @@ def search_api(request):
|
||||
'id': thing.id,
|
||||
'name': thing.name,
|
||||
'box': thing.box.id,
|
||||
'description': thing.description[:100] if thing.description else '',
|
||||
'description': _strip_markdown(thing.description),
|
||||
'tags': [
|
||||
{
|
||||
'name': tag.name,
|
||||
|
||||
Reference in New Issue
Block a user