-
{{ section.title|default:section.item.content|truncatechars:100 }}
-
#{{ section.item.id }}
+
{{ section.title|default:section.item.content|markdown_title|truncatechars:100 }}
+
{{ section.item|item_markdown }}
{% if section.item.comment %}
{{ section.item.comment|markdown }}
{% endif %}
diff --git a/core/templatetags/markdown_extras.py b/core/templatetags/markdown_extras.py
index 3a39a0e..45950fc 100644
--- a/core/templatetags/markdown_extras.py
+++ b/core/templatetags/markdown_extras.py
@@ -13,7 +13,8 @@ ALLOWED_TAGS = set(bleach.sanitizer.ALLOWED_TAGS) | {
ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class'], 'img': ['src', 'alt', 'title', 'class']}
FILE_RE = re.compile(r'!\[\[file:(\d+)\]\]')
FENCE_RE = re.compile(r'^\s*(`{3,}|~{3,})')
-HASH_TAG_AT_LINE_START_RE = re.compile(r'^(#{1,6})(?=\S)')
+HASH_TAG_AT_LINE_START_RE = re.compile(r'^(#{1,6})(?!#)(?=\S)')
+TAG_RE = re.compile(r'(?= len(fence):
+ fence = None
+ elif match:
+ fence = match.group(1)
+ lines.append(line)
+ else:
+ parts = re.split(r'(`[^`]*`)', line)
+ cleaned = ''.join(part if part.startswith('`') and part.endswith('`') else TAG_RE.sub('', part) for part in parts)
+ lines.append(re.sub(r'[ \t]{2,}', ' ', cleaned).strip())
+ return '\n'.join(lines).strip()
+
+
@register.filter
def markdown(text):
html = md.markdown(require_space_after_markdown_heading(text), extensions=['extra', 'sane_lists', 'nl2br'])
@@ -41,8 +62,17 @@ def markdown(text):
@register.filter
-def item_markdown(item):
- text = item.content or ''
+def markdown_title(text):
+ """Return a readable title for Markdown content without heading markers."""
+ for line in (text or '').splitlines():
+ title = line.strip()
+ if title:
+ return strip_tags_outside_code(re.sub(r'^#{1,6}\s+', '', title)).strip()
+ return ''
+
+
+def render_item_markdown(item, hide_tags=False):
+ text = strip_tags_outside_code(item.content) if hide_tags else (item.content or '')
attachments = {str(a.id): a for a in item.attachments.all()}
def replace(match):
@@ -54,6 +84,16 @@ def item_markdown(item):
return markdown(FILE_RE.sub(replace, text))
+@register.filter
+def item_markdown(item):
+ return render_item_markdown(item)
+
+
+@register.filter
+def collection_item_markdown(item):
+ return render_item_markdown(item, hide_tags=True)
+
+
@register.filter
def is_image(file_name):
return str(file_name).lower().split('?')[0].endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'))
diff --git a/core/tests.py b/core/tests.py
index 6564025..13b66e6 100644
--- a/core/tests.py
+++ b/core/tests.py
@@ -2,8 +2,8 @@ from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
-from .models import Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference
-from .templatetags.markdown_extras import markdown
+from .models import ApiKey, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference
+from .templatetags.markdown_extras import markdown, markdown_title
from .views import parse_due_command, parse_quick_content
@@ -98,15 +98,25 @@ class MarkdownHeadingTests(TestCase):
def test_heading_with_space_remains_a_heading(self):
self.assertIn('
Project ', str(markdown('# Project')))
+ self.assertIn('
Section ', str(markdown('## Section')))
def test_hash_in_fenced_code_is_unchanged(self):
rendered = str(markdown('```python\n# comment\n```'))
self.assertIn('# comment', rendered)
self.assertNotIn('\\# comment', rendered)
+ self.assertNotIn('
', rendered)
+
+ def test_hash_in_inline_code_is_not_a_heading(self):
+ rendered = str(markdown('```# Das ist Kommentar im Code```'))
+ self.assertIn('# Das ist Kommentar im Code', rendered)
+ self.assertNotIn('', rendered)
def test_underline_is_allowed(self):
self.assertIn('underlined ', str(markdown('underlined ')))
+ def test_markdown_title_strips_heading_marker(self):
+ self.assertEqual(markdown_title('## Ziel von my2dos\n\nText'), 'Ziel von my2dos')
+
class FormattingToolbarTests(TestCase):
def test_toolbar_and_shortcuts_are_available(self):
@@ -179,6 +189,21 @@ class TeamItemEditTests(TestCase):
response = self.client.get(reverse('item_editor', args=[item.id]))
self.assertContains(response, '', html=False)
+ def test_team_item_can_be_made_public_even_with_team_tag(self):
+ item = Item.objects.create(
+ owner=self.user, team=self.team, content='Original #docs', visibility=Item.Visibility.TEAM,
+ )
+ item.sync_metadata()
+
+ response = self.client.post(reverse('item_editor', args=[item.id]), {
+ 'content': 'Updated #docs', 'comment': '', 'visibility': Item.Visibility.PUBLIC, 'url': '',
+ })
+
+ self.assertRedirects(response, item.get_absolute_url())
+ item.refresh_from_db()
+ self.assertEqual(item.visibility, Item.Visibility.PUBLIC)
+ self.assertIsNone(item.team)
+
class CollectionTests(TestCase):
def setUp(self):
@@ -274,8 +299,10 @@ class CollectionTests(TestCase):
response = self.client.get(collection.get_absolute_url())
html = response.content.decode()
self.assertContains(response, 'Tag-Sammlung')
- self.assertLess(html.index('First #guide'), html.index('Second #guide'))
- self.assertNotContains(response, 'Ignored #guide')
+ self.assertLess(html.index('First'), html.index('Second'))
+ self.assertNotContains(response, 'First #guide')
+ self.assertNotContains(response, 'Second #guide')
+ self.assertNotContains(response, 'Ignored')
self.assertContains(response, reverse('collection_edit', args=[collection.id]))
self.assertNotContains(response, reverse('collection_manage', args=[collection.id]))
@@ -294,6 +321,26 @@ class CollectionTests(TestCase):
self.assertNotContains(response, 'Add existing note')
self.assertNotContains(response, 'section_id')
+ def test_collection_reading_view_hides_note_tags(self):
+ collection = Collection.objects.create(owner=self.user, title='Reading view')
+ note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='## Section #tutorial\nText #my2dos and `#code`')
+ CollectionSection.objects.create(collection=collection, item=note, position=0)
+ response = self.client.get(collection.get_absolute_url())
+ self.assertContains(response, 'Section ', html=True)
+ self.assertContains(response, '#code', html=True)
+ self.assertNotContains(response, '#tutorial')
+ self.assertNotContains(response, '#my2dos')
+
+ def test_collection_manage_links_note_editor_back_to_collection(self):
+ collection = Collection.objects.create(owner=self.user, title='Reading view')
+ note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='## Section')
+ CollectionSection.objects.create(collection=collection, item=note, position=0)
+ manage_url = reverse('collection_manage', args=[collection.id])
+ response = self.client.get(manage_url)
+ self.assertContains(response, f'{reverse("item_editor", args=[note.id])}?next={manage_url}')
+ self.assertContains(response, '>Section')
+ self.assertNotContains(response, '>## Section')
+
def test_public_collection_is_visible_without_login(self):
collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC)
self.client.logout()
@@ -325,6 +372,47 @@ class CollectionTests(TestCase):
self.assertEqual(note.visibility, Item.Visibility.PUBLIC)
+class ApiItemFilterTests(TestCase):
+ def setUp(self):
+ self.user = get_user_model().objects.create_user(username='api-filter', password='secret')
+ self.help_tag = Tag.objects.create(name='help')
+ self.api_key = ApiKey.objects.create(owner=self.user, name='Help CLI')
+ self.api_key.tags.add(self.help_tag)
+
+ def create_item(self, content):
+ item = Item.objects.create(owner=self.user, content=content)
+ item.sync_metadata()
+ return item
+
+ def test_api_key_scope_and_request_tags_are_combined(self):
+ matching = self.create_item('Keyboard shortcuts #help #tasten')
+ self.create_item('Git help #help #git')
+ self.create_item('Private keyboard note #tasten')
+
+ response = self.client.get(
+ reverse('api_items'), {'tag': 'tasten', 'tag_mode': 'all'},
+ HTTP_X_API_KEY=self.api_key.token,
+ )
+
+ self.assertEqual(response.status_code, 200)
+ ids = [item['id'] for item in response.json()['items']]
+ self.assertEqual(ids, [matching.id])
+
+ def test_api_items_supports_limit_and_search(self):
+ self.create_item('First #help #shell')
+ second = self.create_item('Second #help #shell keyboard')
+
+ response = self.client.get(
+ reverse('api_items'), {'tag': 'shell', 'q': 'keyboard', 'limit': '1'},
+ HTTP_X_API_KEY=self.api_key.token,
+ )
+
+ self.assertEqual(response.status_code, 200)
+ items = response.json()['items']
+ self.assertEqual(len(items), 1)
+ self.assertEqual(items[0]['id'], second.id)
+
+
class CopyButtonTests(TestCase):
def test_item_has_copy_button_with_original_text(self):
user = get_user_model().objects.create_user(username='tester', password='secret')
diff --git a/core/views.py b/core/views.py
index 9d8087e..e1c8b28 100644
--- a/core/views.py
+++ b/core/views.py
@@ -163,6 +163,8 @@ def user_can_delete_item(user, item):
def desired_item_scope(item, user, force_private=False):
if force_private:
return Item.Visibility.PRIVATE, None
+ if item.visibility == Item.Visibility.PUBLIC:
+ return Item.Visibility.PUBLIC, None
tag_names = {match.group(1).lower() for match in re.finditer(r'(?