Compare commits

...

46 Commits

Author SHA1 Message Date
Sascha Ißbrücker
a73910d9c7 Bump version 2021-03-29 00:57:15 +02:00
Sascha Ißbrücker
0c1c21c8d1 Implement bulk edit (#101) 2021-03-29 00:43:50 +02:00
Sascha Ißbrücker
2e4f271490 Update CHANGELOG.md 2021-03-28 12:27:38 +02:00
Sascha Ißbrücker
61b13dc531 Update CHANGELOG.md 2021-03-28 12:26:09 +02:00
Sascha Ißbrücker
e976fd054c Bump version 2021-03-28 12:14:11 +02:00
Sascha Ißbrücker
119d8f7efb Implement dark theme (#49) 2021-03-28 12:11:56 +02:00
Sascha Ißbrücker
3e5e825032 Update CHANGELOG.md 2021-03-20 12:41:47 +01:00
Sascha Ißbrücker
dc1f6f9c44 Update CHANGELOG.md 2021-03-20 12:39:15 +01:00
Sascha Ißbrücker
9e0114ea49 Bump version 2021-03-20 12:36:30 +01:00
Sascha Ißbrücker
84508e07cd Doc improvements (#97)
* Improve docs

* Improve docs
2021-03-20 11:58:20 +01:00
mattofr
496c5badbf Add backup document (#89)
* Added backup document

* Improve and reference backup docs

Co-authored-by: matto <matto@matto.nl>
Co-authored-by: Sascha Ißbrücker <sissbruecker@lyska.io>
2021-03-20 07:01:19 +01:00
dependabot[bot]
1c5d92dc73 Bump djangorestframework from 3.11.1 to 3.11.2 (#96)
Bumps [djangorestframework](https://github.com/encode/django-rest-framework) from 3.11.1 to 3.11.2.
- [Release notes](https://github.com/encode/django-rest-framework/releases)
- [Commits](https://github.com/encode/django-rest-framework/compare/3.11.1...3.11.2)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-03-20 05:58:58 +01:00
dependabot[bot]
b11444f98e Bump django from 2.2.13 to 2.2.18 (#94)
Bumps [django](https://github.com/django/django) from 2.2.13 to 2.2.18.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/2.2.13...2.2.18)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-03-20 05:58:35 +01:00
stranger-danger-zamu
ad070e7019 Multistage Dockerfile (#90)
* Multistage Dockerfile

* Fix final stage and improve image size + layer caching

Co-authored-by: Sascha Ißbrücker <sissbruecker@lyska.io>
2021-03-13 11:24:44 +01:00
Sascha Ißbrücker
6880c9ee56 Update CHANGELOG.md 2021-02-24 03:49:43 +01:00
Sascha Ißbrücker
e773ad1dc4 Bump version 2021-02-24 03:37:25 +01:00
Sascha Ißbrücker
a02338cdec Improve and promote admin panel (#76)
* Improve and promote admin panel (#76)

* Customize admin panel texts (#76)

* Improve settings structure (#76)

* Improve admin list consistency (#76)

* Fix redirect URLs (#76)

* Add admin tooltip (#76)
2021-02-24 03:36:27 +01:00
Sascha Ißbrücker
8c161ba119 Implement bookmark API tests 2021-02-20 09:01:38 +01:00
Sascha Ißbrücker
5644dae14e Update CHANGELOG.md 2021-02-18 22:12:02 +01:00
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
89 changed files with 2845 additions and 357 deletions

View File

@@ -5,13 +5,22 @@
/node_modules
/tmp
/docs
/static
/build
/.dockerignore
/.gitignore
/build-*.sh
/Dockerfile
/docker-compose.yml
/*.sh
/*.iml
/package*.json
/*.patch
/*.md
/*.js
# Whitelist files needed in build or prod image
!/rollup.config.js
!/bootstrap.sh
# Remove development settings
/siteroot/settings/dev.py

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

View File

@@ -1,8 +1,61 @@
# Changelog
## v1.5.0 (28/03/2021)
- [**closed**] Add a dark mode [#49](https://github.com/sissbruecker/linkding/issues/49)
---
## v1.4.1 (20/03/2021)
- Security patches
- Documentation improvements
---
## v1.4.0 (24/02/2021)
- [**enhancement**] Improve admin utilization [#76](https://github.com/sissbruecker/linkding/issues/76)
---
## v1.3.3 (18/02/2021)
- [**closed**] Missing "description" request body parameter in API causes 500 [#78](https://github.com/sissbruecker/linkding/issues/78)
---
## 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)

View File

@@ -1,22 +1,52 @@
FROM python:3.7-slim-stretch
# Install packages required for uswgi
RUN apt-get update
RUN apt-get -y install build-essential
RUN apt-get -y install mime-support
# Install requirements and uwsgi server for running python web apps
FROM node:current-alpine AS node-build
WORKDIR /etc/linkding
COPY requirements.prod.txt ./requirements.txt
RUN pip install -U pip
RUN pip install -Ur requirements.txt
# Copy application
# install build dependencies
COPY package.json package-lock.json ./
RUN npm install -g npm && \
npm install
# compile JS components
COPY . .
RUN npm run build
FROM python:3.9-slim AS python-base
RUN apt-get update && apt-get -y install build-essential
WORKDIR /etc/linkding
FROM python-base AS python-build
# install build dependencies
COPY requirements.txt requirements.txt
RUN pip install -U pip && pip install -Ur requirements.txt
# run Django part of the build
COPY --from=node-build /etc/linkding .
RUN python manage.py compilescss && \
python manage.py collectstatic --ignore=*.scss && \
python manage.py compilescss --delete-files
FROM python-base AS prod-deps
COPY requirements.prod.txt ./requirements.txt
RUN mkdir /opt/venv && \
python -m venv --upgrade-deps --copies /opt/venv && \
/opt/venv/bin/pip install --upgrade pip wheel && \
/opt/venv/bin/pip install -Ur requirements.txt
FROM python:3.9-slim as final
RUN apt-get update && apt-get -y install mime-support
WORKDIR /etc/linkding
# copy prod dependencies
COPY --from=prod-deps /opt/venv /opt/venv
# copy output from build stage
COPY --from=python-build /etc/linkding/static static/
# copy application code
COPY . .
# Expose uwsgi server at port 9090
EXPOSE 9090
# Activate virtual env
ENV VIRTUAL_ENV /opt/venv
ENV PATH /opt/venv/bin:$PATH
# Run bootstrap logic
RUN ["chmod", "+x", "./bootstrap.sh"]
CMD ["./bootstrap.sh"]

View File

@@ -1,12 +1,31 @@
# linkding
*linkding* is a simple bookmark service that you can host yourself. It supports managing bookmarks, categorizing them with tags and has a search function. It provides a bookmarklet for quickly adding new bookmarks while browsing the web. It also supports import / export of bookmarks in the Netscape HTML format. And that's basically it 🙂.
*linkding* is a simple bookmark service that you can host yourself.
It's designed be to be minimal, fast and easy to set up using Docker.
The name comes from:
- *link* which is often used as a synonym for URLs and bookmarks in common language
- *Ding* which is german for *thing*
- ...so basically some thing for managing your links
**Feature Overview:**
- Tags for organizing bookmarks
- Search by text or tags
- Bulk editing
- Bookmark archive
- Automatically provides titles and descriptions from linked websites
- Import and export bookmarks in Netscape HTML format
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
- Bookmarklet that should work in most browsers
- Dark mode
- Easy to set up using Docker
- Uses SQLite as database
- Works without Javascript
- ...but has several UI enhancements when Javascript is enabled
- REST API for developing 3rd party apps
- Admin panel for user self-service and raw data access
**Demo:** https://demo.linkding.link/ (configured with open registration)
**Screenshot:**
@@ -15,7 +34,7 @@ The name comes from:
## Installation
The easiest way to run linkding is to use [Docker](https://docs.docker.com/get-started/). The Docker image should be compatible with ARM platforms, so it can be run on a Raspberry Pi.
The easiest way to run linkding is to use [Docker](https://docs.docker.com/get-started/). The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.
There is also the option to set up the installation manually which I do not support, but can give some pointers on below.
@@ -38,7 +57,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
@@ -49,7 +68,7 @@ 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:
Finally you need to create a user so that you can access the application. Replace the credentials in the following command and run it:
**Docker**
```shell
@@ -74,17 +93,17 @@ The application runs in a web-server called [uWSGI](https://uwsgi-docs.readthedo
- open the port that the application is running on in your servers firewall
- depending on your network configuration, forward the opened port in your network router, so that the application can be addressed from the internet using your public IP address and the opened port
### Backups
## Options
For backups you have two options: manually or automatic.
Check the [options document](docs/Options.md) on how to configure your linkding installation.
For manual backups you can export your bookmarks from the UI and store them on a backup device or online service.
## Backups
For automatic backups you want to backup the applications database. As described above, for production setups you should [mount](https://stackoverflow.com/questions/23439126/how-to-mount-a-host-directory-in-a-docker-container) the `/etc/linkding/data` directory from the Docker container to a directory on your host system. You can then use a backup tool of your choice to backup the contents of that directory.
Check the [backups document](docs/backup.md) for options on how to create backups.
## API
The application provides a REST API that can be used by 3rd party applications to manage bookmarks. Check the [API docs](API.md) for further information.
The application provides a REST API that can be used by 3rd party applications to manage bookmarks. Check the [API docs](docs/API.md) for further information.
## Troubleshooting
@@ -93,11 +112,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.
@@ -148,4 +163,4 @@ The frontend is now available under http://localhost:8000
## Community
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)

BIN
assets/logo.afdesign Normal file

Binary file not shown.

View File

@@ -1,17 +1,100 @@
from django.contrib import admin
from django.contrib import admin, messages
from django.contrib.admin import AdminSite
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import User
from django.db.models import Count, QuerySet
from django.utils.translation import ngettext, gettext
from rest_framework.authtoken.admin import TokenAdmin
from rest_framework.authtoken.models import Token
from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
class LinkdingAdminSite(AdminSite):
site_header = 'linkding administration'
site_title = 'linkding Admin'
from bookmarks.models import Bookmark, Tag
@admin.register(Bookmark)
class AdminBookmark(admin.ModelAdmin):
list_display = ('title', 'url', 'date_added')
search_fields = ('title', 'url', 'tags__name')
list_filter = ('tags',)
ordering = ('-date_added', )
list_display = ('resolved_title', 'url', 'is_archived', 'owner', 'date_added')
search_fields = ('title', 'description', 'website_title', 'website_description', 'url', 'tags__name')
list_filter = ('owner__username', 'is_archived', 'tags',)
ordering = ('-date_added',)
actions = ['archive_selected_bookmarks', 'unarchive_selected_bookmarks']
def archive_selected_bookmarks(self, request, queryset: QuerySet):
for bookmark in queryset:
archive_bookmark(bookmark)
bookmarks_count = queryset.count()
self.message_user(request, ngettext(
'%d bookmark was successfully archived.',
'%d bookmarks were successfully archived.',
bookmarks_count,
) % bookmarks_count, messages.SUCCESS)
def unarchive_selected_bookmarks(self, request, queryset: QuerySet):
for bookmark in queryset:
unarchive_bookmark(bookmark)
bookmarks_count = queryset.count()
self.message_user(request, ngettext(
'%d bookmark was successfully unarchived.',
'%d bookmarks were successfully unarchived.',
bookmarks_count,
) % bookmarks_count, messages.SUCCESS)
@admin.register(Tag)
class AdminTag(admin.ModelAdmin):
list_display = ('name', 'date_added', 'owner')
search_fields = ('name', 'owner__username')
list_filter = ('owner__username', )
ordering = ('-date_added', )
list_display = ('name', 'bookmarks_count', 'owner', 'date_added')
search_fields = ('name', 'owner__username')
list_filter = ('owner__username',)
ordering = ('-date_added',)
actions = ['delete_unused_tags']
def get_queryset(self, request):
queryset = super().get_queryset(request)
queryset = queryset.annotate(bookmarks_count=Count("bookmark"))
return queryset
def bookmarks_count(self, obj):
return obj.bookmarks_count
def delete_unused_tags(self, request, queryset: QuerySet):
unused_tags = queryset.filter(bookmark__isnull=True)
unused_tags_count = unused_tags.count()
for tag in unused_tags:
tag.delete()
if unused_tags_count > 0:
self.message_user(request, ngettext(
'%d unused tag was successfully deleted.',
'%d unused tags were successfully deleted.',
unused_tags_count,
) % unused_tags_count, messages.SUCCESS)
else:
self.message_user(request, gettext(
'There were no unused tags in the selection',
), messages.SUCCESS)
class AdminUserProfileInline(admin.StackedInline):
model = UserProfile
can_delete = False
verbose_name_plural = 'Profile'
fk_name = 'user'
class AdminCustomUser(UserAdmin):
inlines = (AdminUserProfileInline,)
def get_inline_instances(self, request, obj=None):
if not obj:
return list()
return super(AdminCustomUser, self).get_inline_instances(request, obj)
linkding_admin_site = LinkdingAdminSite()
linkding_admin_site.register(Bookmark, AdminBookmark)
linkding_admin_site.register(Tag, AdminTag)
linkding_admin_site.register(User, AdminCustomUser)
linkding_admin_site.register(Token, TokenAdmin)

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,18 +260,8 @@
.form-autocomplete-input {
padding: 0;
}
/* TODO: Should be read from theme */
.menu-item.selected > a {
background: #f1f1fc;
color: #5755d9;
.form-autocomplete-input.is-focused {
z-index: 2;
}
.group-item, .group-item:hover {
color: #999999;
text-transform: uppercase;
background: none;
font-size: 0.6rem;
font-weight: bold;
}
</style>

View File

@@ -4,8 +4,10 @@
export let id;
export let name;
export let value;
export let tags;
export let apiClient;
export let variant = 'default';
let tags = [];
let isFocus = false;
let isOpen = false;
let input = null;
@@ -13,6 +15,18 @@
let suggestions = [];
let selectedIndex = 0;
init();
async function init() {
// For now we cache all tags on load as the template did before
try {
tags = await apiClient.getTags({limit: 1000, offset: 0});
tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
} catch (e) {
console.warn('TagAutocomplete: Error loading tag list');
}
}
function handleFocus() {
isFocus = true;
}
@@ -27,7 +41,9 @@
const word = getCurrentWord(input);
suggestions = word ? tags.filter(tag => tag.indexOf(word) === 0) : [];
suggestions = word
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
: [];
if (word && suggestions.length > 0) {
open();
@@ -70,7 +86,7 @@
function complete(suggestion) {
const bounds = getCurrentWordBounds(input);
const value = input.value;
input.value = value.substring(0, bounds.start) + suggestion + value.substring(bounds.end);
input.value = value.substring(0, bounds.start) + suggestion.name + value.substring(bounds.end);
close();
}
@@ -87,11 +103,11 @@
}
</script>
<div class="form-autocomplete">
<div class="form-autocomplete" class:small={variant === 'small'}>
<!-- autocomplete input container -->
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<!-- autocomplete real input box -->
<input id="{id}" name="{name}" value="{value ||''}"
<input id="{id}" name="{name}" value="{value ||''}" placeholder="&nbsp;"
class="form-input" type="text" autocomplete="off"
on:input={handleInput} on:keydown={handleKeyDown}
on:focus={handleFocus} on:blur={handleBlur}>
@@ -105,7 +121,7 @@
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
<div class="tile tile-centered">
<div class="tile-content">
{tag}
{tag.name}
</div>
</div>
</a>
@@ -125,9 +141,16 @@
display: block;
}
/* TODO: Should be read from theme */
.menu-item.selected > a {
background: #f1f1fc;
color: #5755d9;
.form-autocomplete.small .form-autocomplete-input {
height: 1.4rem;
min-height: 1.4rem;
}
</style>
.form-autocomplete.small .form-autocomplete-input input {
margin: 0;
padding: 0;
font-size: 0.7rem;
}
.form-autocomplete.small .menu .menu-item {
font-size: 0.7rem;
}
</style>

View File

@@ -5,7 +5,24 @@ export class ApiClient {
getBookmarks(query, options = {limit: 100, offset: 0}) {
const encodedQuery = encodeURIComponent(query)
const url = `${this.baseUrl}bookmarks?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
const url = `${this.baseUrl}bookmarks/?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
return fetch(url)
.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)
}
getTags(options = {limit: 100, offset: 0}) {
const url = `${this.baseUrl}tags/?limit=${options.limit}&offset=${options.offset}`
return fetch(url)
.then(response => response.json())

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

@@ -0,0 +1,43 @@
# Generated by Django 2.2.18 on 2021-03-26 22:39
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def forwards(apps, schema_editor):
User = apps.get_model('auth', 'User')
UserProfile = apps.get_model('bookmarks', 'UserProfile')
for user in User.objects.all():
try:
if user.profile:
continue
except UserProfile.DoesNotExist:
profile = UserProfile(user=user)
profile.save()
def reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('bookmarks', '0006_bookmark_is_archived'),
]
operations = [
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('theme',
models.CharField(choices=[('auto', 'Auto'), ('light', 'Light'), ('dark', 'Dark')], default='auto',
max_length=10)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile',
to=settings.AUTH_USER_MODEL)),
],
),
migrations.RunPython(forwards, reverse),
]

View File

@@ -2,7 +2,13 @@ from typing import List
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from bookmarks.utils import unique
from bookmarks.validators import BookmarkURLValidator
class Tag(models.Model):
@@ -18,7 +24,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 +36,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 +56,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 +81,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,
@@ -83,3 +96,33 @@ class BookmarkForm(forms.ModelForm):
class Meta:
model = Bookmark
fields = ['url', 'tag_string', 'title', 'description', 'auto_close', 'return_url']
class UserProfile(models.Model):
THEME_AUTO = 'auto'
THEME_LIGHT = 'light'
THEME_DARK = 'dark'
THEME_CHOICES = [
(THEME_AUTO, 'Auto'),
(THEME_LIGHT, 'Light'),
(THEME_DARK, 'Dark'),
]
user = models.OneToOneField(get_user_model(), related_name='profile', on_delete=models.CASCADE)
theme = models.CharField(max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO)
class UserProfileForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = ['theme']
@receiver(post_save, sender=get_user_model())
def create_user_profile(sender, instance, created, **kwargs):
if created:
UserProfile.objects.create(user=instance)
@receiver(post_save, sender=get_user_model())
def save_user_profile(sender, instance, **kwargs):
instance.profile.save()

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

@@ -1,3 +1,5 @@
from typing import Union
from django.contrib.auth.models import User
from django.utils import timezone
@@ -39,6 +41,67 @@ 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 archive_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.update(is_archived=True, date_modified=timezone.now())
def unarchive_bookmark(bookmark: Bookmark):
bookmark.is_archived = False
bookmark.date_modified = timezone.now()
bookmark.save()
return bookmark
def unarchive_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.update(is_archived=False, date_modified=timezone.now())
def delete_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.delete()
def tag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
tag_names = parse_tag_string(tag_string, ' ')
tags = get_or_create_tags(tag_names, current_user)
for bookmark in bookmarks:
bookmark.tags.add(*tags)
bookmark.date_modified = timezone.now()
Bookmark.objects.bulk_update(bookmarks, ['date_modified'])
def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
tag_names = parse_tag_string(tag_string, ' ')
tags = get_or_create_tags(tag_names, current_user)
for bookmark in bookmarks:
bookmark.tags.remove(*tags)
bookmark.date_modified = timezone.now()
Bookmark.objects.bulk_update(bookmarks, ['date_modified'])
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.title = from_bookmark.title
to_bookmark.description = from_bookmark.description
@@ -54,3 +117,8 @@ def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
tag_names = parse_tag_string(tag_string, ' ')
tags = get_or_create_tags(tag_names, user)
bookmark.tags.set(tags)
def _sanitize_id_list(bookmark_ids: [Union[int, str]]) -> [int]:
# Convert string ids to int if necessary
return [int(bm_id) if isinstance(bm_id, str) else bm_id for bm_id in bookmark_ids]

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

View File

@@ -0,0 +1,86 @@
(function () {
const bulkEditToggle = document.getElementById('bulk-edit-mode')
const bulkEditBar = document.querySelector('.bulk-edit-bar')
const singleToggles = document.querySelectorAll('.bulk-edit-toggle input')
const allToggle = document.querySelector('.bulk-edit-all-toggle input')
function isAllSelected() {
let result = true
singleToggles.forEach(function (toggle) {
result = result && toggle.checked
})
return result
}
function selectAll() {
singleToggles.forEach(function (toggle) {
toggle.checked = true
})
}
function deselectAll() {
singleToggles.forEach(function (toggle) {
toggle.checked = false
})
}
// Toggle all
allToggle.addEventListener('change', function (e) {
if (e.target.checked) {
selectAll()
} else {
deselectAll()
}
})
// Toggle single
singleToggles.forEach(function (toggle) {
toggle.addEventListener('change', function () {
allToggle.checked = isAllSelected()
})
})
// Allow overflow when bulk edit mode is active to be able to display tag auto complete menu
let bulkEditToggleTimeout
if (bulkEditToggle.checked) {
bulkEditBar.style.overflow = 'visible';
}
bulkEditToggle.addEventListener('change', function (e) {
if (bulkEditToggleTimeout) {
clearTimeout(bulkEditToggleTimeout);
bulkEditToggleTimeout = null;
}
if (e.target.checked) {
bulkEditToggleTimeout = setTimeout(function () {
bulkEditBar.style.overflow = 'visible';
}, 500);
} else {
bulkEditBar.style.overflow = 'hidden';
}
});
// Init tag auto-complete
function initTagAutoComplete() {
const wrapper = document.createElement('div');
const tagInput = document.getElementById('bulk-edit-tags-input');
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || '';
const apiClient = new linkding.ApiClient(apiBaseUrl)
new linkding.TagAutoComplete({
target: wrapper,
props: {
id: 'bulk-edit-tags-input',
name: tagInput.name,
value: tagInput.value,
apiClient: apiClient,
variant: 'small'
}
});
tagInput.parentElement.replaceChild(wrapper, tagInput);
}
initTagAutoComplete();
})()

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
bookmarks/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,45 @@
(function () {
function initConfirmationButtons() {
const buttonEls = document.querySelectorAll('.btn-confirmation');
function showConfirmation(buttonEl) {
const cancelEl = document.createElement(buttonEl.nodeName);
cancelEl.innerText = 'Cancel';
cancelEl.className = 'btn btn-link btn-sm btn-confirmation-action mr-1';
cancelEl.addEventListener('click', function () {
container.remove();
buttonEl.style = '';
});
const confirmEl = document.createElement(buttonEl.nodeName);
confirmEl.innerText = 'Confirm';
confirmEl.className = 'btn btn-link btn-delete btn-sm btn-confirmation-action';
if (buttonEl.nodeName === 'BUTTON') {
confirmEl.type = buttonEl.type;
confirmEl.name = buttonEl.name;
}
if (buttonEl.nodeName === 'A') {
confirmEl.href = buttonEl.href;
}
const container = document.createElement('span');
container.className = 'confirmation'
container.appendChild(cancelEl);
container.appendChild(confirmEl);
buttonEl.parentElement.insertBefore(container, buttonEl);
buttonEl.style = 'display: none';
}
buttonEls.forEach(function (linkEl) {
linkEl.addEventListener('click', function (e) {
e.preventDefault();
showConfirmation(linkEl);
});
});
}
initConfirmationButtons()
})()

View File

@@ -1,5 +1,10 @@
body {
margin: 20px 10px;
@media (min-width: $size-sm) {
// High horizontal padding accounts for checkboxes that show up in bulk edit mode
margin: 20px 24px;
}
}
header {
@@ -10,20 +15,22 @@ header {
.navbar-brand {
display: flex;
align-items: center;
.logo {
background-color: $primary-color;
color: $light-color;
padding: 14px;
width: 28px;
height: 28px;
}
h1 {
text-transform: uppercase;
display: inline-block;
margin: 0 0 0 8px;
}
}
.dropdown-toggle {
padding: 0;
}
}
@@ -39,12 +46,47 @@ h2 {
color: $gray-color-dark;
}
// Button color should not change for anchor elements
.btn:visited:not(.btn-primary) {
color: $primary-color;
// Fix up visited styles
a:visited {
color: $link-color;
}
a:visited:hover {
color: $link-color-dark;
}
.btn-link:visited:not(.btn-primary) {
color: $link-color;
}
.btn-link:visited:not(.btn-primary):hover {
color: $link-color-dark;
}
// Increase spacing between columns
.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;
}
// Override border color for tab block
.tab-block {
border-bottom: solid 1px $border-color;
}
// Form auto-complete menu
.form-autocomplete .menu {
.menu-item.selected > a, .menu-item > a:hover {
background: $secondary-color;
color: $primary-color;
}
.group-item, .group-item:hover {
color: $gray-color;
text-transform: uppercase;
background: none;
font-size: 0.6rem;
font-weight: bold;
}
}

View File

@@ -1,13 +1,39 @@
.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;
}
}
}
}
.bookmarks-page .content-area-header {
span.btn {
margin-left: 8px;
}
}
ul.bookmark-list {
@@ -19,7 +45,7 @@ ul.bookmark-list {
.description {
color: $gray-color-dark;
a {
a, a:visited:hover {
color: $alternative-color;
}
}
@@ -36,11 +62,19 @@ ul.bookmark-list {
color: darken($gray-color, 10%);
}
}
.bulk-edit-toggle {
display: none;
}
}
.bookmark-pagination {
margin-top: 1rem;
}
.tag-cloud {
a {
a, a:visited:hover {
color: $alternative-color;
}
@@ -72,3 +106,100 @@ ul.bookmark-list {
}
}
}
/* Bulk edit */
$bulk-edit-toggle-width: 16px;
$bulk-edit-toggle-offset: 8px;
$bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset);
$bulk-edit-transition-duration: 400ms;
.bulk-edit-form {
.bulk-edit-bar {
margin-top: -17px;
margin-bottom: 16px;
margin-left: -$bulk-edit-bar-offset;
max-height: 0;
overflow: hidden;
transition: max-height $bulk-edit-transition-duration;
}
.bulk-edit-actions {
display: flex;
align-items: baseline;
padding: 4px 0;
border-top: solid 1px $border-color;
button:hover {
text-decoration: underline;
}
> label.form-checkbox {
min-height: 1rem;
}
> button {
padding: 0;
margin-left: 8px;
}
> span {
margin-left: 8px;
}
> input, .form-autocomplete {
width: auto;
margin-left: 4px;
max-width: 200px;
-webkit-appearance: none;
}
span.confirmation {
display: flex;
}
span.confirmation button {
padding: 0;
}
}
.bulk-edit-all-toggle {
width: $bulk-edit-toggle-width;
margin: 0 0 0 $bulk-edit-toggle-offset;
padding: 0;
}
ul.bookmark-list li {
position: relative;
}
ul.bookmark-list li .bulk-edit-toggle {
display: block;
position: absolute;
width: $bulk-edit-toggle-width;
left: -$bulk-edit-toggle-width - $bulk-edit-toggle-offset;
top: 0;
padding: 0;
margin: 0;
visibility: hidden;
opacity: 0;
transition: all $bulk-edit-transition-duration;
i {
top: 0.2rem;
}
}
}
#bulk-edit-mode {
display: none;
}
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-toggle {
visibility: visible;
opacity: 1;
}
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-bar {
max-height: 37px;
border-bottom: solid 1px $border-color;
}

View File

@@ -0,0 +1,32 @@
/* Dark theme overrides */
/* Buttons */
.btn.btn-primary {
background: $dt-primary-button-color;
border-color: darken($dt-primary-button-color, 5%);
&:hover, &:active, &:focus {
background: darken($dt-primary-button-color, 5%);
border-color: darken($dt-primary-button-color, 10%);
}
}
/* Focus ring*/
a:focus, .btn:focus {
box-shadow: 0 0 0 .1rem rgba($primary-color, .5);
}
/* Forms */
.has-error .form-input, .form-input.is-error, .has-error .form-select, .form-select.is-error {
background: darken($error-color, 40%);
}
.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
background: $dt-primary-button-color;
border-color: $dt-primary-button-color;
}
/* Pagination */
.pagination .page-item.active a {
background: $dt-primary-button-color;
}

View File

@@ -1,27 +0,0 @@
// Font sizes
$html-font-size: 18px !default;
//$alternative-color: #c84e00;
//$alternative-color: #FF84E8;
//$alternative-color: #98C1D9;
//$alternative-color: #7B287D;
$alternative-color: #05a6a3;
$alternative-color-dark: darken($alternative-color, 5%);
// Import Spectre CSS lib
@import "../../node_modules/spectre.css/src/spectre";
@import "../../node_modules/spectre.css/src/autocomplete";
// Import Spectre icons
@import "../../node_modules/spectre.css/src/icons/icons-core";
@import "../../node_modules/spectre.css/src/icons/icons-navigation";
@import "../../node_modules/spectre.css/src/icons/icons-action";
@import "../../node_modules/spectre.css/src/icons/icons-object";
// Import style modules
@import "base";
@import "util";
@import "shared";
@import "bookmarks";
@import "settings";
@import "auth";

View File

@@ -1,6 +1,11 @@
.settings-page {
section.content-area {
margin-bottom: 2rem;
h2 {
font-size: 1.0rem;
margin-bottom: 0.8rem;
}
}
.input-group > input[type=submit] {

View File

@@ -1,10 +1,10 @@
// Content area component
section.content-area {
.content-area-header {
border-bottom: solid 1px $border-color;
display: flex;
flex-direction: row;
align-items: baseline;
margin-bottom: 16px;
h2 {
@@ -12,3 +12,11 @@ section.content-area {
}
}
}
// Confirm button component
.btn-confirmation-action {
color: $error-color !important;
&:hover {
text-decoration: underline;
}
}

View File

@@ -0,0 +1,17 @@
// Import custom variables
@import "variables-dark";
// Import Spectre CSS lib
@import "../../node_modules/spectre.css/src/spectre";
@import "../../node_modules/spectre.css/src/autocomplete";
// Import style modules
@import "base";
@import "util";
@import "shared";
@import "bookmarks";
@import "settings";
@import "auth";
// Dark theme overrides
@import "dark";

View File

@@ -0,0 +1,14 @@
// Import custom variables
@import "variables-light";
// Import Spectre CSS lib
@import "../../node_modules/spectre.css/src/spectre";
@import "../../node_modules/spectre.css/src/autocomplete";
// Import style modules
@import "base";
@import "util";
@import "shared";
@import "bookmarks";
@import "settings";
@import "auth";

View File

@@ -7,3 +7,11 @@
overflow: hidden;
text-overflow: ellipsis;
}
.text-sm {
font-size: 0.7rem;
}
.text-gray-dark {
color: $gray-color-dark;
}

View File

@@ -0,0 +1,28 @@
$html-font-size: 18px !default;
$body-bg: #161822 !default;
$bg-color: lighten($body-bg, 5%) !default;
$bg-color-light: lighten($body-bg, 5%) !default;
$border-color: #4C4E53 !default;
$border-color-dark: $border-color !default;
$body-font-color: #b5bec8 !default;
$light-color: #fafafa !default;
$gray-color: #7f879b !default;
$gray-color-dark: lighten($gray-color, 20%) !default;
$primary-color: #a8b1ff !default;
$primary-color-dark: saturate($primary-color, 5%) !default;
$secondary-color: lighten($body-bg, 10%) !default;
$link-color: $primary-color !default;
$link-color-dark: darken($link-color, 5%) !default;
$link-color-light: $link-color !default;
$alternative-color: #59bdb9;
$alternative-color-dark: #73f1eb;
/* Dark theme specific */
$dt-primary-button-color: #5761cb !default;

View File

@@ -0,0 +1,4 @@
$html-font-size: 18px !default;
$alternative-color: #05a6a3;
$alternative-color-dark: darken($alternative-color, 5%);

View File

@@ -0,0 +1,46 @@
{% extends "bookmarks/layout.html" %}
{% load static %}
{% load shared %}
{% load bookmarks %}
{% block content %}
{% include 'bookmarks/bulk_edit/state.html' %}
<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' %}
{% include 'bookmarks/bulk_edit/toggle.html' %}
</div>
<form class="bulk-edit-form" action="{% url 'bookmarks:bulk_edit' %}?return_url={{ return_url }}"
method="post">
{% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with mode='archive' %}
{% if empty %}
{% include 'bookmarks/empty_bookmarks.html' %}
{% else %}
{% bookmark_list bookmarks return_url %}
{% endif %}
</form>
</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>
<script src="{% static "shared.js" %}"></script>
<script src="{% static "bulk_edit.js" %}"></script>
{% endblock %}

View File

@@ -1,18 +1,23 @@
{% load shared %}
{% load pagination %}
<ul class="bookmark-list">
{% for bookmark in bookmarks %}
<li>
<label class="form-checkbox bulk-edit-toggle">
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
<i class="form-icon"></i>
</label>
<div class="title truncate">
<a href="{{ bookmark.url }}" target="_blank" rel="noopener">{{ bookmark.resolved_title }}</a>
</div>
<div class="description truncate">
{% if bookmark.tag_names %}
<span>
{% for tag_name in bookmark.tag_names %}
<a href="?{% append_query_param q=tag_name|hash_tag %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</span>
{% for tag_name in bookmark.tag_names %}
<a href="?{% append_query_param q=tag_name|hash_tag %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</span>
{% endif %}
{% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %}
@@ -23,20 +28,20 @@
<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 btn-confirmation">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>
<div class="bookmark-pagination">
{% pagination bookmarks %}
</div>

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

@@ -0,0 +1,31 @@
<div class="bulk-edit-bar">
<div class="bulk-edit-actions bg-gray">
<label class="form-checkbox bulk-edit-all-toggle">
<input type="checkbox" style="display: none">
<i class="form-icon"></i>
</label>
{% if mode == 'archive' %}
<button type="submit" name="bulk_unarchive" class="btn btn-link btn-sm btn-confirmation"
title="Unarchive selected bookmarks">Unarchive
</button>
{% else %}
<button type="submit" name="bulk_archive" class="btn btn-link btn-sm btn-confirmation"
title="Archive selected bookmarks">Archive
</button>
{% endif %}
<span class="text-sm text-gray-dark"></span>
<button type="submit" name="bulk_delete" class="btn btn-link btn-sm btn-confirmation"
title="Delete selected bookmarks">Delete
</button>
<span class="text-sm text-gray-dark"></span>
<span class="text-sm text-gray-dark"><label for="bulk-edit-tags-input">Tags:</label></span>
<input id="bulk-edit-tags-input" name="bulk_tag_string" class="form-input input-sm"
placeholder="&nbsp;">
<button type="submit" name="bulk_tag" class="btn btn-link btn-sm"
title="Add tags to selected bookmarks">Add
</button>
<button type="submit" name="bulk_untag" class="btn btn-link btn-sm"
title="Remove tags from selected bookmarks">Remove
</button>
</div>
</div>

View File

@@ -0,0 +1 @@
<input id="bulk-edit-mode" type="checkbox">

View File

@@ -0,0 +1,8 @@
<label for="bulk-edit-mode" class="hide-sm">
<span class="btn" title="Bulk edit">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px"
height="20px">
<path d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z"/>
</svg>
</span>
</label>

View File

@@ -8,7 +8,7 @@
<h2>Edit bookmark</h2>
</div>
<form action="{% url 'bookmarks:edit' bookmark_id %}" method="post" class="col-6 col-md-12" novalidate>
{% bookmark_form form all_tags return_url bookmark_id %}
{% bookmark_form form return_url bookmark_id %}
</form>
</section>
</div>

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.general' %}">importing</a> your existing bookmarks or configuring the
<a href="{% url 'bookmarks:settings.integrations' %}">browser extension</a> or the <a href="{% url 'bookmarks:settings.integrations' %}">bookmarklet</a>.
</p>
</div>

View File

@@ -7,7 +7,7 @@
{{ form.return_url|attr:"type:hidden" }}
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
{{ form.url|add_class:"form-input"|attr:"autofocus" }}
{{ form.url|add_class:"form-input"|attr:"autofocus"|attr:"placeholder: " }}
{% if form.url.errors %}
<div class="form-input-hint">
{{ form.url.errors }}
@@ -64,8 +64,7 @@
<script type="application/javascript">
const wrapper = document.createElement('div');
const tagInput = document.getElementById('{{ form.tag_string.id_for_label }}');
const allTagsString = '{{ all_tags }}';
const allTags = allTagsString.split(' ');
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
new linkding.TagAutoComplete({
target: wrapper,
@@ -73,7 +72,7 @@
id: '{{ form.tag_string.id_for_label }}',
name: '{{ form.tag_string.name }}',
value: tagInput.value,
tags: allTags
apiClient: apiClient
}
});
@@ -97,7 +96,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

@@ -4,6 +4,9 @@
{% load bookmarks %}
{% block content %}
{% include 'bookmarks/bulk_edit/state.html' %}
<div class="bookmarks-page columns">
{# Bookmark list #}
@@ -11,24 +14,21 @@
<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 %}
{% include 'bookmarks/bulk_edit/toggle.html' %}
</div>
{% if empty %}
{% include 'bookmarks/empty_bookmarks.html' %}
{% else %}
{% bookmark_list bookmarks return_url %}
{% endif %}
<form class="bulk-edit-form" action="{% url 'bookmarks:bulk_edit' %}?return_url={{ return_url }}"
method="post">
{% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with mode='default' %}
{% if empty %}
{% include 'bookmarks/empty_bookmarks.html' %}
{% else %}
{% bookmark_list bookmarks return_url %}
{% endif %}
</form>
</section>
{# Tag list #}
@@ -40,24 +40,7 @@
</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>
<script src="{% static "shared.js" %}"></script>
<script src="{% static "bulk_edit.js" %}"></script>
{% endblock %}

View File

@@ -2,59 +2,42 @@
{% load sass_tags %}
<!DOCTYPE html>
<html lang="en">
{# Use data attributes as storage for access in static scripts #}
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}">
<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">
<meta name="author" content="Sascha Ißbrücker">
<title>linkding</title>
{# Include SASS styles, files are resolved from bookmarks/styles #}
<link href="{% sass_src 'index.scss' %}" rel="stylesheet" type="text/css"/>
{# Include specific theme variant based on user profile setting #}
{% if request.user.profile.theme == 'light' %}
<link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"/>
{% elif request.user.profile.theme == 'dark' %}
<link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"/>
{% else %}
{# Use auto theme as fallback #}
<link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: dark)"/>
<link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: light)"/>
{% endif %}
</head>
<body>
<header class="navbar container grid-lg">
<section class="navbar-section">
<a href="/" class="navbar-brand text-bold">
<i class="logo icon icon-link s-circle"></i>
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
<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,53 @@
{# 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">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</a>
<div class="dropdown dropdown-right">
<a href="#" id="mobile-nav-menu-trigger" class="btn btn-link dropdown-toggle" tabindex="0">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</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

@@ -8,7 +8,7 @@
<h2>New bookmark</h2>
</div>
<form action="{% url 'bookmarks:new' %}" method="post" class="col-6 col-md-12" novalidate>
{% bookmark_form form all_tags return_url auto_close=auto_close %}
{% bookmark_form form return_url auto_close=auto_close %}
</form>
</section>
</div>

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

@@ -20,11 +20,11 @@
{% endif %}
<div class="form-group">
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
{{ form.username|add_class:'form-input' }}
{{ form.username|add_class:'form-input'|attr:"placeholder: " }}
</div>
<div class="form-group">
<label class="form-label" for="{{ form.password.id_for_label }}">Password</label>
{{ form.password|add_class:'form-input' }}
{{ form.password|add_class:'form-input'|attr:"placeholder: " }}
</div>
<br/>

View File

@@ -0,0 +1,25 @@
{% extends "bookmarks/layout.html" %}
{% block content %}
<div class="settings-page">
{% include 'settings/nav.html' %}
<section class="content-area">
<h2>API Token</h2>
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
<div class="form-group">
<div class="columns">
<div class="column col-6 col-md-12">
<input class="form-input" value="{{ api_token }}" disabled>
</div>
</div>
</div>
<p><strong>Please treat this token as you would any other credential.</strong> Any party with access to this
token can access and manage all your bookmarks.</p>
<p>If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_token_changelist' %}">admin panel</a>. After deleting the token, a new one will be generated when you reload this settings page.</p>
</section>
</div>
{% endblock %}

View File

@@ -1,13 +1,29 @@
{% extends "bookmarks/layout.html" %}
{% load widget_tweaks %}
{% block content %}
<div class="settings-page">
{% include 'settings/nav.html' %}
{# Profile section #}
<section class="content-area">
<h2>Profile</h2>
<form action="{% url 'bookmarks:settings.general' %}" method="post" novalidate>
{% csrf_token %}
<div class="form-group">
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
{{ form.theme|add_class:"form-select col-2 col-sm-12" }}
</div>
<div class="form-group">
<input type="submit" value="Save" class="btn btn-primary mt-2">
</div>
</form>
</section>
{# Import section #}
<section class="content-area">
<div class="content-area-header">
<h2>Import</h2>
</div>
<h2>Import</h2>
<p>Import bookmarks and tags in the Netscape HTML format. This will execute a sync where new bookmarks are
added and existing ones are updated.</p>
<form method="post" enctype="multipart/form-data" action="{% url 'bookmarks:settings.import' %}">
@@ -37,9 +53,7 @@
{# Export section #}
<section class="content-area">
<div class="content-area-header">
<h2>Export</h2>
</div>
<h2>Export</h2>
<p>Export all bookmarks in Netscape HTML format.</p>
<a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
{% if export_error %}
@@ -51,22 +65,6 @@
{% endif %}
</section>
{# API token section #}
<section class="content-area">
<div class="content-area-header">
<h2>API Token</h2>
</div>
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
<div class="form-group">
<div class="columns">
<div class="column col-6 col-md-12">
<input class="form-input" value="{{ api_token }}" disabled>
</div>
</div>
</div>
<p><strong>Please treat this token as you would any other credential.</strong> Any party with access to this token can access and manage all your bookmarks.</p>
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "bookmarks/layout.html" %}
{% block content %}
<div class="settings-page">
{% include 'settings/nav.html' %}
{# Integrations section #}
<section class="content-area">
<h2>Browser Extension</h2>
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The extension is available in the official extension stores for:</p>
<ul>
<li><a href="https://addons.mozilla.org/de/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe" target="_blank">Chrome</a></li>
</ul>
<p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a> as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p>
<h2>Bookmarklet</h2>
<p>The bookmarklet is an alternative, cross-browser way to quickly 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

@@ -0,0 +1,26 @@
{% url 'bookmarks:settings.index' as index_url %}
{% url 'bookmarks:settings.general' as general_url %}
{% url 'bookmarks:settings.integrations' as integrations_url %}
{% url 'bookmarks:settings.api' as api_url %}
<ul class="tab tab-block">
<li class="tab-item {% if request.get_full_path == index_url or request.get_full_path == general_url%}active{% endif %}">
<a href="{% url 'bookmarks:settings.general' %}">General</a>
</li>
<li class="tab-item {% if request.get_full_path == integrations_url %}active{% endif %}">
<a href="{% url 'bookmarks:settings.integrations' %}">Integrations</a>
</li>
<li class="tab-item {% if request.get_full_path == api_url %}active{% endif %}">
<a href="{% url 'bookmarks:settings.api' %}">API</a>
</li>
<li class="tab-item tooltip tooltip-bottom" data-tooltip="The admin panel provides additional features &#010; such as user management and bulk operations.">
<a href="{% url 'admin:index' %}" target="_blank">
<span>Admin</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="ml-1" style="width: 1.2em; height: 1.2em; vertical-align: -0.2em;">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</a>
</li>
</ul>
<br>

View File

@@ -9,15 +9,10 @@ register = template.Library()
@register.inclusion_tag('bookmarks/form.html', name='bookmark_form')
def bookmark_form(form: BookmarkForm, all_tags: List[Tag], cancel_url: str, bookmark_id: int = 0, auto_close: bool = False):
all_tag_names = [tag.name for tag in all_tags]
all_tags_string = build_tag_string(all_tag_names, ' ')
def bookmark_form(form: BookmarkForm, cancel_url: str, bookmark_id: int = 0, auto_close: bool = False):
return {
'form': form,
'auto_close': auto_close,
'all_tags': all_tags_string,
'bookmark_id': bookmark_id,
'cancel_url': cancel_url
}
@@ -61,3 +56,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,64 @@
from django.contrib.auth.models import User
from django.utils import timezone
from django.utils.crypto import get_random_string
from rest_framework import status
from rest_framework.test import APITestCase
from bookmarks.models import Bookmark, Tag
class BookmarkFactoryMixin:
user = None
def get_or_create_test_user(self):
if self.user is None:
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
return self.user
def setup_bookmark(self, is_archived: bool = False, tags: [Tag] = [], user: User = None):
if user is None:
user = self.get_or_create_test_user()
unique_id = get_random_string(length=32)
bookmark = Bookmark(
url='https://example.com/' + unique_id,
date_added=timezone.now(),
date_modified=timezone.now(),
owner=user,
is_archived=is_archived
)
bookmark.save()
for tag in tags:
bookmark.tags.add(tag)
bookmark.save()
return bookmark
def setup_tag(self, user: User = None):
if user is None:
user = self.get_or_create_test_user()
name = get_random_string(length=32)
tag = Tag(name=name, date_added=timezone.now(), owner=user)
tag.save()
return tag
class LinkdingApiTestCase(APITestCase):
def get(self, url, expected_status_code=status.HTTP_200_OK):
response = self.client.get(url)
self.assertEqual(response.status_code, expected_status_code)
return response
def post(self, url, data=None, expected_status_code=status.HTTP_200_OK):
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, expected_status_code)
return response
def put(self, url, data=None, expected_status_code=status.HTTP_200_OK):
response = self.client.put(url, data, format='json')
self.assertEqual(response.status_code, expected_status_code)
return response
def delete(self, url, expected_status_code=status.HTTP_200_OK):
response = self.client.delete(url)
self.assertEqual(response.status_code, expected_status_code)
return response

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,138 @@
from collections import OrderedDict
from django.contrib.auth.models import User
from django.urls import reverse
from rest_framework import status
from rest_framework.authtoken.models import Token
from bookmarks.models import Bookmark
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.api_token = Token.objects.get_or_create(user=self.get_or_create_test_user())[0]
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.api_token.key)
self.tag1 = self.setup_tag()
self.tag2 = self.setup_tag()
self.bookmark1 = self.setup_bookmark(tags=[self.tag1, self.tag2])
self.bookmark2 = self.setup_bookmark()
self.bookmark3 = self.setup_bookmark(tags=[self.tag2])
self.archived_bookmark1 = self.setup_bookmark(is_archived=True, tags=[self.tag1, self.tag2])
self.archived_bookmark2 = self.setup_bookmark(is_archived=True)
def assertBookmarkListEqual(self, data_list, bookmarks):
expectations = []
for bookmark in bookmarks:
tag_names = [tag.name for tag in bookmark.tags.all()]
tag_names.sort(key=str.lower)
expectation = OrderedDict()
expectation['id'] = bookmark.id
expectation['url'] = bookmark.url
expectation['title'] = bookmark.title
expectation['description'] = bookmark.description
expectation['website_title'] = bookmark.website_title
expectation['website_description'] = bookmark.website_description
expectation['tag_names'] = tag_names
expectation['date_added'] = bookmark.date_added.isoformat().replace('+00:00', 'Z')
expectation['date_modified'] = bookmark.date_modified.isoformat().replace('+00:00', 'Z')
expectations.append(expectation)
for data in data_list:
data['tag_names'].sort(key=str.lower)
self.assertCountEqual(data_list, expectations)
def test_create_bookmark(self):
data = {
'url': 'https://example.com/',
'title': 'Test title',
'description': 'Test description',
'tag_names': ['tag1', 'tag2']
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertEqual(bookmark.url, data['url'])
self.assertEqual(bookmark.title, data['title'])
self.assertEqual(bookmark.description, data['description'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][0]).count(), 1)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
def test_create_bookmark_minimal_payload(self):
data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
def test_list_bookmarks(self):
response = self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1, self.bookmark2, self.bookmark3])
def test_list_bookmarks_should_filter_by_query(self):
response = self.get(reverse('bookmarks:bookmark-list') + '?q=#' + self.tag1.name, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1])
def test_list_archived_bookmarks_does_not_return_unarchived_bookmarks(self):
response = self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1, self.archived_bookmark2])
def test_list_archived_bookmarks_should_filter_by_query(self):
response = self.get(reverse('bookmarks:bookmark-archived') + '?q=#' + self.tag1.name, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1])
def test_get_bookmark(self):
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual([response.data], [self.bookmark1])
def test_update_bookmark(self):
data = {'url': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertEqual(updated_bookmark.url, data['url'])
def test_delete_bookmark(self):
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
self.assertEqual(len(Bookmark.objects.filter(id=self.bookmark1.id)), 0)
def test_archive(self):
url = reverse('bookmarks:bookmark-archive', args=[self.bookmark1.id])
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertTrue(bookmark.is_archived)
def test_unarchive(self):
url = reverse('bookmarks:bookmark-unarchive', args=[self.archived_bookmark1.id])
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
bookmark = Bookmark.objects.get(id=self.archived_bookmark1.id)
self.assertFalse(bookmark.is_archived)
def test_can_only_access_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
inaccessible_bookmark = self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user, is_archived=True)
url = reverse('bookmarks:bookmark-list')
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 3)
url = reverse('bookmarks:bookmark-archived')
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 2)
url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id])
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id])
self.put(url, {url: 'https://example.com/'}, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id])
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse('bookmarks:bookmark-archive', args=[inaccessible_bookmark.id])
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse('bookmarks:bookmark-unarchive', args=[inaccessible_bookmark.id])
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)

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,343 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from bookmarks.models import Bookmark, Tag
from bookmarks.services.bookmarks import archive_bookmark, archive_bookmarks, unarchive_bookmark, unarchive_bookmarks, \
delete_bookmarks, tag_bookmarks, untag_bookmarks
from bookmarks.tests.helpers import BookmarkFactoryMixin
User = get_user_model()
class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
def test_archive_bookmark(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_bookmark(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)
def test_archive_bookmarks(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
archive_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
def test_archive_bookmarks_should_only_archive_specified_bookmarks(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
archive_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
def test_archive_bookmarks_should_only_archive_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
inaccessible_bookmark = self.setup_bookmark(user=other_user)
archive_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=inaccessible_bookmark.id).is_archived)
def test_archive_bookmarks_should_accept_mix_of_int_and_string_ids(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
archive_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
def test_unarchive_bookmarks(self):
bookmark1 = self.setup_bookmark(is_archived=True)
bookmark2 = self.setup_bookmark(is_archived=True)
bookmark3 = self.setup_bookmark(is_archived=True)
unarchive_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
def test_unarchive_bookmarks_should_only_unarchive_specified_bookmarks(self):
bookmark1 = self.setup_bookmark(is_archived=True)
bookmark2 = self.setup_bookmark(is_archived=True)
bookmark3 = self.setup_bookmark(is_archived=True)
unarchive_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
def test_unarchive_bookmarks_should_only_unarchive_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark(is_archived=True)
bookmark2 = self.setup_bookmark(is_archived=True)
inaccessible_bookmark = self.setup_bookmark(is_archived=True, user=other_user)
unarchive_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=inaccessible_bookmark.id).is_archived)
def test_unarchive_bookmarks_should_accept_mix_of_int_and_string_ids(self):
bookmark1 = self.setup_bookmark(is_archived=True)
bookmark2 = self.setup_bookmark(is_archived=True)
bookmark3 = self.setup_bookmark(is_archived=True)
unarchive_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
def test_delete_bookmarks(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
delete_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())
self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first())
def test_delete_bookmarks_should_only_delete_specified_bookmarks(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
delete_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
self.assertIsNotNone(Bookmark.objects.filter(id=bookmark2.id).first())
self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first())
def test_delete_bookmarks_should_only_delete_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
inaccessible_bookmark = self.setup_bookmark(user=other_user)
delete_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())
self.assertIsNotNone(Bookmark.objects.filter(id=inaccessible_bookmark.id).first())
def test_delete_bookmarks_should_accept_mix_of_int_and_string_ids(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
delete_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())
self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first())
def test_tag_bookmarks(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
tag1 = self.setup_tag()
tag2 = self.setup_tag()
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name} {tag2.name}',
self.get_or_create_test_user())
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
bookmark3.refresh_from_db()
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
def test_tag_bookmarks_should_create_tags(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], 'tag1 tag2', self.get_or_create_test_user())
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
bookmark3.refresh_from_db()
self.assertEqual(2, Tag.objects.count())
tag1 = Tag.objects.filter(name='tag1').first()
tag2 = Tag.objects.filter(name='tag2').first()
self.assertIsNotNone(tag1)
self.assertIsNotNone(tag2)
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
def test_tag_bookmarks_should_only_tag_specified_bookmarks(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
tag1 = self.setup_tag()
tag2 = self.setup_tag()
tag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name} {tag2.name}', self.get_or_create_test_user())
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
bookmark3.refresh_from_db()
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark2.tags.all(), [])
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
def test_tag_bookmarks_should_only_tag_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
inaccessible_bookmark = self.setup_bookmark(user=other_user)
tag1 = self.setup_tag()
tag2 = self.setup_tag()
tag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name} {tag2.name}',
self.get_or_create_test_user())
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
inaccessible_bookmark.refresh_from_db()
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
self.assertCountEqual(inaccessible_bookmark.tags.all(), [])
def test_tag_bookmarks_should_accept_mix_of_int_and_string_ids(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
tag1 = self.setup_tag()
tag2 = self.setup_tag()
tag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name} {tag2.name}',
self.get_or_create_test_user())
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
def test_untag_bookmarks(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
untag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name} {tag2.name}',
self.get_or_create_test_user())
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
bookmark3.refresh_from_db()
self.assertCountEqual(bookmark1.tags.all(), [])
self.assertCountEqual(bookmark2.tags.all(), [])
self.assertCountEqual(bookmark3.tags.all(), [])
def test_untag_bookmarks_should_only_tag_specified_bookmarks(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
untag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name} {tag2.name}', self.get_or_create_test_user())
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
bookmark3.refresh_from_db()
self.assertCountEqual(bookmark1.tags.all(), [])
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark3.tags.all(), [])
def test_untag_bookmarks_should_only_tag_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
tag1 = self.setup_tag()
tag2 = self.setup_tag()
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
inaccessible_bookmark = self.setup_bookmark(user=other_user, tags=[tag1, tag2])
untag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name} {tag2.name}',
self.get_or_create_test_user())
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
inaccessible_bookmark.refresh_from_db()
self.assertCountEqual(bookmark1.tags.all(), [])
self.assertCountEqual(bookmark2.tags.all(), [])
self.assertCountEqual(inaccessible_bookmark.tags.all(), [tag1, tag2])
def test_untag_bookmarks_should_accept_mix_of_int_and_string_ids(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
untag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name} {tag2.name}',
self.get_or_create_test_user())
self.assertCountEqual(bookmark1.tags.all(), [])
self.assertCountEqual(bookmark2.tags.all(), [])
self.assertCountEqual(bookmark3.tags.all(), [])

