Compare commits

...

27 Commits

Author SHA1 Message Date
Sascha Ißbrücker
58836c3c76 Bump version 2021-02-18 22:11:09 +01:00
Sascha Ißbrücker
b7a8f9e53d Mark optional fields in bookmark serializer (#78) 2021-02-18 22:02:45 +01:00
Sascha Ißbrücker
afe081d3b5 Update CHANGELOG.md 2021-02-18 07:39:40 +01:00
Sascha Ißbrücker
7a14c6e2d1 Bump version 2021-02-18 07:27:31 +01:00
Sascha Ißbrücker
f7e6fbc588 Fix archive endpoints (#77) 2021-02-18 07:14:44 +01:00
Sascha Ißbrücker
778f1b2ff3 Remove legacy API (#55) 2021-02-16 04:45:21 +01:00
Sascha Ißbrücker
79dd4179d2 Add archive endpoints 2021-02-16 04:24:22 +01:00
Sascha Ißbrücker
0980e6a2b2 Update CHANGELOG.md 2021-02-15 21:11:56 +01:00
Sascha Ißbrücker
83ccf5279f Bump version 2021-02-15 21:11:03 +01:00
Sascha Ißbrücker
3bab7db023 Enhance delete links with inline confirmation (#74) 2021-02-15 21:09:03 +01:00
Sascha Ißbrücker
b6b7d3f662 Update CHANGELOG.md 2021-02-14 18:05:12 +01:00
Sascha Ißbrücker
9c51487d3b Bump version 2021-02-14 18:04:28 +01:00
Sascha Ißbrücker
c61e8ee2cd Implement archive feature (#73)
* Implement archive function (#46)

* Implement archive view (#46)

* Filter tags for archived/unarchived (#46)

* Implement archived bookmarks endpoint (#46)

* Implement archive mode for search component (#46)

* Move bookmarklet to settings (#46)

* Update modified timestamp on archive/unarchive (#46)

* Fix bookmarklet (#46)
2021-02-14 18:00:22 +01:00
Sascha Ißbrücker
f555bba9e9 Fix mobile issues with searchbox and nav menu (#72)
* Fix mobile Safari searchbox style (#62)

* Fix mobile menu not closing on outside click (#62)
2021-02-07 00:10:02 +01:00
Sascha Ißbrücker
91d876a7f1 Add option to disable bookmark URL validation (#57)
* Add option for disabled bookmark URL validation (#36)

* Add options documentation (#36)
2021-02-06 16:27:19 +01:00
Sascha Ißbrücker
085027b00a Show URL as fallback if no title is available (#64) 2021-01-16 00:57:57 +01:00
Sascha Ißbrücker
94eb55896d Fix default API permissions 2021-01-16 00:29:37 +01:00
Sascha Ißbrücker
bea0fe3b70 Fix duplicate tags test 2021-01-13 09:43:17 +01:00
Sascha Ißbrücker
2d62ba3710 Update CHANGELOG.md 2021-01-12 22:58:38 +01:00
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
54 changed files with 1185 additions and 188 deletions

View File

@@ -1,3 +1,9 @@
# Docker container name
LD_CONTAINER_NAME=linkding
# Port on the host system that the application should be published on
LD_HOST_PORT=9090
# Directory on the host system that should be mounted as data dir into the Docker container
LD_HOST_DATA_DIR=./data
# Option to disable URL validation for bookmarks completely
LD_DISABLE_URL_VALIDATION=False

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

28
API.md
View File

@@ -49,7 +49,7 @@ Example response:
"website_description": "Website description",
"tag_names": [
"tag1",
"tag2"
"tag2"
],
"date_added": "2020-09-26T09:46:23.006313Z",
"date_modified": "2020-09-26T16:01:14.275335Z"
@@ -59,6 +59,16 @@ Example response:
}
```
**List Archived**
```
GET /api/bookmarks/archived/
```
List archived bookmarks.
Parameters and response are the same as for the regular list endpoint.
**Retrieve**
```
@@ -111,6 +121,22 @@ Example payload:
}
```
**Archive**
```
POST /api/bookmarks/<id>/archive/
```
Archives a bookmark.
**Unarchive**
```
POST /api/bookmarks/<id>/unarchive/
```
Unarchives a bookmark.
**Delete**
```

View File

@@ -1,8 +1,41 @@
# Changelog
## v1.3.2 (18/02/2021)
- [**closed**] /archive and /unarchive API routes return 404 [#77](https://github.com/sissbruecker/linkding/issues/77)
- [**closed**] API - /api/check_url?url= with token authetification [#55](https://github.com/sissbruecker/linkding/issues/55)
---
## v1.3.1 (15/02/2021)
[enhancement] Enhance delete links with inline confirmation
---
## v1.3.0 (14/02/2021)
- [**closed**] Novice help. [#71](https://github.com/sissbruecker/linkding/issues/71)
- [**closed**] Option to create bookmarks public [#70](https://github.com/sissbruecker/linkding/issues/70)
- [**enhancement**] Show URL if title is not available [#64](https://github.com/sissbruecker/linkding/issues/64)
- [**bug**] minor ui nitpicks [#62](https://github.com/sissbruecker/linkding/issues/62)
- [**enhancement**] add an archive function [#46](https://github.com/sissbruecker/linkding/issues/46)
- [**closed**] remove non fqdn check and alert [#36](https://github.com/sissbruecker/linkding/issues/36)
- [**closed**] Add Lotus Notes links [#22](https://github.com/sissbruecker/linkding/issues/22)
---
## v1.2.1 (12/01/2021)
- [**bug**] Bug: Two equal tags with different capitalisation lead to 500 server errors [#65](https://github.com/sissbruecker/linkding/issues/65)
- [**closed**] Enhancement: category and pagination [#11](https://github.com/sissbruecker/linkding/issues/11)
---
## 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)

38
Options.md Normal file
View File

@@ -0,0 +1,38 @@
# Options
This document lists the options that linkding can be configured with and explains how to use them in the individual install scenarios.
## Using options
### Docker
Options are passed as environment variables to the Docker container by using the `-e` argument when using `docker run`. For example:
```
docker run --name linkding -p 9090:9090 -d -e LD_DISABLE_URL_VALIDATION=True sissbruecker/linkding:latest
```
For multiple options, use one `-e` argument per option.
### Docker-compose
For docker-compose options are configured using an `.env` file.
Follow the docker-compose setup in the README and copy `.env.sample` to `.env`. Then modify the options in `.env`.
### Manual setup
All options need to be defined as environment variables in the environment that linkding runs in.
## List of options
### `LD_DISABLE_URL_VALIDATION`
Values: `True`, `False` | Default = `False`
Completely disables URL validation for bookmarks. This can be useful if you intend to store non fully qualified domain name URLs, such as network paths, or you want to store URLs that use another protocol than `http` or `https`.
### `LD_REQUEST_TIMEOUT`
Values: `Integer` as seconds | Default = `60`
Configures the request timeout in the uwsgi application server. This can be useful if you want to import a bookmark file with a high number of bookmarks and run into request timeouts.

View File

@@ -38,7 +38,7 @@ If everything completed successfully the application should now be running and c
If you are using a Linux system you can use the following [shell script](https://github.com/sissbruecker/linkding/blob/master/install-linkding.sh) for an automated setup. The script does basically everything described above, but also handles updating an existing container to a new application version (technically replaces the existing container with a new container built from a newer image, while mounting the same data folder).
The script can be configured using using shell variables - for more details have a look at the script itself.
The script can be configured using shell variables - for more details have a look at the script itself.
### Docker-compose setup
@@ -67,6 +67,10 @@ The command will prompt you for a secure password. After the command has complet
If you can not or don't want to use Docker you can install the application manually on your server. To do so you can basically follow the steps from the *Development* section below while cross-referencing the `Dockerfile` and `bootstrap.sh` on how to make the application production-ready.
### Options
Check the [options document](Options.md) on how to configure your linkding installation.
### Hosting
The application runs in a web-server called [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) that is production-ready and that you can expose to the web. If you don't know how to configure your server to expose the application to the web there are several more steps involved. I can not support support the process here, but I can give some pointers on what to search for:
@@ -93,11 +97,7 @@ The application provides a REST API that can be used by 3rd party applications t
The default timeout for requests is 60 seconds, after which the application server will cancel the request and return the above error.
Depending on the system that the application runs on, and the number of bookmarks that need to be imported, the import may take longer than the default 60 seconds.
To increase the timeout you can provide a custom timeout to the Docker container using the `LD_REQUEST_TIMEOUT` environment variable:
```
docker run --name linkding -p 9090:9090 -e LD_REQUEST_TIMEOUT=180 -d sissbruecker/linkding:latest
```
To increase the timeout you can configure the [`LD_REQUEST_TIMEOUT` option](Options.md#LD_REQUEST_TIMEOUT).
Note that any proxy servers that you are running in front of linkding may have their own timeout settings, which are not affected by the variable.

BIN
assets/logo.afdesign Normal file

Binary file not shown.

View File

@@ -1,9 +1,14 @@
from rest_framework import viewsets, mixins
from django.urls import reverse
from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
from bookmarks import queries
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
from bookmarks.models import Bookmark, Tag
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
from bookmarks.services.website_loader import load_website_metadata
class BookmarkViewSet(viewsets.GenericViewSet,
@@ -27,6 +32,47 @@ class BookmarkViewSet(viewsets.GenericViewSet,
def get_serializer_context(self):
return {'user': self.request.user}
@action(methods=['get'], detail=False)
def archived(self, request):
user = request.user
query_string = request.GET.get('q')
query_set = queries.query_archived_bookmarks(user, query_string)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class()
data = serializer(page, many=True).data
return self.get_paginated_response(data)
@action(methods=['post'], detail=True)
def archive(self, request, pk):
bookmark = self.get_object()
archive_bookmark(bookmark)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=['post'], detail=True)
def unarchive(self, request, pk):
bookmark = self.get_object()
unarchive_bookmark(bookmark)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=['get'], detail=False)
def check(self, request):
url = request.GET.get('url')
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
existing_bookmark_data = None
if bookmark is not None:
existing_bookmark_data = {
'id': bookmark.id,
'edit_url': reverse('bookmarks:edit', args=[bookmark.id])
}
metadata = load_website_metadata(url)
return Response({
'bookmark': existing_bookmark_data,
'metadata': metadata.to_dict()
}, status=status.HTTP_200_OK)
class TagViewSet(viewsets.GenericViewSet,
mixins.ListModelMixin,

View File

@@ -30,8 +30,11 @@ class BookmarkSerializer(serializers.ModelSerializer):
'date_modified'
]
# Override optional char fields to provide default value
title = serializers.CharField(required=False, allow_blank=True, default='')
description = serializers.CharField(required=False, allow_blank=True, default='')
# Override readonly tag_names property to allow passing a list of tag names to create/update
tag_names = TagListField()
tag_names = TagListField(required=False, default=[])
def create(self, validated_data):
bookmark = Bookmark()

View File

@@ -8,6 +8,7 @@
export let placeholder;
export let value;
export let tags;
export let mode = 'default';
export let apiClient;
let isFocus = false;
@@ -111,7 +112,9 @@
let bookmarks = []
if (value && value.length >= 3) {
const fetchedBookmarks = await apiClient.getBookmarks(value, {limit: 5, offset: 0})
const fetchedBookmarks = mode === 'archive'
? await apiClient.getArchivedBookmarks(value, {limit: 5, offset: 0})
: await apiClient.getBookmarks(value, {limit: 5, offset: 0})
bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
const label = clampText(fullLabel, 60)
@@ -189,8 +192,8 @@
</script>
<div class="form-autocomplete">
<div class="form-autocomplete-input" class:is-focused={isFocus}>
<input type="search" name="{name}" placeholder="{placeholder}" autocomplete="off" value="{value}"
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<input type="search" class="form-input" name="{name}" placeholder="{placeholder}" autocomplete="off" value="{value}"
bind:this={input}
on:input={handleInput} on:keydown={handleKeyDown} on:focus={handleFocus} on:blur={handleBlur}>
</div>
@@ -257,6 +260,9 @@
.form-autocomplete-input {
padding: 0;
}
.form-autocomplete-input.is-focused {
z-index: 2;
}
/* TODO: Should be read from theme */
.menu-item.selected > a {

View File

@@ -11,4 +11,13 @@ export class ApiClient {
.then(response => response.json())
.then(data => data.results)
}
getArchivedBookmarks(query, options = {limit: 100, offset: 0}) {
const encodedQuery = encodeURIComponent(query)
const url = `${this.baseUrl}bookmarks/archived?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
return fetch(url)
.then(response => response.json())
.then(data => data.results)
}
}

View File

@@ -0,0 +1,19 @@
# Generated by Django 2.2.13 on 2021-01-03 12:12
import bookmarks.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0004_auto_20200926_1028'),
]
operations = [
migrations.AlterField(
model_name='bookmark',
name='url',
field=models.CharField(max_length=2048, validators=[bookmarks.validators.BookmarkURLValidator()]),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2.13 on 2021-02-14 09:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0005_auto_20210103_1212'),
]
operations = [
migrations.AddField(
model_name='bookmark',
name='is_archived',
field=models.BooleanField(default=False),
),
]

View File

@@ -4,6 +4,9 @@ from django import forms
from django.contrib.auth import get_user_model
from django.db import models
from bookmarks.utils import unique
from bookmarks.validators import BookmarkURLValidator
class Tag(models.Model):
name = models.CharField(max_length=64)
@@ -18,7 +21,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
@@ -29,12 +33,13 @@ def build_tag_string(tag_names: List[str], delimiter: str = ','):
class Bookmark(models.Model):
url = models.URLField(max_length=2048)
url = models.CharField(max_length=2048, validators=[BookmarkURLValidator()])
title = models.CharField(max_length=512, blank=True)
description = models.TextField(blank=True)
website_title = models.CharField(max_length=512, blank=True, null=True)
website_description = models.TextField(blank=True, null=True)
unread = models.BooleanField(default=True)
is_archived = models.BooleanField(default=False)
date_added = models.DateTimeField()
date_modified = models.DateTimeField()
date_accessed = models.DateTimeField(blank=True, null=True)
@@ -48,7 +53,12 @@ class Bookmark(models.Model):
@property
def resolved_title(self):
return self.website_title if not self.title else self.title
if self.title:
return self.title
elif self.website_title:
return self.website_title
else:
return self.url
@property
def resolved_description(self):
@@ -68,7 +78,7 @@ class Bookmark(models.Model):
class BookmarkForm(forms.ModelForm):
# Use URLField for URL
url = forms.URLField()
url = forms.CharField(validators=[BookmarkURLValidator()])
tag_string = forms.CharField(required=False)
# Do not require title and description in form as we fill these automatically if they are empty
title = forms.CharField(max_length=512,

View File

@@ -1,7 +1,8 @@
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, QuerySet
from bookmarks.models import Bookmark, Tag
from bookmarks.utils import unique
class Concat(Aggregate):
@@ -16,7 +17,17 @@ class Concat(Aggregate):
**extra)
def query_bookmarks(user: User, query_string: str):
def query_bookmarks(user: User, query_string: str) -> QuerySet:
return _base_bookmarks_query(user, query_string) \
.filter(is_archived=False)
def query_archived_bookmarks(user: User, query_string: str) -> QuerySet:
return _base_bookmarks_query(user, query_string) \
.filter(is_archived=True)
def _base_bookmarks_query(user: User, query_string: str) -> QuerySet:
# Add aggregated tag info to bookmark instances
query_set = Bookmark.objects \
.annotate(tag_count=Count('tags'),
@@ -41,7 +52,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
@@ -50,7 +61,19 @@ def query_bookmarks(user: User, query_string: str):
return query_set
def query_tags(user: User, query_string: str):
def query_bookmark_tags(user: User, query_string: str) -> QuerySet:
return _base_bookmark_tags_query(user, query_string) \
.filter(bookmark__is_archived=False) \
.distinct()
def query_archived_bookmark_tags(user: User, query_string: str) -> QuerySet:
return _base_bookmark_tags_query(user, query_string) \
.filter(bookmark__is_archived=True) \
.distinct()
def _base_bookmark_tags_query(user: User, query_string: str) -> QuerySet:
query_set = Tag.objects
# Filter for user
@@ -74,7 +97,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 +118,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

@@ -39,6 +39,20 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
return bookmark
def archive_bookmark(bookmark: Bookmark):
bookmark.is_archived = True
bookmark.date_modified = timezone.now()
bookmark.save()
return bookmark
def unarchive_bookmark(bookmark: Bookmark):
bookmark.is_archived = False
bookmark.date_modified = timezone.now()
bookmark.save()
return bookmark
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.title = from_bookmark.title
to_bookmark.description = from_bookmark.description

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

@@ -1,13 +1,33 @@
.bookmarks-page {
.bookmarks-page .search {
$searchbox-height: 1.8rem;
.search input[type=search] {
// Regular input
input[type='search'] {
width: 180px;
height: 1.8rem;
height: $searchbox-height;
-webkit-appearance: none;
@media (min-width: $control-width-md) {
width: 300px;
}
}
// Enhanced auto-complete input
// This needs a bit more wrangling to make the CSS component align with the attached button
.form-autocomplete {
height: $searchbox-height;
.form-autocomplete-input {
height: $searchbox-height;
width: 100%;
input[type='search'] {
height: 100%;
margin: 0;
border: none;
}
}
}
}
ul.bookmark-list {
@@ -36,6 +56,17 @@ ul.bookmark-list {
color: darken($gray-color, 10%);
}
}
.actions .btn-link.bm-remove-confirm {
color: $error-color;
&:hover {
text-decoration: underline;
}
}
}
.bookmark-pagination {
margin-top: 1rem;
}
.tag-cloud {

View File

@@ -4,7 +4,6 @@ section.content-area {
border-bottom: solid 1px $border-color;
display: flex;
flex-direction: row;
align-items: baseline;
margin-bottom: 16px;
h2 {

View File

@@ -0,0 +1,34 @@
{% extends "bookmarks/layout.html" %}
{% load static %}
{% load shared %}
{% load bookmarks %}
{% block content %}
<div class="bookmarks-page columns">
{# Bookmark list #}
<section class="content-area column col-8 col-md-12">
<div class="content-area-header">
<h2>Archived bookmarks</h2>
<div class="spacer"></div>
{% bookmark_search query tags mode='archive' %}
</div>
{% if empty %}
{% include 'bookmarks/empty_bookmarks.html' %}
{% else %}
{% bookmark_list bookmarks return_url %}
{% endif %}
</section>
{# Tag list #}
<section class="content-area column col-4 hide-md">
<div class="content-area-header">
<h2>Tags</h2>
</div>
{% tag_cloud tags %}
</section>
</div>
<script src="{% static "bundle.js" %}"></script>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% load shared %}
{% load pagination %}
<ul class="bookmark-list">
{% for bookmark in bookmarks %}
@@ -23,20 +24,55 @@
<div class="actions">
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm">Edit</a>
{% if bookmark.is_archived %}
<a href="{% url 'bookmarks:unarchive' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm">Unarchive</a>
{% else %}
<a href="{% url 'bookmarks:archive' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm">Archive</a>
{% endif %}
<a href="{% url 'bookmarks:remove' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm"
onclick="return confirm('Do you really want to delete this bookmark?')">Remove</a>
class="btn btn-link btn-sm bm-remove">Remove</a>
</div>
</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>
{# Enhance delete links to show inline confirmation #}
<script type="application/javascript">
window.addEventListener("load", function () {
const linkEls = document.querySelectorAll('.bookmark-list a.bm-remove');
function showConfirmation(linkEl) {
const cancelEl = document.createElement('span');
cancelEl.innerText = 'Cancel';
cancelEl.className = 'btn btn-link btn-sm bm-remove-confirm mr-1';
cancelEl.addEventListener('click', function() {
container.remove();
linkEl.style = '';
});
const confirmEl = document.createElement('a');
confirmEl.innerText = 'Confirm';
confirmEl.className = 'btn btn-link btn-delete btn-sm bm-remove-confirm';
confirmEl.href = linkEl.href;
const container = document.createElement('span');
container.appendChild(cancelEl);
container.appendChild(confirmEl);
linkEl.parentElement.appendChild(container);
linkEl.style = 'display: none';
}
linkEls.forEach(function (linkEl) {
linkEl.addEventListener('click', function (e) {
e.preventDefault();
showConfirmation(linkEl);
});
});
});
</script>

View File

@@ -1,24 +0,0 @@
{% extends "bookmarks/layout.html" %}
{% block content %}
<div class="columns">
<section class="content-area column col-12">
<div class="content-area-header">
<h2>Bookmarklet</h2>
</div>
<p>The bookmarklet is a quick way to add new bookmarks without opening the linkding application
first. Here's how it works:</p>
<ul>
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
<li>Open the website that you want to bookmark</li>
<li>Click the bookmarklet in your browsers toolbar</li>
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
</ul>
<p>Drag the following bookmarklet to your browsers toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
class="btn btn-primary">📎 Add bookmark</a>
</section>
</div>
{% endblock %}

View File

@@ -1,8 +1,8 @@
<div class="empty">
<p class="empty-title h5">You have no bookmarks yet</p>
<p class="empty-subtitle">
You can get started by <a href="{% url 'bookmarks:new' %}">adding</a> bookmarks, <a
href="{% url 'bookmarks:settings.index' %}">importing</a> your existing bookmarks or <a
href="{% url 'bookmarks:bookmarklet' %}">configuring</a> the bookmarklet.
You can get started by <a href="{% url 'bookmarks:new' %}">adding</a> bookmarks,
<a href="{% url 'bookmarks:settings.index' %}">importing</a> your existing bookmarks or configuring the
<a href="{% url 'bookmarks:settings.index' %}#bookmarklet">bookmarklet</a>.
</p>
</div>

View File

@@ -97,7 +97,7 @@
toggleIcon(descriptionInput, true);
const websiteUrl = encodeURIComponent(urlInput.value);
const requestUrl = `{% url 'bookmarks:api.check_url' %}?url=${websiteUrl}`;
const requestUrl = `{% url 'bookmarks:api-root' %}bookmarks/check?url=${websiteUrl}`;
fetch(requestUrl)
.then(response => response.json())
.then(data => {

View File

@@ -11,17 +11,7 @@
<div class="content-area-header">
<h2>Bookmarks</h2>
<div class="spacer"></div>
<div class="search">
<form action="{% url 'bookmarks:index' %}" method="get">
<div class="input-group">
<span id="search-input-wrap">
<input type="search" name="q" placeholder="Search for words or #tags"
value="{{ query }}">
</span>
<input type="submit" value="Search" class="btn input-group-btn">
</div>
</form>
</div>
{% bookmark_search query tags %}
</div>
{% if empty %}
@@ -40,24 +30,5 @@
</section>
</div>
{# Replace search input with auto-complete component #}
<script src="{% static "bundle.js" %}"></script>
<script type="application/javascript">
const currentTagsString = '{{ tags_string }}';
const currentTags = currentTagsString.split(' ');
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
const wrapper = document.getElementById('search-input-wrap')
const newWrapper = document.createElement('div')
new linkding.SearchAutoComplete({
target: newWrapper,
props: {
name: 'q',
placeholder: 'Search for words or #tags',
value: '{{ query }}',
tags: currentTags,
apiClient
}
})
wrapper.parentElement.replaceChild(newWrapper, wrapper)
</script>
{% endblock %}

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">
@@ -22,39 +23,10 @@
<h1>linkding</h1>
</a>
</section>
{# Only nav items menu when logged in #}
{# Only show nav items menu when logged in #}
{% if request.user.is_authenticated %}
<section class="navbar-section">
{# Basic menu list #}
<div class="hide-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
<a href="{% url 'bookmarks:bookmarklet' %}" class="btn btn-link">Bookmarklet</a>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
</div>
{# Menu drop-down for smaller devices #}
<div class="show-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">
<i class="icon icon-plus"></i>
</a>
<div class="dropdown dropdown-right">
<a href="#" class="btn btn-link dropdown-toggle" tabindex="0">
<i class="icon icon-menu icon-2x"></i>
</a>
<!-- menu component -->
<ul class="menu">
<li>
<a href="{% url 'bookmarks:bookmarklet' %}" class="btn btn-link">Bookmarklet</a>
</li>
<li>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
</li>
<li>
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
</li>
</ul>
</div>
</div>
{% include 'bookmarks/nav_menu.html' %}
</section>
{% endif %}
</header>

View File

@@ -0,0 +1,49 @@
{# Basic menu list #}
<div class="hide-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archive</a>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
</div>
{# Menu drop-down for smaller devices #}
<div class="show-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">
<i class="icon icon-plus"></i>
</a>
<div class="dropdown dropdown-right">
<a href="#" id="mobile-nav-menu-trigger" class="btn btn-link dropdown-toggle" tabindex="0">
<i class="icon icon-menu icon-2x"></i>
</a>
<!-- menu component -->
<ul class="menu">
<li>
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archive</a>
</li>
<li>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
</li>
<li>
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
</li>
</ul>
</div>
</div>
<script>
// Hide mobile menu on outside click
// The Spectre CSS component relies on focus changes to show/hide the dropdown, however mobile browsers like
// Safari will not trigger a blur event when clicking on a non-focusable element, so we have to simulate the
// behaviour through Javascript
const mobileNavMenuTrigger = document.getElementById('mobile-nav-menu-trigger');
function mobileNavMenuOutsideClickHandler(clickEvent) {
if (mobileNavMenuTrigger.parentElement.contains(clickEvent.target)) return
mobileNavMenuTrigger.blur();
}
mobileNavMenuTrigger.addEventListener('focus', function () {
document.addEventListener('click', mobileNavMenuOutsideClickHandler);
})
mobileNavMenuTrigger.addEventListener('blur', function () {
document.removeEventListener('click', mobileNavMenuOutsideClickHandler);
})
</script>

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,34 @@
<div class="search">
<form action="" method="get" role="search">
<div class="input-group">
<span id="search-input-wrap">
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
value="{{ query }}">
</span>
<input type="submit" value="Search" class="btn input-group-btn">
</div>
</form>
</div>
{# Replace search input with auto-complete component #}
<script type="application/javascript">
window.addEventListener("load", function() {
const currentTagsString = '{{ tags_string }}';
const currentTags = currentTagsString.split(' ');
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
const wrapper = document.getElementById('search-input-wrap')
const newWrapper = document.createElement('div')
new linkding.SearchAutoComplete({
target: newWrapper,
props: {
name: 'q',
placeholder: 'Search for words or #tags',
value: '{{ query }}',
tags: currentTags,
mode: '{{ mode }}',
apiClient
}
})
wrapper.parentElement.replaceChild(newWrapper, wrapper)
});
</script>

View File

@@ -51,6 +51,25 @@
{% endif %}
</section>
{# Integrations section #}
<section class="content-area">
<div class="content-area-header">
<a id="bookmarklet"><h2>Bookmarklet</h2></a>
</div>
<p>The bookmarklet is a quick way to add new bookmarks without opening the linkding application
first. Here's how it works:</p>
<ul>
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
<li>Open the website that you want to bookmark</li>
<li>Click the bookmarklet in your browsers toolbar</li>
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
</ul>
<p>Drag the following bookmarklet to your browsers toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
class="btn btn-primary">📎 Add bookmark</a>
</section>
{# API token section #}
<section class="content-area">
<div class="content-area-header">

View File

@@ -61,3 +61,14 @@ def bookmark_list(context, bookmarks: Page, return_url: str):
'bookmarks': bookmarks,
'return_url': return_url
}
@register.inclusion_tag('bookmarks/search.html', name='bookmark_search', takes_context=True)
def bookmark_search(context, query: str, tags: [Tag], mode: str = 'default'):
tag_names = [tag.name for tag in tags]
tags_string = build_tag_string(tag_names, ' ')
return {
'query': query,
'tags_string': tags_string,
'mode': mode,
}

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,90 @@
import datetime
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
from bookmarks.models import BookmarkForm, Bookmark
User = get_user_model()
ENABLED_URL_VALIDATION_TEST_CASES = [
('thisisnotavalidurl', False),
('http://domain', False),
('unknownscheme://domain.com', False),
('http://domain.com', True),
('http://www.domain.com', True),
('https://domain.com', True),
('https://www.domain.com', True),
]
DISABLED_URL_VALIDATION_TEST_CASES = [
('thisisnotavalidurl', True),
('http://domain', True),
('unknownscheme://domain.com', True),
('http://domain.com', True),
('http://www.domain.com', True),
('https://domain.com', True),
('https://www.domain.com', True),
]
class BookmarkValidationTestCase(TestCase):
def setUp(self) -> None:
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
@override_settings(LD_DISABLE_URL_VALIDATION=False)
def test_bookmark_model_should_validate_url_if_not_disabled_in_settings(self):
self._run_bookmark_model_url_validity_checks(ENABLED_URL_VALIDATION_TEST_CASES)
@override_settings(LD_DISABLE_URL_VALIDATION=True)
def test_bookmark_model_should_not_validate_url_if_disabled_in_settings(self):
self._run_bookmark_model_url_validity_checks(DISABLED_URL_VALIDATION_TEST_CASES)
def test_bookmark_form_should_validate_required_fields(self):
form = BookmarkForm(data={'url': ''})
self.assertEqual(len(form.errors), 1)
self.assertIn('required', str(form.errors))
form = BookmarkForm(data={'url': None})
self.assertEqual(len(form.errors), 1)
self.assertIn('required', str(form.errors))
@override_settings(LD_DISABLE_URL_VALIDATION=False)
def test_bookmark_form_should_validate_url_if_not_disabled_in_settings(self):
self._run_bookmark_form_url_validity_checks(ENABLED_URL_VALIDATION_TEST_CASES)
@override_settings(LD_DISABLE_URL_VALIDATION=True)
def test_bookmark_form_should_not_validate_url_if_disabled_in_settings(self):
self._run_bookmark_form_url_validity_checks(DISABLED_URL_VALIDATION_TEST_CASES)
def _run_bookmark_model_url_validity_checks(self, cases):
for case in cases:
url, expectation = case
bookmark = Bookmark(
url=url,
date_added=datetime.datetime.now(),
date_modified=datetime.datetime.now(),
owner=self.user
)
try:
bookmark.full_clean()
self.assertTrue(expectation, 'Did not expect validation error')
except ValidationError as e:
self.assertFalse(expectation, 'Expected validation error')
self.assertTrue('url' in e.message_dict, 'Expected URL validation to fail')
def _run_bookmark_form_url_validity_checks(self, cases):
for case in cases:
url, expectation = case
form = BookmarkForm(data={'url': url})
if expectation:
self.assertEqual(len(form.errors), 0)
else:
self.assertEqual(len(form.errors), 1)
self.assertIn('Enter a valid URL', str(form.errors))

View File

@@ -0,0 +1,16 @@
from django.test import TestCase
from bookmarks.models import Bookmark
class BookmarkTestCase(TestCase):
def test_bookmark_resolved_title(self):
bookmark = Bookmark(title='Custom title', website_title='Website title', url='https://example.com')
self.assertEqual(bookmark.resolved_title, 'Custom title')
bookmark = Bookmark(title='', website_title='Website title', url='https://example.com')
self.assertEqual(bookmark.resolved_title, 'Website title')
bookmark = Bookmark(title='', website_title='', url='https://example.com')
self.assertEqual(bookmark.resolved_title, 'https://example.com')

View File

@@ -0,0 +1,47 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from bookmarks.models import Bookmark
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
User = get_user_model()
class BookmarkServiceTestCase(TestCase):
def setUp(self) -> None:
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
def test_archive(self):
bookmark = Bookmark(
url='https://example.com',
date_added=timezone.now(),
date_modified=timezone.now(),
owner=self.user
)
bookmark.save()
self.assertFalse(bookmark.is_archived)
archive_bookmark(bookmark)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertTrue(updated_bookmark.is_archived)
def test_unarchive(self):
bookmark = Bookmark(
url='https://example.com',
date_added=timezone.now(),
date_modified=timezone.now(),
owner=self.user,
is_archived=True,
)
bookmark.save()
unarchive_bookmark(bookmark)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertFalse(updated_bookmark.is_archived)

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,99 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from django.utils.crypto import get_random_string
from bookmarks.models import Bookmark, Tag
from bookmarks import queries
User = get_user_model()
class QueriesTestCase(TestCase):
def setUp(self) -> None:
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
def setup_bookmark(self, is_archived: bool = False, tags: [Tag] = []):
unique_id = get_random_string(length=32)
bookmark = Bookmark(
url='https://example.com/' + unique_id,
date_added=timezone.now(),
date_modified=timezone.now(),
owner=self.user,
is_archived=is_archived
)
bookmark.save()
for tag in tags:
bookmark.tags.add(tag)
bookmark.save()
return bookmark
def setup_tag(self):
name = get_random_string(length=32)
tag = Tag(name=name, date_added=timezone.now(), owner=self.user)
tag.save()
return tag
def test_query_bookmarks_should_not_return_archived_bookmarks(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True)
query = queries.query_bookmarks(self.user, '')
self.assertCountEqual([bookmark1, bookmark2], list(query))
def test_query_archived_bookmarks_should_not_return_unarchived_bookmarks(self):
bookmark1 = self.setup_bookmark(is_archived=True)
bookmark2 = self.setup_bookmark(is_archived=True)
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
query = queries.query_archived_bookmarks(self.user, '')
self.assertCountEqual([bookmark1, bookmark2], list(query))
def test_query_bookmark_tags_should_return_tags_for_unarchived_bookmarks_only(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
self.setup_bookmark(tags=[tag1])
self.setup_bookmark()
self.setup_bookmark(is_archived=True, tags=[tag2])
query = queries.query_bookmark_tags(self.user, '')
self.assertCountEqual([tag1], list(query))
def test_query_bookmark_tags_should_return_distinct_tags(self):
tag = self.setup_tag()
self.setup_bookmark(tags=[tag])
self.setup_bookmark(tags=[tag])
self.setup_bookmark(tags=[tag])
query = queries.query_bookmark_tags(self.user, '')
self.assertCountEqual([tag], list(query))
def test_query_archived_bookmark_tags_should_return_tags_for_archived_bookmarks_only(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
self.setup_bookmark(tags=[tag1])
self.setup_bookmark()
self.setup_bookmark(is_archived=True, tags=[tag2])
query = queries.query_archived_bookmark_tags(self.user, '')
self.assertCountEqual([tag2], list(query))
def test_query_archived_bookmark_tags_should_return_distinct_tags(self):
tag = self.setup_tag()
self.setup_bookmark(is_archived=True, tags=[tag])
self.setup_bookmark(is_archived=True, tags=[tag])
self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmark_tags(self.user, '')
self.assertCountEqual([tag], list(query))

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 TagServiceTestCase(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):
first_tag = Tag.objects.create(name='book', date_added=timezone.now(), owner=self.user)
Tag.objects.create(name='Book', date_added=timezone.now(), owner=self.user)
retrieved_tag = get_or_create_tag('Book', self.user)
self.assertEqual(first_tag.id, retrieved_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])

View File

@@ -11,16 +11,17 @@ urlpatterns = [
url(r'^$', RedirectView.as_view(pattern_name='bookmarks:index', permanent=False)),
# Bookmarks
path('bookmarks', views.bookmarks.index, name='index'),
path('bookmarks/archived', views.bookmarks.archived, name='archived'),
path('bookmarks/new', views.bookmarks.new, name='new'),
path('bookmarks/close', views.bookmarks.close, name='close'),
path('bookmarks/<int:bookmark_id>/edit', views.bookmarks.edit, name='edit'),
path('bookmarks/<int:bookmark_id>/remove', views.bookmarks.remove, name='remove'),
path('bookmarklet', views.bookmarks.bookmarklet, name='bookmarklet'),
path('bookmarks/<int:bookmark_id>/archive', views.bookmarks.archive, name='archive'),
path('bookmarks/<int:bookmark_id>/unarchive', views.bookmarks.unarchive, name='unarchive'),
# Settings
path('settings', views.settings.index, name='settings.index'),
path('settings/import', views.settings.bookmark_import, name='settings.import'),
path('settings/export', views.settings.bookmark_export, name='settings.export'),
# API
path('api/check_url', views.api.check_url, name='api.check_url'),
path('api/', include(router.urls), name='api')
]

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

14
bookmarks/validators.py Normal file
View File

@@ -0,0 +1,14 @@
from django.conf import settings
from django.core import validators
class BookmarkURLValidator(validators.URLValidator):
"""
Extends default Django URLValidator and cancels validation if it is disabled in settings.
This allows to switch URL validation on/off dynamically which helps with testing
"""
def __call__(self, value):
if settings.LD_DISABLE_URL_VALIDATION:
return
super().__call__(value)

View File

@@ -1,3 +1,2 @@
from .api import *
from .bookmarks import *
from .settings import *

View File

@@ -1,27 +0,0 @@
from django.contrib.auth.decorators import login_required
from django.forms import model_to_dict
from django.http import JsonResponse
from django.urls import reverse
from bookmarks.services.website_loader import load_website_metadata
from bookmarks.models import Bookmark
@login_required
def check_url(request):
url = request.GET.get('url')
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
existing_bookmark_data = None
if bookmark is not None:
existing_bookmark_data = {
'id': bookmark.id,
'edit_url': reverse('bookmarks:edit', args=[bookmark.id])
}
metadata = load_website_metadata(url)
return JsonResponse({
'bookmark': existing_bookmark_data,
'metadata': metadata.to_dict()
})

View File

@@ -8,47 +8,58 @@ from django.urls import reverse
from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkForm, build_tag_string
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
from bookmarks.queries import get_user_tags
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, unarchive_bookmark
_default_page_size = 30
@login_required
def index(request):
page = request.GET.get('page')
query_string = request.GET.get('q')
query_set = queries.query_bookmarks(request.user, query_string)
tags = queries.query_bookmark_tags(request.user, query_string)
base_url = reverse('bookmarks:index')
context = get_bookmark_view_context(request, query_set, tags, base_url)
return render(request, 'bookmarks/index.html', context)
@login_required
def archived(request):
query_string = request.GET.get('q')
query_set = queries.query_archived_bookmarks(request.user, query_string)
tags = queries.query_archived_bookmark_tags(request.user, query_string)
base_url = reverse('bookmarks:archived')
context = get_bookmark_view_context(request, query_set, tags, base_url)
return render(request, 'bookmarks/archive.html', context)
def get_bookmark_view_context(request, query_set, tags, base_url):
page = request.GET.get('page')
query_string = request.GET.get('q')
paginator = Paginator(query_set, _default_page_size)
bookmarks = paginator.get_page(page)
tags = queries.query_tags(request.user, query_string)
tag_names = [tag.name for tag in tags]
tags_string = build_tag_string(tag_names, ' ')
return_url = generate_index_return_url(page, query_string)
return_url = generate_return_url(base_url, page, query_string)
if request.GET.get('tag'):
mod = request.GET.copy()
mod.pop('tag')
request.GET = mod
context = {
return {
'bookmarks': bookmarks,
'tags': tags,
'tags_string': tags_string,
'query': query_string if query_string else '',
'empty': paginator.count == 0,
'return_url': return_url
}
return render(request, 'bookmarks/index.html', context)
def generate_index_return_url(page, query_string):
def generate_return_url(base_url, page, query_string):
url_query = {}
if query_string is not None:
url_query['q'] = query_string
if page is not None:
url_query['page'] = page
base_url = reverse('bookmarks:index')
url_params = urllib.parse.urlencode(url_query)
return_url = base_url if url_params == '' else base_url + '?' + url_params
return urllib.parse.quote_plus(return_url)
@@ -76,7 +87,7 @@ def new(request):
if initial_auto_close:
form.initial['auto_close'] = 'true'
all_tags = get_user_tags(request.user)
all_tags = queries.get_user_tags(request.user)
context = {
'form': form,
'auto_close': initial_auto_close,
@@ -105,7 +116,7 @@ def edit(request, bookmark_id: int):
form.initial['tag_string'] = build_tag_string(bookmark.tag_names, ' ')
form.initial['return_url'] = return_url
all_tags = get_user_tags(request.user)
all_tags = queries.get_user_tags(request.user)
context = {
'form': form,
@@ -127,10 +138,21 @@ def remove(request, bookmark_id: int):
@login_required
def bookmarklet(request):
return render(request, 'bookmarks/bookmarklet.html', {
'application_url': request.build_absolute_uri("/bookmarks/new")
})
def archive(request, bookmark_id: int):
bookmark = Bookmark.objects.get(pk=bookmark_id)
archive_bookmark(bookmark)
return_url = request.GET.get('return_url')
return_url = return_url if return_url else reverse('bookmarks:index')
return HttpResponseRedirect(return_url)
@login_required
def unarchive(request, bookmark_id: int):
bookmark = Bookmark.objects.get(pk=bookmark_id)
unarchive_bookmark(bookmark)
return_url = request.GET.get('return_url')
return_url = return_url if return_url else reverse('bookmarks:archived')
return HttpResponseRedirect(return_url)
@login_required

View File

@@ -18,10 +18,12 @@ logger = logging.getLogger(__name__)
def index(request):
import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success')
import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors')
application_url = request.build_absolute_uri("/bookmarks/new")
api_token = Token.objects.get_or_create(user=request.user)[0]
return render(request, 'settings/index.html', {
'import_success_message': import_success_message,
'import_errors_message': import_errors_message,
'application_url': application_url,
'api_token': api_token.key
})

View File

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

View File

@@ -151,9 +151,15 @@ REST_FRAMEWORK = {
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated'
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'PAGE_SIZE': 100
}
# Registration switch
ALLOW_REGISTRATION = False
# URL validation flag
LD_DISABLE_URL_VALIDATION = os.getenv('LD_DISABLE_URL_VALIDATION', False) in (True, 'True', '1')

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.1
1.3.3