mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-09-04 16:26:39 +02:00
Compare commits
13 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
63acde36de | ||
![]() |
70953a52b9 | ||
![]() |
f8fc360d84 | ||
![]() |
b2aeec2cac | ||
![]() |
cb7abbfacb | ||
![]() |
b844293342 | ||
![]() |
0f231bcd9f | ||
![]() |
9df270557f | ||
![]() |
f98c89e99d | ||
![]() |
6addee1377 | ||
![]() |
16ba7f390d | ||
![]() |
64914fb0d5 | ||
![]() |
ac0f0a7831 |
3
.env.sample
Normal file
3
.env.sample
Normal file
@@ -0,0 +1,3 @@
|
||||
LD_CONTAINER_NAME=linkding
|
||||
LD_HOST_PORT=9090
|
||||
LD_HOST_DATA_DIR=./data
|
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
|
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,8 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## v1.0.0 (31/12/2020)
|
||||
## 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)
|
||||
- [**enhancement**] Add docker-compose support [#54](https://github.com/sissbruecker/linkding/pull/54)
|
||||
---
|
||||
|
||||
## v1.1.0 (31/12/2020)
|
||||
- [**enhancement**] Search autocomplete [#52](https://github.com/sissbruecker/linkding/issues/52)
|
||||
- [**enhancement**] Improve Netscape bookmarks file parsing [#50](https://github.com/sissbruecker/linkding/issues/50)
|
||||
---
|
||||
|
||||
## v1.0.0 (31/12/2020)
|
||||
- [**bug**] Import does not import bookmark descriptions [#47](https://github.com/sissbruecker/linkding/issues/47)
|
||||
- [**enhancement**] Enhancement: return to same page we were on after editing a bookmark [#26](https://github.com/sissbruecker/linkding/issues/26)
|
||||
- [**bug**] Increase limit on bookmark URL length [#25](https://github.com/sissbruecker/linkding/issues/25)
|
||||
|
19
README.md
19
README.md
@@ -28,7 +28,7 @@ docker run --name linkding -p 9090:9090 -d sissbruecker/linkding:latest
|
||||
By default the application runs on port `9090`, but you can map it to a different host port by modifying the command above.
|
||||
|
||||
However for **production use** you also want to mount a data folder on your system, so that the applications database is not stored in the container, but on your hosts file system. This is safer in case something happens to the container and makes it easier to update the container later on, or to run backups. To do so you can use the following extended command, where you replace `{host-data-folder}` with the absolute path to a folder on your system where you want to store the data:
|
||||
```
|
||||
```shell
|
||||
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest
|
||||
```
|
||||
|
||||
@@ -40,12 +40,27 @@ If you are using a Linux system you can use the following [shell script](https:/
|
||||
|
||||
The script can be configured using using shell variables - for more details have a look at the script itself.
|
||||
|
||||
### Docker-compose setup
|
||||
|
||||
To install linkding using docker-compose you can use the `docker-compose.yml` file. Copy the `.env.sample` file to `.env` and set your parameters, then run:
|
||||
```shell
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### User setup
|
||||
|
||||
Finally you need to create a user so that you can access the frontend. Replace the credentials in the following command and run it:
|
||||
```
|
||||
|
||||
**Docker**
|
||||
```shell
|
||||
docker exec -it linkding python manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
```
|
||||
|
||||
**Docker-compose**
|
||||
```shell
|
||||
docker-compose exec linkding python manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
```
|
||||
|
||||
The command will prompt you for a secure password. After the command has completed you can start using the application by logging into the UI with your credentials.
|
||||
|
||||
### Manual setup
|
||||
|
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.db import models
|
||||
|
||||
from bookmarks.utils import unique
|
||||
|
||||
|
||||
class Tag(models.Model):
|
||||
name = models.CharField(max_length=64)
|
||||
@@ -18,7 +20,8 @@ def parse_tag_string(tag_string: str, delimiter: str = ','):
|
||||
if not tag_string:
|
||||
return []
|
||||
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)
|
||||
|
||||
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 bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.utils import unique
|
||||
|
||||
|
||||
class Concat(Aggregate):
|
||||
@@ -41,7 +42,7 @@ def query_bookmarks(user: User, query_string: str):
|
||||
|
||||
for tag_name in query['tag_names']:
|
||||
query_set = query_set.filter(
|
||||
tags__name=tag_name
|
||||
tags__name__iexact=tag_name
|
||||
)
|
||||
|
||||
# Sort by modification date
|
||||
@@ -74,7 +75,7 @@ def query_tags(user: User, query_string: str):
|
||||
|
||||
for tag_name in query['tag_names']:
|
||||
query_set = query_set.filter(
|
||||
bookmark__tags__name=tag_name
|
||||
bookmark__tags__name__iexact=tag_name
|
||||
)
|
||||
|
||||
return query_set.distinct()
|
||||
@@ -95,6 +96,7 @@ def _parse_query_string(query_string):
|
||||
|
||||
search_terms = [word 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 {
|
||||
'search_terms': search_terms,
|
||||
|
@@ -1,20 +1,37 @@
|
||||
import logging
|
||||
import operator
|
||||
from typing import List
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
|
||||
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):
|
||||
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):
|
||||
try:
|
||||
return Tag.objects.get(name=name, owner=user)
|
||||
return Tag.objects.get(name__iexact=name, owner=user)
|
||||
except Tag.DoesNotExist:
|
||||
tag = Tag(name=name, owner=user)
|
||||
tag.date_added = timezone.now()
|
||||
tag.save()
|
||||
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) {
|
||||
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 {
|
||||
|
||||
a {
|
||||
|
@@ -1,4 +1,5 @@
|
||||
{% load shared %}
|
||||
{% load pagination %}
|
||||
|
||||
<ul class="bookmark-list">
|
||||
{% for bookmark in bookmarks %}
|
||||
@@ -30,13 +31,7 @@
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="pagination">
|
||||
{% if bookmarks.has_next %}
|
||||
<a href="?{% update_query_string page=bookmarks.next_page_number %}"
|
||||
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 class="bookmark-pagination">
|
||||
{% pagination bookmarks %}
|
||||
</div>
|
||||
|
@@ -5,6 +5,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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="description" content="Self-hosted bookmark service">
|
||||
<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())
|
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
linkding:
|
||||
container_name: "${LD_CONTAINER_NAME:-linkding}"
|
||||
image: sissbruecker/linkding:latest
|
||||
ports:
|
||||
- "${LD_HOST_PORT:-9090}:9090"
|
||||
volumes:
|
||||
- "${LD_HOST_DATA_DIR:-./data}:/etc/linkding/data"
|
||||
restart: unless-stopped
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@@ -12,21 +12,25 @@ DEBUG = True
|
||||
SASS_PROCESSOR_ENABLED = True
|
||||
|
||||
# Enable debug logging
|
||||
# Logging with SQL statements
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'filters': {
|
||||
'require_debug_true': {
|
||||
'()': 'django.utils.log.RequireDebugTrue',
|
||||
}
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'simple': {
|
||||
'format': '{levelname} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'level': 'DEBUG',
|
||||
'filters': ['require_debug_true'],
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'simple'
|
||||
}
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': 'WARNING',
|
||||
},
|
||||
'loggers': {
|
||||
'django.db.backends': {
|
||||
'level': 'ERROR', # Set to DEBUG to log all SQL calls
|
||||
|
@@ -1 +1 @@
|
||||
1.1.0
|
||||
1.2.1
|
Reference in New Issue
Block a user