View File

@@ -0,0 +1,132 @@
from django.forms import model_to_dict
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BulkEditIntegrationTests(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def assertBookmarksAreUnmodified(self, bookmarks: [Bookmark]):
self.assertEqual(len(bookmarks), Bookmark.objects.count())
for bookmark in bookmarks:
self.assertEqual(model_to_dict(bookmark), model_to_dict(Bookmark.objects.get(id=bookmark.id)))
def test_bulk_archive(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:bulk_edit'), {
'bulk_archive': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
def test_bulk_unarchive(self):
bookmark1 = self.setup_bookmark(is_archived=True)
bookmark2 = self.setup_bookmark(is_archived=True)
bookmark3 = self.setup_bookmark(is_archived=True)
self.client.post(reverse('bookmarks:bulk_edit'), {
'bulk_unarchive': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
def test_bulk_delete(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:bulk_edit'), {
'bulk_delete': [''],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
self.assertFalse(Bookmark.objects.filter(id=bookmark2.id).first())
self.assertFalse(Bookmark.objects.filter(id=bookmark3.id).first())
def test_bulk_tag(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
tag1 = self.setup_tag()
tag2 = self.setup_tag()
self.client.post(reverse('bookmarks:bulk_edit'), {
'bulk_tag': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
bookmark3.refresh_from_db()
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
def test_bulk_untag(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
self.client.post(reverse('bookmarks:bulk_edit'), {
'bulk_untag': [''],
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
bookmark3.refresh_from_db()
self.assertCountEqual(bookmark1.tags.all(), [])
self.assertCountEqual(bookmark2.tags.all(), [])
self.assertCountEqual(bookmark3.tags.all(), [])
def test_bulk_edit_handles_empty_bookmark_id(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
response = self.client.post(reverse('bookmarks:bulk_edit'), {
'bulk_archive': [''],
})
self.assertEqual(response.status_code, 302)
response = self.client.post(reverse('bookmarks:bulk_edit'), {
'bulk_archive': [''],
'bookmark_id': [],
})
self.assertEqual(response.status_code, 302)
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
def test_empty_action_does_not_modify_bookmarks(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
self.client.post(reverse('bookmarks:bulk_edit'), {
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
})
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])

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,74 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from bookmarks import queries
from bookmarks.tests.helpers import BookmarkFactoryMixin
User = get_user_model()
class QueriesTestCase(TestCase, BookmarkFactoryMixin):
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.get_or_create_test_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.get_or_create_test_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.get_or_create_test_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.get_or_create_test_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.get_or_create_test_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.get_or_create_test_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

@@ -0,0 +1,12 @@
from django.test import TestCase
from django.contrib.auth.models import User
from bookmarks.models import UserProfile
class UserProfileTestCase(TestCase):
def test_create_user_should_init_profile(self):
user = User.objects.create_user('testuser', 'test@example.com', 'password123')
profile = UserProfile.objects.all().filter(user_id=user.id).first()
self.assertIsNotNone(profile)

View File

@@ -11,16 +11,21 @@ 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'),
path('bookmarks/bulkedit', views.bookmarks.bulk_edit, name='bulk_edit'),
# Settings
path('settings', views.settings.index, name='settings.index'),
path('settings', views.settings.general, name='settings.index'),
path('settings/general', views.settings.general, name='settings.general'),
path('settings/integrations', views.settings.integrations, name='settings.integrations'),
path('settings/api', views.settings.api, name='settings.api'),
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,59 @@ 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, archive_bookmarks, \
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks
_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,11 +88,9 @@ def new(request):
if initial_auto_close:
form.initial['auto_close'] = 'true'
all_tags = get_user_tags(request.user)
context = {
'form': form,
'auto_close': initial_auto_close,
'all_tags': all_tags,
'return_url': reverse('bookmarks:index')
}
@@ -105,12 +115,10 @@ 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)
context = {
'form': form,
'bookmark_id': bookmark_id,
'all_tags': all_tags,
'return_url': return_url
}
@@ -127,10 +135,44 @@ 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
def bulk_edit(request):
bookmark_ids = request.POST.getlist('bookmark_id')
# Determine action
if 'bulk_archive' in request.POST:
archive_bookmarks(bookmark_ids, request.user)
if 'bulk_unarchive' in request.POST:
unarchive_bookmarks(bookmark_ids, request.user)
if 'bulk_delete' in request.POST:
delete_bookmarks(bookmark_ids, request.user)
if 'bulk_tag' in request.POST:
tag_string = request.POST['bulk_tag_string']
tag_bookmarks(bookmark_ids, tag_string, request.user)
if 'bulk_untag' in request.POST:
tag_string = request.POST['bulk_tag_string']
untag_bookmarks(bookmark_ids, tag_string, request.user)
return_url = request.GET.get('return_url')
return_url = return_url if return_url else reverse('bookmarks:index')
return HttpResponseRedirect(return_url)
@login_required

View File

@@ -7,6 +7,7 @@ from django.shortcuts import render
from django.urls import reverse
from rest_framework.authtoken.models import Token
from bookmarks.models import UserProfileForm
from bookmarks.queries import query_bookmarks
from bookmarks.services.exporter import export_netscape_html
from bookmarks.services.importer import import_netscape_html
@@ -15,13 +16,35 @@ logger = logging.getLogger(__name__)
@login_required
def index(request):
def general(request):
if request.method == 'POST':
form = UserProfileForm(request.POST, instance=request.user.profile)
if form.is_valid():
form.save()
else:
form = UserProfileForm(instance=request.user.profile)
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')
api_token = Token.objects.get_or_create(user=request.user)[0]
return render(request, 'settings/index.html', {
return render(request, 'settings/general.html', {
'form': form,
'import_success_message': import_success_message,
'import_errors_message': import_errors_message,
})
@login_required
def integrations(request):
application_url = request.build_absolute_uri("/bookmarks/new")
return render(request, 'settings/integrations.html', {
'application_url': application_url,
})
@login_required
def api(request):
api_token = Token.objects.get_or_create(user=request.user)[0]
return render(request, 'settings/api.html', {
'api_token': api_token.key
})
@@ -47,7 +70,7 @@ def bookmark_import(request):
messages.error(request, 'An error occurred during bookmark import.', 'bookmark_import_errors')
pass
return HttpResponseRedirect(reverse('bookmarks:settings.index'))
return HttpResponseRedirect(reverse('bookmarks:settings.general'))
@login_required
@@ -63,7 +86,7 @@ def bookmark_export(request):
return response
except:
return render(request, 'settings/index.html', {
return render(request, 'settings/general.html', {
'export_error': 'An error occurred during bookmark export.'
})

View File

@@ -2,7 +2,6 @@
version=$(<version.txt)
./build-static.sh
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \
-t sissbruecker/linkding:latest \
-t sissbruecker/linkding:$version \

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**
```

38
docs/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.

52
docs/backup.md Normal file
View File

@@ -0,0 +1,52 @@
# Backups
This page describes some options on how to create backups.
## What to backup
Linkding stores all data in a SQLite database, so all you need to backup are the contents of that database.
The location of the database file is `data/db.sqlite3` in the application folder.
If you are using Docker then the full path in the Docker container is `/etc/linkding/data/db.sqlite`.
As described in the installation docs, you should mount the `/etc/linkding/data` folder to a folder on your host system, from which you then can execute the backup.
Below, we describe several methods to create a backup of the database:
- Manual backup using the export function from the UI
- Create a copy of the SQLite databse with the SQLite backup function
- Create a plain textfile with the contents of the SQLite database with the SQLite dump function
Choose the method that fits you best.
## Exporting from the UI
The least technical option is to use the bookmark export in the UI.
Go to the settings page and open the *Data* tab.
Then click on the *Download* button to download an HTML file containing all your bookmarks.
You can backup this file on a drive, or an online file host.
## Using the SQLite backup function
Requires [SQLite](https://www.sqlite.org/index.html) to be installed on your host system.
With this method you create a new SQLite database, which is a copy of your linkding database.
This method uses the backup command in the [Command Line Shell For SQLite](https://sqlite.org/cli.html).
```shell
sqlite3 db.sqlite3 ".backup 'backup.sqlite3'"
```
After you have created the backup database `backup.sqlite` you have to move it to another system, for example with rsync.
## Using the SQLite dump function
Requires [SQLite](https://www.sqlite.org/index.html) to be installed on your host system.
With this method you create a plain text file with the SQL statements to recreate the SQLite database.
```shell
sqlite3 db.sqlite3 .dump > backup.sql
```
As this is a plain text file you can commit it to any revision management system, like git.
Using git you can commit the changes, followed by a git push to a remote repository.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 304 KiB

View File

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

View File

@@ -1,22 +1,18 @@
beautifulsoup4==4.7.1
certifi==2019.6.16
chardet==3.0.4
Django==2.2.13
django-appconf==1.0.3
django-compressor==2.3
confusable-homoglyphs==3.2.0
Django==2.2.18
django-generate-secret-key==1.0.2
django-picklefield==2.0
django-registration==3.0.1
django-sass-processor==0.7.3
django-widget-tweaks==1.4.5
djangorestframework==3.11.1
djangorestframework==3.11.2
idna==2.8
pyparsing==2.4.7
pytz==2019.1
rcssmin==1.0.6
requests==2.22.0
rjsmin==1.1.0
six==1.12.0
soupsieve==1.9.2
sqlparse==0.3.0
urllib3==1.25.3

View File

@@ -2,15 +2,16 @@ beautifulsoup4==4.7.1
certifi==2019.6.16
chardet==3.0.4
confusable-homoglyphs==3.2.0
Django==2.2.13
Django==2.2.18
django-appconf==1.0.3
django-compressor==2.3
django-debug-toolbar==3.2
django-generate-secret-key==1.0.2
django-picklefield==2.0
django-registration==3.0.1
django-sass-processor==0.7.3
django-widget-tweaks==1.4.5
djangorestframework==3.11.1
djangorestframework==3.11.2
idna==2.8
libsass==0.19.2
pyparsing==2.4.7

View File

@@ -41,7 +41,7 @@ INSTALLED_APPS = [
'widget_tweaks',
'django_generate_secret_key',
'rest_framework',
'rest_framework.authtoken'
'rest_framework.authtoken',
]
MIDDLEWARE = [
@@ -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

@@ -11,22 +11,34 @@ DEBUG = True
# Turn on SASS compilation
SASS_PROCESSOR_ENABLED = True
# Enable debug toolbar
INSTALLED_APPS.append('debug_toolbar')
MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware')
INTERNAL_IPS = [
'127.0.0.1',
]
# 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

@@ -13,13 +13,14 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import path, include
from .settings import ALLOW_REGISTRATION
from bookmarks.admin import linkding_admin_site
from .settings import ALLOW_REGISTRATION, DEBUG
urlpatterns = [
path('admin/', admin.site.urls),
path('admin/', linkding_admin_site.urls),
path('login/', auth_views.LoginView.as_view(redirect_authenticated_user=True,
extra_context=dict(allow_registration=ALLOW_REGISTRATION)),
name='login'),
@@ -27,5 +28,9 @@ urlpatterns = [
path('', include('bookmarks.urls')),
]
if DEBUG:
import debug_toolbar
urlpatterns.append(path('__debug__/', include(debug_toolbar.urls)))
if ALLOW_REGISTRATION:
urlpatterns.append(path('', include('django_registration.backends.one_step.urls')))

View File

@@ -1 +1 @@
1.1.1
1.6.0