Improve collection editing and rendering
This commit is contained in:
@@ -22,15 +22,15 @@
|
|||||||
{% if sections or tag_items %}
|
{% if sections or tag_items %}
|
||||||
<nav class="collection-toc mb-5" aria-label="{% trans 'Contents' %}">
|
<nav class="collection-toc mb-5" aria-label="{% trans 'Contents' %}">
|
||||||
<div class="small fw-semibold text-uppercase text-muted mb-2">{% trans "Contents" %}</div>
|
<div class="small fw-semibold text-uppercase text-muted mb-2">{% trans "Contents" %}</div>
|
||||||
<ol class="mb-0">{% if collection.mode == 'tags' %}{% for item in tag_items %}<li><a href="#item-{{ item.id }}">{{ item.content|truncatechars:90 }}</a></li>{% endfor %}{% else %}{% for section in sections %}<li><a href="#section-{{ section.id }}">{{ section.title|default:section.item.content|truncatechars:90 }}</a></li>{% endfor %}{% endif %}</ol>
|
<ol class="mb-0">{% if collection.mode == 'tags' %}{% for item in tag_items %}<li><a href="#item-{{ item.id }}">{{ item.content|markdown_title|truncatechars:90 }}</a></li>{% endfor %}{% else %}{% for section in sections %}<li><a href="#section-{{ section.id }}">{{ section.title|default:section.item.content|markdown_title|truncatechars:90 }}</a></li>{% endfor %}{% endif %}</ol>
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="collection-flow content">
|
<div class="collection-flow content">
|
||||||
{% if collection.mode == 'tags' %}
|
{% if collection.mode == 'tags' %}
|
||||||
{% for item in tag_items %}<section id="item-{{ item.id }}" class="collection-flow-section">{{ item|item_markdown }}{% if item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ item.comment|markdown }}</div>{% endif %}</section>{% empty %}<div class="text-center text-muted py-5">{% trans "No notes match the selected tags yet." %}</div>{% endfor %}
|
{% for item in tag_items %}<section id="item-{{ item.id }}" class="collection-flow-section">{{ item|collection_item_markdown }}{% if item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ item.comment|markdown }}</div>{% endif %}</section>{% empty %}<div class="text-center text-muted py-5">{% trans "No notes match the selected tags yet." %}</div>{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for section in sections %}<section id="section-{{ section.id }}" class="collection-flow-section">{% if section.title %}<h2>{{ section.title }}</h2>{% endif %}{{ section.item|item_markdown }}{% if section.item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %}</section>{% empty %}<div class="text-center text-muted py-5">{% trans "This collection has no sections yet." %}</div>{% endfor %}
|
{% for section in sections %}<section id="section-{{ section.id }}" class="collection-flow-section">{% if section.title %}<h2>{{ section.title }}</h2>{% endif %}{{ section.item|collection_item_markdown }}{% if section.item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %}</section>{% empty %}<div class="text-center text-muted py-5">{% trans "This collection has no sections yet." %}</div>{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
{% if collection.description %}<div class="content lead mb-4">{{ collection.description|markdown }}</div>{% endif %}
|
{% if collection.description %}<div class="content lead mb-4">{{ collection.description|markdown }}</div>{% endif %}
|
||||||
|
|
||||||
{% if sections %}
|
{% if sections %}
|
||||||
<div class="card item-card mb-4"><div class="card-body"><div class="fw-semibold mb-2">{% trans "Contents" %}</div><ol class="mb-0">{% for section in sections %}<li><a href="#section-{{ section.id }}">{{ section.title|default:section.item.content|truncatechars:90 }}</a></li>{% endfor %}</ol></div></div>
|
<div class="card item-card mb-4"><div class="card-body"><div class="fw-semibold mb-2">{% trans "Contents" %}</div><ol class="mb-0">{% for section in sections %}<li><a href="#section-{{ section.id }}">{{ section.title|default:section.item.content|markdown_title|truncatechars:90 }}</a></li>{% endfor %}</ol></div></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if pending_item %}
|
{% if pending_item %}
|
||||||
@@ -39,8 +39,8 @@
|
|||||||
{% for section in sections %}
|
{% for section in sections %}
|
||||||
<section id="section-{{ section.id }}" class="card item-card"><div class="card-body">
|
<section id="section-{{ section.id }}" class="card item-card"><div class="card-body">
|
||||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||||
<h2 class="h3 mb-0">{{ section.title|default:section.item.content|truncatechars:100 }}</h2>
|
<h2 class="h3 mb-0">{{ section.title|default:section.item.content|markdown_title|truncatechars:100 }}</h2>
|
||||||
<a class="small text-muted" href="{{ section.item.get_absolute_url }}">#{{ section.item.id }}</a>
|
<div class="d-flex gap-2 align-items-center"><a class="btn btn-sm btn-outline-secondary" href="{% url 'item_editor' section.item.id %}?next={% url 'collection_manage' collection.id %}">{% trans "Edit note" %}</a><a class="small text-muted" href="{{ section.item.get_absolute_url }}">#{{ section.item.id }}</a></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">{{ section.item|item_markdown }}</div>
|
<div class="content">{{ section.item|item_markdown }}</div>
|
||||||
{% if section.item.comment %}<div class="content text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %}
|
{% if section.item.comment %}<div class="content text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ ALLOWED_TAGS = set(bleach.sanitizer.ALLOWED_TAGS) | {
|
|||||||
ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class'], 'img': ['src', 'alt', 'title', 'class']}
|
ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class'], 'img': ['src', 'alt', 'title', 'class']}
|
||||||
FILE_RE = re.compile(r'!\[\[file:(\d+)\]\]')
|
FILE_RE = re.compile(r'!\[\[file:(\d+)\]\]')
|
||||||
FENCE_RE = re.compile(r'^\s*(`{3,}|~{3,})')
|
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'(?<!\w)#([\wäöüÄÖÜß-]+)')
|
||||||
|
|
||||||
|
|
||||||
def require_space_after_markdown_heading(text):
|
def require_space_after_markdown_heading(text):
|
||||||
@@ -34,6 +35,26 @@ def require_space_after_markdown_heading(text):
|
|||||||
return '\n'.join(lines)
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_tags_outside_code(text):
|
||||||
|
"""Remove #tags for collection reading view while preserving code."""
|
||||||
|
lines = []
|
||||||
|
fence = None
|
||||||
|
for line in (text or '').splitlines():
|
||||||
|
match = FENCE_RE.match(line)
|
||||||
|
if fence:
|
||||||
|
lines.append(line)
|
||||||
|
if match and match.group(1)[0] == fence[0] and len(match.group(1)) >= 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
|
@register.filter
|
||||||
def markdown(text):
|
def markdown(text):
|
||||||
html = md.markdown(require_space_after_markdown_heading(text), extensions=['extra', 'sane_lists', 'nl2br'])
|
html = md.markdown(require_space_after_markdown_heading(text), extensions=['extra', 'sane_lists', 'nl2br'])
|
||||||
@@ -41,8 +62,17 @@ def markdown(text):
|
|||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def item_markdown(item):
|
def markdown_title(text):
|
||||||
text = item.content or ''
|
"""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()}
|
attachments = {str(a.id): a for a in item.attachments.all()}
|
||||||
|
|
||||||
def replace(match):
|
def replace(match):
|
||||||
@@ -54,6 +84,16 @@ def item_markdown(item):
|
|||||||
return markdown(FILE_RE.sub(replace, text))
|
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
|
@register.filter
|
||||||
def is_image(file_name):
|
def is_image(file_name):
|
||||||
return str(file_name).lower().split('?')[0].endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'))
|
return str(file_name).lower().split('?')[0].endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'))
|
||||||
|
|||||||
+92
-4
@@ -2,8 +2,8 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from .models import Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference
|
from .models import ApiKey, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference
|
||||||
from .templatetags.markdown_extras import markdown
|
from .templatetags.markdown_extras import markdown, markdown_title
|
||||||
from .views import parse_due_command, parse_quick_content
|
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):
|
def test_heading_with_space_remains_a_heading(self):
|
||||||
self.assertIn('<h1>Project</h1>', str(markdown('# Project')))
|
self.assertIn('<h1>Project</h1>', str(markdown('# Project')))
|
||||||
|
self.assertIn('<h2>Section</h2>', str(markdown('## Section')))
|
||||||
|
|
||||||
def test_hash_in_fenced_code_is_unchanged(self):
|
def test_hash_in_fenced_code_is_unchanged(self):
|
||||||
rendered = str(markdown('```python\n# comment\n```'))
|
rendered = str(markdown('```python\n# comment\n```'))
|
||||||
self.assertIn('# comment', rendered)
|
self.assertIn('# comment', rendered)
|
||||||
self.assertNotIn('\\# comment', rendered)
|
self.assertNotIn('\\# comment', rendered)
|
||||||
|
self.assertNotIn('<h1>', rendered)
|
||||||
|
|
||||||
|
def test_hash_in_inline_code_is_not_a_heading(self):
|
||||||
|
rendered = str(markdown('```# Das ist Kommentar im Code```'))
|
||||||
|
self.assertIn('<code># Das ist Kommentar im Code</code>', rendered)
|
||||||
|
self.assertNotIn('<h1>', rendered)
|
||||||
|
|
||||||
def test_underline_is_allowed(self):
|
def test_underline_is_allowed(self):
|
||||||
self.assertIn('<u>underlined</u>', str(markdown('<u>underlined</u>')))
|
self.assertIn('<u>underlined</u>', str(markdown('<u>underlined</u>')))
|
||||||
|
|
||||||
|
def test_markdown_title_strips_heading_marker(self):
|
||||||
|
self.assertEqual(markdown_title('## Ziel von my2dos\n\nText'), 'Ziel von my2dos')
|
||||||
|
|
||||||
|
|
||||||
class FormattingToolbarTests(TestCase):
|
class FormattingToolbarTests(TestCase):
|
||||||
def test_toolbar_and_shortcuts_are_available(self):
|
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]))
|
response = self.client.get(reverse('item_editor', args=[item.id]))
|
||||||
self.assertContains(response, '<option value="team" selected>', html=False)
|
self.assertContains(response, '<option value="team" selected>', 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):
|
class CollectionTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -274,8 +299,10 @@ class CollectionTests(TestCase):
|
|||||||
response = self.client.get(collection.get_absolute_url())
|
response = self.client.get(collection.get_absolute_url())
|
||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
self.assertContains(response, 'Tag-Sammlung')
|
self.assertContains(response, 'Tag-Sammlung')
|
||||||
self.assertLess(html.index('First #guide'), html.index('Second #guide'))
|
self.assertLess(html.index('First'), html.index('Second'))
|
||||||
self.assertNotContains(response, 'Ignored #guide')
|
self.assertNotContains(response, 'First #guide')
|
||||||
|
self.assertNotContains(response, 'Second #guide')
|
||||||
|
self.assertNotContains(response, 'Ignored')
|
||||||
self.assertContains(response, reverse('collection_edit', args=[collection.id]))
|
self.assertContains(response, reverse('collection_edit', args=[collection.id]))
|
||||||
self.assertNotContains(response, reverse('collection_manage', 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, 'Add existing note')
|
||||||
self.assertNotContains(response, 'section_id')
|
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, '<h2>Section</h2>', html=True)
|
||||||
|
self.assertContains(response, '<code>#code</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):
|
def test_public_collection_is_visible_without_login(self):
|
||||||
collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC)
|
collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC)
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
@@ -325,6 +372,47 @@ class CollectionTests(TestCase):
|
|||||||
self.assertEqual(note.visibility, Item.Visibility.PUBLIC)
|
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):
|
class CopyButtonTests(TestCase):
|
||||||
def test_item_has_copy_button_with_original_text(self):
|
def test_item_has_copy_button_with_original_text(self):
|
||||||
user = get_user_model().objects.create_user(username='tester', password='secret')
|
user = get_user_model().objects.create_user(username='tester', password='secret')
|
||||||
|
|||||||
+31
-1
@@ -163,6 +163,8 @@ def user_can_delete_item(user, item):
|
|||||||
def desired_item_scope(item, user, force_private=False):
|
def desired_item_scope(item, user, force_private=False):
|
||||||
if force_private:
|
if force_private:
|
||||||
return Item.Visibility.PRIVATE, None
|
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'(?<!\w)#([\wäöüÄÖÜß-]+)', item.content)}
|
tag_names = {match.group(1).lower() for match in re.finditer(r'(?<!\w)#([\wäöüÄÖÜß-]+)', item.content)}
|
||||||
membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first()
|
membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first()
|
||||||
if membership:
|
if membership:
|
||||||
@@ -187,6 +189,10 @@ def apply_team_from_tags(item, user, force_private=False):
|
|||||||
item.visibility = Item.Visibility.PRIVATE
|
item.visibility = Item.Visibility.PRIVATE
|
||||||
item.save(update_fields=['team', 'visibility', 'updated_at'])
|
item.save(update_fields=['team', 'visibility', 'updated_at'])
|
||||||
return
|
return
|
||||||
|
if item.visibility == Item.Visibility.PUBLIC:
|
||||||
|
item.team = None
|
||||||
|
item.save(update_fields=['team', 'updated_at'])
|
||||||
|
return
|
||||||
tag_names = list(item.tags.values_list('name', flat=True))
|
tag_names = list(item.tags.values_list('name', flat=True))
|
||||||
membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first()
|
membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first()
|
||||||
if membership:
|
if membership:
|
||||||
@@ -980,6 +986,29 @@ def api_item_suggestions(request):
|
|||||||
return JsonResponse({'items': data})
|
return JsonResponse({'items': data})
|
||||||
|
|
||||||
|
|
||||||
|
def api_items_queryset(request, user, api_key=None):
|
||||||
|
items = visible_items(user, api_key)
|
||||||
|
tags = [tag.strip().lstrip('#').lower() for tag in request.GET.getlist('tag') if tag.strip()]
|
||||||
|
if tags:
|
||||||
|
if request.GET.get('tag_mode') == 'all':
|
||||||
|
for tag in tags:
|
||||||
|
items = items.filter(tags__name=tag)
|
||||||
|
else:
|
||||||
|
items = items.filter(tags__name__in=tags).distinct()
|
||||||
|
q = request.GET.get('q', '').strip()
|
||||||
|
if q:
|
||||||
|
items = items.filter(Q(content__icontains=q) | Q(comment__icontains=q) | Q(url__icontains=q) | Q(tags__name__icontains=q)).distinct()
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def api_items_limit(request):
|
||||||
|
try:
|
||||||
|
limit = int(request.GET.get('limit', 200))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 200
|
||||||
|
return max(1, min(limit, 500))
|
||||||
|
|
||||||
|
|
||||||
def serialize_item(item):
|
def serialize_item(item):
|
||||||
return {
|
return {
|
||||||
'id': item.id,
|
'id': item.id,
|
||||||
@@ -1006,7 +1035,8 @@ def serialize_item(item):
|
|||||||
def api_items(request):
|
def api_items(request):
|
||||||
user = request.api_key.owner if request.api_key else request.user
|
user = request.api_key.owner if request.api_key else request.user
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
return JsonResponse({'items': [serialize_item(i) for i in visible_items(user, request.api_key)[:200]]})
|
items = api_items_queryset(request, user, request.api_key)[:api_items_limit(request)]
|
||||||
|
return JsonResponse({'items': [serialize_item(i) for i in items]})
|
||||||
data = json.loads(request.body or '{}') if request.content_type == 'application/json' else request.POST
|
data = json.loads(request.body or '{}') if request.content_type == 'application/json' else request.POST
|
||||||
raw = data.get('content', '')
|
raw = data.get('content', '')
|
||||||
raw, command_due_at, _ = parse_due_command(raw)
|
raw, command_due_at, _ = parse_due_command(raw)
|
||||||
|
|||||||
Reference in New Issue
Block a user