mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-09-04 16:26:39 +02:00
Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
63acde36de | ||
![]() |
70953a52b9 | ||
![]() |
f8fc360d84 | ||
![]() |
b2aeec2cac | ||
![]() |
cb7abbfacb | ||
![]() |
b844293342 | ||
![]() |
0f231bcd9f | ||
![]() |
9df270557f |
18
.github/workflows/main.yaml
vendored
Normal file
18
.github/workflows/main.yaml
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
name: linkding CI
|
||||||
|
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run_tests:
|
||||||
|
name: Run Django Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v1
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v1
|
||||||
|
with:
|
||||||
|
python-version: 3.7
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -r requirements.txt
|
||||||
|
- name: Run tests
|
||||||
|
run: python manage.py test
|
@@ -1,8 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.2.0 (09/01/2021)
|
||||||
|
- [**closed**] Add Favicon [#58](https://github.com/sissbruecker/linkding/issues/58)
|
||||||
|
- [**closed**] Make tags case-insensitive [#45](https://github.com/sissbruecker/linkding/issues/45)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.1.1 (01/01/2021)
|
## v1.1.1 (01/01/2021)
|
||||||
- [**enhancement**] Add docker-compose support [#54](https://github.com/sissbruecker/linkding/pull/54)
|
- [**enhancement**] Add docker-compose support [#54](https://github.com/sissbruecker/linkding/pull/54)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.1.0 (31/12/2020)
|
## v1.1.0 (31/12/2020)
|
||||||
|
BIN
assets/logo.afdesign
Normal file
BIN
assets/logo.afdesign
Normal file
Binary file not shown.
@@ -4,6 +4,8 @@ from django import forms
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
from bookmarks.utils import unique
|
||||||
|
|
||||||
|
|
||||||
class Tag(models.Model):
|
class Tag(models.Model):
|
||||||
name = models.CharField(max_length=64)
|
name = models.CharField(max_length=64)
|
||||||
@@ -18,7 +20,8 @@ def parse_tag_string(tag_string: str, delimiter: str = ','):
|
|||||||
if not tag_string:
|
if not tag_string:
|
||||||
return []
|
return []
|
||||||
names = tag_string.strip().split(delimiter)
|
names = tag_string.strip().split(delimiter)
|
||||||
names = [name for name in names if name]
|
names = [name.strip() for name in names if name]
|
||||||
|
names = unique(names, str.lower)
|
||||||
names.sort(key=str.lower)
|
names.sort(key=str.lower)
|
||||||
|
|
||||||
return names
|
return names
|
||||||
|
@@ -2,6 +2,7 @@ from django.contrib.auth.models import User
|
|||||||
from django.db.models import Q, Count, Aggregate, CharField, Value, BooleanField
|
from django.db.models import Q, Count, Aggregate, CharField, Value, BooleanField
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, Tag
|
from bookmarks.models import Bookmark, Tag
|
||||||
|
from bookmarks.utils import unique
|
||||||
|
|
||||||
|
|
||||||
class Concat(Aggregate):
|
class Concat(Aggregate):
|
||||||
@@ -41,7 +42,7 @@ def query_bookmarks(user: User, query_string: str):
|
|||||||
|
|
||||||
for tag_name in query['tag_names']:
|
for tag_name in query['tag_names']:
|
||||||
query_set = query_set.filter(
|
query_set = query_set.filter(
|
||||||
tags__name=tag_name
|
tags__name__iexact=tag_name
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sort by modification date
|
# Sort by modification date
|
||||||
@@ -74,7 +75,7 @@ def query_tags(user: User, query_string: str):
|
|||||||
|
|
||||||
for tag_name in query['tag_names']:
|
for tag_name in query['tag_names']:
|
||||||
query_set = query_set.filter(
|
query_set = query_set.filter(
|
||||||
bookmark__tags__name=tag_name
|
bookmark__tags__name__iexact=tag_name
|
||||||
)
|
)
|
||||||
|
|
||||||
return query_set.distinct()
|
return query_set.distinct()
|
||||||
@@ -95,6 +96,7 @@ def _parse_query_string(query_string):
|
|||||||
|
|
||||||
search_terms = [word for word in keywords if word[0] != '#']
|
search_terms = [word for word in keywords if word[0] != '#']
|
||||||
tag_names = [word[1:] for word in keywords if word[0] == '#']
|
tag_names = [word[1:] for word in keywords if word[0] == '#']
|
||||||
|
tag_names = unique(tag_names, str.lower)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'search_terms': search_terms,
|
'search_terms': search_terms,
|
||||||
|
@@ -1,20 +1,37 @@
|
|||||||
|
import logging
|
||||||
|
import operator
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from bookmarks.models import Tag
|
from bookmarks.models import Tag
|
||||||
|
from bookmarks.utils import unique
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_tags(tag_names: List[str], user: User):
|
def get_or_create_tags(tag_names: List[str], user: User):
|
||||||
return [get_or_create_tag(tag_name, user) for tag_name in tag_names]
|
tags = [get_or_create_tag(tag_name, user) for tag_name in tag_names]
|
||||||
|
return unique(tags, operator.attrgetter('id'))
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_tag(name: str, user: User):
|
def get_or_create_tag(name: str, user: User):
|
||||||
try:
|
try:
|
||||||
return Tag.objects.get(name=name, owner=user)
|
return Tag.objects.get(name__iexact=name, owner=user)
|
||||||
except Tag.DoesNotExist:
|
except Tag.DoesNotExist:
|
||||||
tag = Tag(name=name, owner=user)
|
tag = Tag(name=name, owner=user)
|
||||||
tag.date_added = timezone.now()
|
tag.date_added = timezone.now()
|
||||||
tag.save()
|
tag.save()
|
||||||
return tag
|
return tag
|
||||||
|
except Tag.MultipleObjectsReturned:
|
||||||
|
# Legacy databases might contain duplicate tags with different capitalization
|
||||||
|
first_tag = Tag.objects.filter(name__iexact=name, owner=user).first()
|
||||||
|
message = (
|
||||||
|
"Found multiple tags for the name '{0}' with different capitalization. "
|
||||||
|
"Using the first tag with the name '{1}'. "
|
||||||
|
"Since v.1.2 tags work case-insensitive, which means duplicates of the same name are not allowed anymore. "
|
||||||
|
"To solve this error remove the duplicate tag in admin."
|
||||||
|
).format(name, first_tag.name)
|
||||||
|
logger.error(message)
|
||||||
|
return first_tag
|
||||||
|
BIN
bookmarks/static/favicon.png
Normal file
BIN
bookmarks/static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
@@ -48,3 +48,8 @@ h2 {
|
|||||||
.container > .columns > .column:not(:first-child) {
|
.container > .columns > .column:not(:first-child) {
|
||||||
padding-left: 2rem;
|
padding-left: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove left padding from first pagination link
|
||||||
|
.pagination .page-item:first-child a {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
@@ -38,6 +38,10 @@ ul.bookmark-list {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bookmark-pagination {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-cloud {
|
.tag-cloud {
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
{% load shared %}
|
{% load shared %}
|
||||||
|
{% load pagination %}
|
||||||
|
|
||||||
<ul class="bookmark-list">
|
<ul class="bookmark-list">
|
||||||
{% for bookmark in bookmarks %}
|
{% for bookmark in bookmarks %}
|
||||||
@@ -30,13 +31,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="pagination">
|
|
||||||
{% if bookmarks.has_next %}
|
<div class="bookmark-pagination">
|
||||||
<a href="?{% update_query_string page=bookmarks.next_page_number %}"
|
{% pagination bookmarks %}
|
||||||
class="btn mr-2">< Older</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if bookmarks.has_previous %}
|
|
||||||
<a href="?{% update_query_string page=bookmarks.previous_page_number %}"
|
|
||||||
class="btn">Newer ></a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
@@ -5,6 +5,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="{% static 'favicon.png' %}" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
|
||||||
<meta name="description" content="Self-hosted bookmark service">
|
<meta name="description" content="Self-hosted bookmark service">
|
||||||
<meta name="robots" content="index,follow">
|
<meta name="robots" content="index,follow">
|
||||||
|
35
bookmarks/templates/bookmarks/pagination.html
Normal file
35
bookmarks/templates/bookmarks/pagination.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{% load shared %}
|
||||||
|
|
||||||
|
<ul class="pagination">
|
||||||
|
{% if page.has_previous %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a href="?{% update_query_string page=page.previous_page_number %}" tabindex="-1">Previous</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<a href="#" tabindex="-1">Previous</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for page_number in visible_page_numbers %}
|
||||||
|
{% if page_number >= 0 %}
|
||||||
|
<li class="page-item {% if page.number == page_number %}active{% endif %}">
|
||||||
|
<a href="?{% update_query_string page=page_number %}">{{ page_number }}</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item">
|
||||||
|
<span>...</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if page.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a href="?{% update_query_string page=page.next_page_number %}" tabindex="-1">Next</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<a href="#" tabindex="-1">Next</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
55
bookmarks/templatetags/pagination.py
Normal file
55
bookmarks/templatetags/pagination.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from functools import reduce
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
from django.core.paginator import Page
|
||||||
|
|
||||||
|
NUM_ADJACENT_PAGES = 2
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.inclusion_tag('bookmarks/pagination.html', name='pagination', takes_context=True)
|
||||||
|
def pagination(context, page: Page):
|
||||||
|
visible_page_numbers = get_visible_page_numbers(page.number, page.paginator.num_pages)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'page': page,
|
||||||
|
'visible_page_numbers': visible_page_numbers
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
|
||||||
|
"""
|
||||||
|
Generates a list of page indexes that should be rendered
|
||||||
|
The list can contain "holes" which indicate that a range of pages are truncated
|
||||||
|
Holes are indicated with a value of `-1`
|
||||||
|
:param current_page_number:
|
||||||
|
:param num_pages:
|
||||||
|
"""
|
||||||
|
visible_pages = set()
|
||||||
|
|
||||||
|
# Add adjacent pages around current page
|
||||||
|
visible_pages |= set(range(
|
||||||
|
max(1, current_page_number - NUM_ADJACENT_PAGES),
|
||||||
|
min(num_pages, current_page_number + NUM_ADJACENT_PAGES) + 1
|
||||||
|
))
|
||||||
|
|
||||||
|
# Add first page
|
||||||
|
visible_pages.add(1)
|
||||||
|
|
||||||
|
# Add last page
|
||||||
|
visible_pages.add(num_pages)
|
||||||
|
|
||||||
|
# Convert to sorted list
|
||||||
|
visible_pages = list(visible_pages)
|
||||||
|
visible_pages.sort()
|
||||||
|
|
||||||
|
def append_page(result: [int], page_number: int):
|
||||||
|
# Look for holes and insert a -1 as indicator
|
||||||
|
is_hole = len(result) > 0 and result[-1] < page_number - 1
|
||||||
|
if is_hole:
|
||||||
|
result.append(-1)
|
||||||
|
result.append(page_number)
|
||||||
|
return result
|
||||||
|
|
||||||
|
return reduce(append_page, visible_pages, [])
|
@@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
0
bookmarks/tests/__init__.py
Normal file
0
bookmarks/tests/__init__.py
Normal file
117
bookmarks/tests/test_pagination_tag.py
Normal file
117
bookmarks/tests/test_pagination_tag.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.test import SimpleTestCase, RequestFactory
|
||||||
|
from django.template import Template, RequestContext
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationTagTest(SimpleTestCase):
|
||||||
|
|
||||||
|
def render_template(self, num_items: int, page_size: int, current_page: int, url: str = '/test') -> str:
|
||||||
|
rf = RequestFactory()
|
||||||
|
request = rf.get(url)
|
||||||
|
paginator = Paginator(range(0, num_items), page_size)
|
||||||
|
page = paginator.page(current_page)
|
||||||
|
|
||||||
|
context = RequestContext(request, {'page': page})
|
||||||
|
template_to_render = Template(
|
||||||
|
'{% load pagination %}'
|
||||||
|
'{% pagination page %}'
|
||||||
|
)
|
||||||
|
return template_to_render.render(context)
|
||||||
|
|
||||||
|
def assertPrevLinkDisabled(self, html: str):
|
||||||
|
self.assertInHTML('''
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<a href="#" tabindex="-1">Previous</a>
|
||||||
|
</li>
|
||||||
|
''', html)
|
||||||
|
|
||||||
|
def assertPrevLink(self, html: str, page_number: int, href: str = None):
|
||||||
|
href = href if href else '?page={0}'.format(page_number)
|
||||||
|
self.assertInHTML('''
|
||||||
|
<li class="page-item">
|
||||||
|
<a href="{0}" tabindex="-1">Previous</a>
|
||||||
|
</li>
|
||||||
|
'''.format(href), html)
|
||||||
|
|
||||||
|
def assertNextLinkDisabled(self, html: str):
|
||||||
|
self.assertInHTML('''
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<a href="#" tabindex="-1">Next</a>
|
||||||
|
</li>
|
||||||
|
''', html)
|
||||||
|
|
||||||
|
def assertNextLink(self, html: str, page_number: int, href: str = None):
|
||||||
|
href = href if href else '?page={0}'.format(page_number)
|
||||||
|
self.assertInHTML('''
|
||||||
|
<li class="page-item">
|
||||||
|
<a href="{0}" tabindex="-1">Next</a>
|
||||||
|
</li>
|
||||||
|
'''.format(href), html)
|
||||||
|
|
||||||
|
def assertPageLink(self, html: str, page_number: int, active: bool, count: int = 1, href: str = None):
|
||||||
|
active_class = 'active' if active else ''
|
||||||
|
href = href if href else '?page={0}'.format(page_number)
|
||||||
|
self.assertInHTML('''
|
||||||
|
<li class="page-item {1}">
|
||||||
|
<a href="{2}">{0}</a>
|
||||||
|
</li>
|
||||||
|
'''.format(page_number, active_class, href), html, count=count)
|
||||||
|
|
||||||
|
def assertTruncationIndicators(self, html: str, count: int):
|
||||||
|
self.assertInHTML('''
|
||||||
|
<li class="page-item">
|
||||||
|
<span>...</span>
|
||||||
|
</li>
|
||||||
|
''', html, count=count)
|
||||||
|
|
||||||
|
def test_previous_disabled_on_page_1(self):
|
||||||
|
rendered_template = self.render_template(100, 10, 1)
|
||||||
|
self.assertPrevLinkDisabled(rendered_template)
|
||||||
|
|
||||||
|
def test_previous_enabled_after_page_1(self):
|
||||||
|
for page_number in range(2, 10):
|
||||||
|
rendered_template = self.render_template(100, 10, page_number)
|
||||||
|
self.assertPrevLink(rendered_template, page_number - 1)
|
||||||
|
|
||||||
|
def test_next_disabled_on_last_page(self):
|
||||||
|
rendered_template = self.render_template(100, 10, 10)
|
||||||
|
self.assertNextLinkDisabled(rendered_template)
|
||||||
|
|
||||||
|
def test_next_enabled_before_last_page(self):
|
||||||
|
for page_number in range(1, 9):
|
||||||
|
rendered_template = self.render_template(100, 10, page_number)
|
||||||
|
self.assertNextLink(rendered_template, page_number + 1)
|
||||||
|
|
||||||
|
def test_truncate_pages_start(self):
|
||||||
|
current_page = 1
|
||||||
|
expected_visible_pages = [1, 2, 3, 10]
|
||||||
|
rendered_template = self.render_template(100, 10, current_page)
|
||||||
|
for page_number in range(1, 10):
|
||||||
|
expected_occurrences = 1 if page_number in expected_visible_pages else 0
|
||||||
|
self.assertPageLink(rendered_template, page_number, page_number == current_page, expected_occurrences)
|
||||||
|
self.assertTruncationIndicators(rendered_template, 1)
|
||||||
|
|
||||||
|
def test_truncate_pages_middle(self):
|
||||||
|
current_page = 5
|
||||||
|
expected_visible_pages = [1, 3, 4, 5, 6, 7, 10]
|
||||||
|
rendered_template = self.render_template(100, 10, current_page)
|
||||||
|
for page_number in range(1, 10):
|
||||||
|
expected_occurrences = 1 if page_number in expected_visible_pages else 0
|
||||||
|
self.assertPageLink(rendered_template, page_number, page_number == current_page, expected_occurrences)
|
||||||
|
self.assertTruncationIndicators(rendered_template, 2)
|
||||||
|
|
||||||
|
def test_truncate_pages_near_end(self):
|
||||||
|
current_page = 9
|
||||||
|
expected_visible_pages = [1, 7, 8, 9, 10]
|
||||||
|
rendered_template = self.render_template(100, 10, current_page)
|
||||||
|
for page_number in range(1, 10):
|
||||||
|
expected_occurrences = 1 if page_number in expected_visible_pages else 0
|
||||||
|
self.assertPageLink(rendered_template, page_number, page_number == current_page, expected_occurrences)
|
||||||
|
self.assertTruncationIndicators(rendered_template, 1)
|
||||||
|
|
||||||
|
def test_extend_existing_query(self):
|
||||||
|
rendered_template = self.render_template(100, 10, 2, url='/test?q=cake')
|
||||||
|
self.assertPrevLink(rendered_template, 1, href='?q=cake&page=1')
|
||||||
|
self.assertPageLink(rendered_template, 1, False, href='?q=cake&page=1')
|
||||||
|
self.assertPageLink(rendered_template, 2, True, href='?q=cake&page=2')
|
||||||
|
self.assertNextLink(rendered_template, 3, href='?q=cake&page=3')
|
27
bookmarks/tests/test_tags_model.py
Normal file
27
bookmarks/tests/test_tags_model.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from bookmarks.models import parse_tag_string
|
||||||
|
|
||||||
|
|
||||||
|
class TagTestCase(TestCase):
|
||||||
|
|
||||||
|
def test_parse_tag_string_returns_list_of_tag_names(self):
|
||||||
|
self.assertCountEqual(parse_tag_string('book, movie, album'), ['book', 'movie', 'album'])
|
||||||
|
|
||||||
|
def test_parse_tag_string_respects_separator(self):
|
||||||
|
self.assertCountEqual(parse_tag_string('book movie album', ' '), ['book', 'movie', 'album'])
|
||||||
|
|
||||||
|
def test_parse_tag_string_orders_tag_names_alphabetically(self):
|
||||||
|
self.assertListEqual(parse_tag_string('book,movie,album'), ['album', 'book', 'movie'])
|
||||||
|
self.assertListEqual(parse_tag_string('Book,movie,album'), ['album', 'Book', 'movie'])
|
||||||
|
|
||||||
|
def test_parse_tag_string_handles_whitespace(self):
|
||||||
|
self.assertCountEqual(parse_tag_string('\t book, movie \t, album, \n\r'), ['album', 'book', 'movie'])
|
||||||
|
|
||||||
|
def test_parse_tag_string_handles_invalid_input(self):
|
||||||
|
self.assertListEqual(parse_tag_string(None), [])
|
||||||
|
self.assertListEqual(parse_tag_string(''), [])
|
||||||
|
|
||||||
|
def test_parse_tag_string_deduplicates_tag_names(self):
|
||||||
|
self.assertEqual(len(parse_tag_string('book,book,Book,BOOK')), 1)
|
||||||
|
|
67
bookmarks/tests/test_tags_service.py
Normal file
67
bookmarks/tests/test_tags_service.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import datetime
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from bookmarks.models import Tag
|
||||||
|
from bookmarks.services.tags import get_or_create_tag, get_or_create_tags
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class TagTestCase(TestCase):
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
|
||||||
|
|
||||||
|
def test_get_or_create_tag_should_create_new_tag(self):
|
||||||
|
get_or_create_tag('Book', self.user)
|
||||||
|
|
||||||
|
tags = Tag.objects.all()
|
||||||
|
|
||||||
|
self.assertEqual(len(tags), 1)
|
||||||
|
self.assertEqual(tags[0].name, 'Book')
|
||||||
|
self.assertEqual(tags[0].owner, self.user)
|
||||||
|
self.assertTrue(abs(tags[0].date_added - timezone.now()) < datetime.timedelta(seconds=10))
|
||||||
|
|
||||||
|
def test_get_or_create_tag_should_return_existing_tag(self):
|
||||||
|
first_tag = get_or_create_tag('Book', self.user)
|
||||||
|
second_tag = get_or_create_tag('Book', self.user)
|
||||||
|
|
||||||
|
tags = Tag.objects.all()
|
||||||
|
|
||||||
|
self.assertEqual(len(tags), 1)
|
||||||
|
self.assertEqual(first_tag.id, second_tag.id)
|
||||||
|
|
||||||
|
def test_get_or_create_tag_should_ignore_casing_when_looking_for_existing_tag(self):
|
||||||
|
first_tag = get_or_create_tag('Book', self.user)
|
||||||
|
second_tag = get_or_create_tag('book', self.user)
|
||||||
|
|
||||||
|
tags = Tag.objects.all()
|
||||||
|
|
||||||
|
self.assertEqual(len(tags), 1)
|
||||||
|
self.assertEqual(first_tag.id, second_tag.id)
|
||||||
|
|
||||||
|
def test_get_or_create_tag_should_handle_legacy_dbs_with_existing_duplicates(self):
|
||||||
|
Tag.objects.create(name='book', date_added=timezone.now(), owner=self.user)
|
||||||
|
Tag.objects.create(name='Book', date_added=timezone.now(), owner=self.user)
|
||||||
|
first_tag = get_or_create_tag('Book', self.user)
|
||||||
|
|
||||||
|
self.assertEqual(first_tag.id, first_tag.id)
|
||||||
|
|
||||||
|
def test_get_or_create_tags_should_return_tags(self):
|
||||||
|
books_tag = get_or_create_tag('Book', self.user)
|
||||||
|
movies_tag = get_or_create_tag('Movie', self.user)
|
||||||
|
|
||||||
|
tags = get_or_create_tags(['book', 'movie'], self.user)
|
||||||
|
|
||||||
|
self.assertEqual(len(tags), 2)
|
||||||
|
self.assertListEqual(tags, [books_tag, movies_tag])
|
||||||
|
|
||||||
|
def test_get_or_create_tags_should_deduplicate_tags(self):
|
||||||
|
books_tag = get_or_create_tag('Book', self.user)
|
||||||
|
|
||||||
|
tags = get_or_create_tags(['book', 'Book', 'BOOK'], self.user)
|
||||||
|
|
||||||
|
self.assertEqual(len(tags), 1)
|
||||||
|
self.assertListEqual(tags, [books_tag])
|
2
bookmarks/utils.py
Normal file
2
bookmarks/utils.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
def unique(elements, key):
|
||||||
|
return list({key(element): element for element in elements}.values())
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "linkding",
|
"name": "linkding",
|
||||||
"version": "1.1.1",
|
"version": "1.2.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@@ -12,21 +12,25 @@ DEBUG = True
|
|||||||
SASS_PROCESSOR_ENABLED = True
|
SASS_PROCESSOR_ENABLED = True
|
||||||
|
|
||||||
# Enable debug logging
|
# Enable debug logging
|
||||||
# Logging with SQL statements
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
'version': 1,
|
'version': 1,
|
||||||
'filters': {
|
'disable_existing_loggers': False,
|
||||||
'require_debug_true': {
|
'formatters': {
|
||||||
'()': 'django.utils.log.RequireDebugTrue',
|
'simple': {
|
||||||
}
|
'format': '{levelname} {message}',
|
||||||
|
'style': '{',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'handlers': {
|
'handlers': {
|
||||||
'console': {
|
'console': {
|
||||||
'level': 'DEBUG',
|
|
||||||
'filters': ['require_debug_true'],
|
|
||||||
'class': 'logging.StreamHandler',
|
'class': 'logging.StreamHandler',
|
||||||
|
'formatter': 'simple'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
'root': {
|
||||||
|
'handlers': ['console'],
|
||||||
|
'level': 'WARNING',
|
||||||
|
},
|
||||||
'loggers': {
|
'loggers': {
|
||||||
'django.db.backends': {
|
'django.db.backends': {
|
||||||
'level': 'ERROR', # Set to DEBUG to log all SQL calls
|
'level': 'ERROR', # Set to DEBUG to log all SQL calls
|
||||||
|
@@ -1 +1 @@
|
|||||||
1.1.1
|
1.2.1
|
Reference in New Issue
Block a user