Compare commits

...

13 Commits

Author SHA1 Message Date
Sascha Ißbrücker
63acde36de Bump version 2021-01-12 22:43:54 +01:00
Sascha Ißbrücker
70953a52b9 Fix duplicate tag error (#65) 2021-01-12 22:42:56 +01:00
Sascha Ißbrücker
f8fc360d84 Add pagination (#63)
* Add pagination tag (#11)

* Add pagination tag tests (#11)

* Improve styling (#11)
2021-01-11 17:49:53 +01:00
Sascha Ißbrücker
b2aeec2cac Update CHANGELOG.md 2021-01-09 22:19:37 +01:00
Sascha Ißbrücker
cb7abbfacb Bump version 2021-01-09 22:17:32 +01:00
Sascha Ißbrücker
b844293342 Add favicon (#60)
Co-authored-by: Sascha Ißbrücker <sissbruecker@lyska.io>
2021-01-09 00:24:06 +01:00
Sascha Ißbrücker
0f231bcd9f Setup CI for tests 2021-01-02 11:50:16 +01:00
Sascha Ißbrücker
9df270557f Make tag search and assignment case insensitive (#56)
* Make tag assignment and search case-insensitive (#45)

* Add tests for tag case-sensitivity and deduplication (#45)

Co-authored-by: Sascha Ißbrücker <sissbruecker@lyska.io>
2021-01-02 11:30:20 +01:00
Sascha Ißbrücker
f98c89e99d Update CHANGELOG.md 2021-01-01 13:25:11 +01:00
Sascha Ißbrücker
6addee1377 Bump version 2021-01-01 13:22:28 +01:00
Sascha Ißbrücker
16ba7f390d Improve README structure 2021-01-01 13:17:47 +01:00
ScientiaSitPotentia
64914fb0d5 Docker compose support (#54)
* added docker-compose files

* updated readme with docker-compose instructions

* updated default docker-compose data folder
2021-01-01 13:11:22 +01:00
Sascha Ißbrücker
ac0f0a7831 Update CHANGELOG.md 2020-12-31 10:02:47 +01:00
25 changed files with 420 additions and 29 deletions

3
.env.sample Normal file
View 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
View 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

View File

@@ -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)

View File

@@ -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

Binary file not shown.

View File

@@ -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

View File

@@ -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,

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -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;
}

View File

@@ -38,6 +38,10 @@ ul.bookmark-list {
}
}
.bookmark-pagination {
margin-top: 1rem;
}
.tag-cloud {
a {

View File

@@ -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>

View File

@@ -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">

View 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>

View 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, [])

View File

@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

View 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')

View 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)

View 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
View 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
View 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

View File

@@ -1,6 +1,6 @@
{
"name": "linkding",
"version": "1.1.0",
"version": "1.2.1",
"description": "",
"main": "index.js",
"scripts": {

View File

@@ -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

View File

@@ -1 +1 @@
1.1.0
1.2.1