mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-14 22:19:32 +02:00
Compare commits
27 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a73910d9c7 | ||
![]() |
0c1c21c8d1 | ||
![]() |
2e4f271490 | ||
![]() |
61b13dc531 | ||
![]() |
e976fd054c | ||
![]() |
119d8f7efb | ||
![]() |
3e5e825032 | ||
![]() |
dc1f6f9c44 | ||
![]() |
9e0114ea49 | ||
![]() |
84508e07cd | ||
![]() |
496c5badbf | ||
![]() |
1c5d92dc73 | ||
![]() |
b11444f98e | ||
![]() |
ad070e7019 | ||
![]() |
6880c9ee56 | ||
![]() |
e773ad1dc4 | ||
![]() |
a02338cdec | ||
![]() |
8c161ba119 | ||
![]() |
5644dae14e | ||
![]() |
58836c3c76 | ||
![]() |
b7a8f9e53d | ||
![]() |
afe081d3b5 | ||
![]() |
7a14c6e2d1 | ||
![]() |
f7e6fbc588 | ||
![]() |
778f1b2ff3 | ||
![]() |
79dd4179d2 | ||
![]() |
0980e6a2b2 |
@@ -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
|
||||
|
31
CHANGELOG.md
31
CHANGELOG.md
@@ -1,5 +1,36 @@
|
||||
# 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)
|
||||
|
58
Dockerfile
58
Dockerfile
@@ -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"]
|
||||
|
41
README.md
41
README.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -67,10 +86,6 @@ The command will prompt you for a secure password. After the command has complet
|
||||
|
||||
If you can not or don't want to use Docker you can install the application manually on your server. To do so you can basically follow the steps from the *Development* section below while cross-referencing the `Dockerfile` and `bootstrap.sh` on how to make the application production-ready.
|
||||
|
||||
### Options
|
||||
|
||||
Check the [options document](Options.md) on how to configure your linkding installation.
|
||||
|
||||
### Hosting
|
||||
|
||||
The application runs in a web-server called [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) that is production-ready and that you can expose to the web. If you don't know how to configure your server to expose the application to the web there are several more steps involved. I can not support support the process here, but I can give some pointers on what to search for:
|
||||
@@ -78,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
|
||||
|
||||
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -1,3 +1,4 @@
|
||||
from django.urls import reverse
|
||||
from rest_framework import viewsets, mixins, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
@@ -6,6 +7,8 @@ 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,
|
||||
@@ -39,6 +42,37 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
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,
|
||||
|
@@ -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()
|
||||
|
@@ -264,17 +264,4 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* TODO: Should be read from theme */
|
||||
.menu-item.selected > a {
|
||||
background: #f1f1fc;
|
||||
color: #5755d9;
|
||||
}
|
||||
|
||||
.group-item, .group-item:hover {
|
||||
color: #999999;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
font-size: 0.6rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
@@ -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=" "
|
||||
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>
|
||||
|
@@ -5,7 +5,7 @@ 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())
|
||||
@@ -14,7 +14,15 @@ export class ApiClient {
|
||||
|
||||
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}`
|
||||
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())
|
||||
|
43
bookmarks/migrations/0007_userprofile.py
Normal file
43
bookmarks/migrations/0007_userprofile.py
Normal 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),
|
||||
]
|
@@ -2,7 +2,10 @@ 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
|
||||
@@ -93,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()
|
||||
|
@@ -1,3 +1,5 @@
|
||||
from typing import Union
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -46,6 +48,13 @@ def archive_bookmark(bookmark: Bookmark):
|
||||
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()
|
||||
@@ -53,6 +62,46 @@ def unarchive_bookmark(bookmark: Bookmark):
|
||||
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
|
||||
@@ -68,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]
|
||||
|
86
bookmarks/static/bulk_edit.js
Normal file
86
bookmarks/static/bulk_edit.js
Normal 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();
|
||||
})()
|
BIN
bookmarks/static/logo.png
Normal file
BIN
bookmarks/static/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.9 KiB |
45
bookmarks/static/shared.js
Normal file
45
bookmarks/static/shared.js
Normal 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()
|
||||
})()
|
@@ -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,9 +46,18 @@ 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
|
||||
@@ -52,4 +68,25 @@ h2 {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
@@ -30,6 +30,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarks-page .content-area-header {
|
||||
span.btn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
ul.bookmark-list {
|
||||
|
||||
list-style: none;
|
||||
@@ -39,7 +45,7 @@ ul.bookmark-list {
|
||||
.description {
|
||||
color: $gray-color-dark;
|
||||
|
||||
a {
|
||||
a, a:visited:hover {
|
||||
color: $alternative-color;
|
||||
}
|
||||
}
|
||||
@@ -57,11 +63,8 @@ ul.bookmark-list {
|
||||
}
|
||||
}
|
||||
|
||||
.actions .btn-link.bm-remove-confirm {
|
||||
color: $error-color;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.bulk-edit-toggle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +74,7 @@ ul.bookmark-list {
|
||||
|
||||
.tag-cloud {
|
||||
|
||||
a {
|
||||
a, a:visited:hover {
|
||||
color: $alternative-color;
|
||||
}
|
||||
|
||||
@@ -103,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;
|
||||
}
|
32
bookmarks/styles/dark.scss
Normal file
32
bookmarks/styles/dark.scss
Normal 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;
|
||||
}
|
@@ -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";
|
@@ -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] {
|
||||
|
@@ -1,3 +1,4 @@
|
||||
// Content area component
|
||||
section.content-area {
|
||||
|
||||
.content-area-header {
|
||||
@@ -11,3 +12,11 @@ section.content-area {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm button component
|
||||
.btn-confirmation-action {
|
||||
color: $error-color !important;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
17
bookmarks/styles/theme-dark.scss
Normal file
17
bookmarks/styles/theme-dark.scss
Normal 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";
|
14
bookmarks/styles/theme-light.scss
Normal file
14
bookmarks/styles/theme-light.scss
Normal 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";
|
@@ -7,3 +7,11 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.text-gray-dark {
|
||||
color: $gray-color-dark;
|
||||
}
|
28
bookmarks/styles/variables-dark.scss
Normal file
28
bookmarks/styles/variables-dark.scss
Normal 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;
|
4
bookmarks/styles/variables-light.scss
Normal file
4
bookmarks/styles/variables-light.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
$html-font-size: 18px !default;
|
||||
|
||||
$alternative-color: #05a6a3;
|
||||
$alternative-color-dark: darken($alternative-color, 5%);
|
@@ -4,6 +4,9 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% include 'bookmarks/bulk_edit/state.html' %}
|
||||
|
||||
<div class="bookmarks-page columns">
|
||||
|
||||
{# Bookmark list #}
|
||||
@@ -12,13 +15,20 @@
|
||||
<h2>Archived bookmarks</h2>
|
||||
<div class="spacer"></div>
|
||||
{% bookmark_search query tags mode='archive' %}
|
||||
{% 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='archive' %}
|
||||
|
||||
{% if empty %}
|
||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||
{% else %}
|
||||
{% bookmark_list bookmarks return_url %}
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{# Tag list #}
|
||||
@@ -31,4 +41,6 @@
|
||||
</div>
|
||||
|
||||
<script src="{% static "bundle.js" %}"></script>
|
||||
<script src="{% static "shared.js" %}"></script>
|
||||
<script src="{% static "bulk_edit.js" %}"></script>
|
||||
{% endblock %}
|
||||
|
@@ -4,16 +4,20 @@
|
||||
<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 %}
|
||||
|
||||
@@ -32,7 +36,7 @@
|
||||
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 bm-remove">Remove</a>
|
||||
class="btn btn-link btn-sm btn-confirmation">Remove</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
@@ -40,39 +44,4 @@
|
||||
|
||||
<div class="bookmark-pagination">
|
||||
{% pagination bookmarks %}
|
||||
</div>
|
||||
|
||||
{# Enhance delete links to show inline confirmation #}
|
||||
<script type="application/javascript">
|
||||
window.addEventListener("load", function () {
|
||||
const linkEls = document.querySelectorAll('.bookmark-list a.bm-remove');
|
||||
|
||||
function showConfirmation(linkEl) {
|
||||
const cancelEl = document.createElement('span');
|
||||
cancelEl.innerText = 'Cancel';
|
||||
cancelEl.className = 'btn btn-link btn-sm bm-remove-confirm mr-1';
|
||||
cancelEl.addEventListener('click', function() {
|
||||
container.remove();
|
||||
linkEl.style = '';
|
||||
});
|
||||
|
||||
const confirmEl = document.createElement('a');
|
||||
confirmEl.innerText = 'Confirm';
|
||||
confirmEl.className = 'btn btn-link btn-delete btn-sm bm-remove-confirm';
|
||||
confirmEl.href = linkEl.href;
|
||||
|
||||
const container = document.createElement('span');
|
||||
container.appendChild(cancelEl);
|
||||
container.appendChild(confirmEl);
|
||||
linkEl.parentElement.appendChild(container);
|
||||
linkEl.style = 'display: none';
|
||||
}
|
||||
|
||||
linkEls.forEach(function (linkEl) {
|
||||
linkEl.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
showConfirmation(linkEl);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
31
bookmarks/templates/bookmarks/bulk_edit/bar.html
Normal file
31
bookmarks/templates/bookmarks/bulk_edit/bar.html
Normal 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=" ">
|
||||
<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>
|
1
bookmarks/templates/bookmarks/bulk_edit/state.html
Normal file
1
bookmarks/templates/bookmarks/bulk_edit/state.html
Normal file
@@ -0,0 +1 @@
|
||||
<input id="bulk-edit-mode" type="checkbox">
|
8
bookmarks/templates/bookmarks/bulk_edit/toggle.html
Normal file
8
bookmarks/templates/bookmarks/bulk_edit/toggle.html
Normal 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>
|
@@ -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>
|
||||
|
@@ -2,7 +2,7 @@
|
||||
<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 configuring the
|
||||
<a href="{% url 'bookmarks:settings.index' %}#bookmarklet">bookmarklet</a>.
|
||||
<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>
|
||||
|
@@ -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 => {
|
||||
|
@@ -4,6 +4,9 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% include 'bookmarks/bulk_edit/state.html' %}
|
||||
|
||||
<div class="bookmarks-page columns">
|
||||
|
||||
{# Bookmark list #}
|
||||
@@ -12,13 +15,20 @@
|
||||
<h2>Bookmarks</h2>
|
||||
<div class="spacer"></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 #}
|
||||
@@ -31,4 +41,6 @@
|
||||
</div>
|
||||
|
||||
<script src="{% static "bundle.js" %}"></script>
|
||||
<script src="{% static "shared.js" %}"></script>
|
||||
<script src="{% static "bulk_edit.js" %}"></script>
|
||||
{% endblock %}
|
||||
|
@@ -2,7 +2,8 @@
|
||||
{% 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' %}"/>
|
||||
@@ -12,14 +13,24 @@
|
||||
<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>
|
||||
|
@@ -7,12 +7,16 @@
|
||||
</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 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">
|
||||
<i class="icon icon-menu icon-2x"></i>
|
||||
<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">
|
||||
@@ -46,4 +50,4 @@
|
||||
mobileNavMenuTrigger.addEventListener('blur', function () {
|
||||
document.removeEventListener('click', mobileNavMenuOutsideClickHandler);
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
@@ -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>
|
||||
|
@@ -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/>
|
||||
|
25
bookmarks/templates/settings/api.html
Normal file
25
bookmarks/templates/settings/api.html
Normal 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 %}
|
@@ -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,41 +65,6 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# Integrations section #}
|
||||
<section class="content-area">
|
||||
<div class="content-area-header">
|
||||
<a id="bookmarklet"><h2>Bookmarklet</h2></a>
|
||||
</div>
|
||||
<p>The bookmarklet is a quick way to add new bookmarks without opening the linkding application
|
||||
first. Here's how it works:</p>
|
||||
<ul>
|
||||
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
|
||||
<li>Open the website that you want to bookmark</li>
|
||||
<li>Click the bookmarklet in your browsers toolbar</li>
|
||||
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
|
||||
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
|
||||
</ul>
|
||||
<p>Drag the following bookmarklet to your browsers toolbar:</p>
|
||||
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
|
||||
class="btn btn-primary">📎 Add bookmark</a>
|
||||
</section>
|
||||
|
||||
{# API token section #}
|
||||
<section class="content-area">
|
||||
<div class="content-area-header">
|
||||
<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 %}
|
33
bookmarks/templates/settings/integrations.html
Normal file
33
bookmarks/templates/settings/integrations.html
Normal 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 %}
|
26
bookmarks/templates/settings/nav.html
Normal file
26
bookmarks/templates/settings/nav.html
Normal 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 
 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>
|
@@ -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
|
||||
}
|
||||
|
64
bookmarks/tests/helpers.py
Normal file
64
bookmarks/tests/helpers.py
Normal 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
|
138
bookmarks/tests/test_bookmarks_api.py
Normal file
138
bookmarks/tests/test_bookmarks_api.py
Normal 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)
|
@@ -2,18 +2,20 @@ from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import Bookmark
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||
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):
|
||||
class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
|
||||
|
||||
def test_archive(self):
|
||||
def test_archive_bookmark(self):
|
||||
bookmark = Bookmark(
|
||||
url='https://example.com',
|
||||
date_added=timezone.now(),
|
||||
@@ -30,7 +32,7 @@ class BookmarkServiceTestCase(TestCase):
|
||||
|
||||
self.assertTrue(updated_bookmark.is_archived)
|
||||
|
||||
def test_unarchive(self):
|
||||
def test_unarchive_bookmark(self):
|
||||
bookmark = Bookmark(
|
||||
url='https://example.com',
|
||||
date_added=timezone.now(),
|
||||
@@ -45,3 +47,297 @@ class BookmarkServiceTestCase(TestCase):
|
||||
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(), [])
|
||||
|
132
bookmarks/tests/test_bulk_edit_integration.py
Normal file
132
bookmarks/tests/test_bulk_edit_integration.py
Normal 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])
|
@@ -1,38 +1,13 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class QueriesTestCase(TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
|
||||
|
||||
def setup_bookmark(self, is_archived: bool = False, tags: [Tag] = []):
|
||||
unique_id = get_random_string(length=32)
|
||||
bookmark = Bookmark(
|
||||
url='https://example.com/' + unique_id,
|
||||
date_added=timezone.now(),
|
||||
date_modified=timezone.now(),
|
||||
owner=self.user,
|
||||
is_archived=is_archived
|
||||
)
|
||||
bookmark.save()
|
||||
for tag in tags:
|
||||
bookmark.tags.add(tag)
|
||||
bookmark.save()
|
||||
return bookmark
|
||||
|
||||
def setup_tag(self):
|
||||
name = get_random_string(length=32)
|
||||
tag = Tag(name=name, date_added=timezone.now(), owner=self.user)
|
||||
tag.save()
|
||||
return tag
|
||||
class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def test_query_bookmarks_should_not_return_archived_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
@@ -41,7 +16,7 @@ class QueriesTestCase(TestCase):
|
||||
self.setup_bookmark(is_archived=True)
|
||||
self.setup_bookmark(is_archived=True)
|
||||
|
||||
query = queries.query_bookmarks(self.user, '')
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), '')
|
||||
|
||||
self.assertCountEqual([bookmark1, bookmark2], list(query))
|
||||
|
||||
@@ -52,7 +27,7 @@ class QueriesTestCase(TestCase):
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
|
||||
query = queries.query_archived_bookmarks(self.user, '')
|
||||
query = queries.query_archived_bookmarks(self.get_or_create_test_user(), '')
|
||||
|
||||
self.assertCountEqual([bookmark1, bookmark2], list(query))
|
||||
|
||||
@@ -63,7 +38,7 @@ class QueriesTestCase(TestCase):
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark(is_archived=True, tags=[tag2])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '')
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '')
|
||||
|
||||
self.assertCountEqual([tag1], list(query))
|
||||
|
||||
@@ -73,7 +48,7 @@ class QueriesTestCase(TestCase):
|
||||
self.setup_bookmark(tags=[tag])
|
||||
self.setup_bookmark(tags=[tag])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '')
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '')
|
||||
|
||||
self.assertCountEqual([tag], list(query))
|
||||
|
||||
@@ -84,7 +59,7 @@ class QueriesTestCase(TestCase):
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark(is_archived=True, tags=[tag2])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.user, '')
|
||||
query = queries.query_archived_bookmark_tags(self.get_or_create_test_user(), '')
|
||||
|
||||
self.assertCountEqual([tag2], list(query))
|
||||
|
||||
@@ -94,6 +69,6 @@ class QueriesTestCase(TestCase):
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.user, '')
|
||||
query = queries.query_archived_bookmark_tags(self.get_or_create_test_user(), '')
|
||||
|
||||
self.assertCountEqual([tag], list(query))
|
||||
|
12
bookmarks/tests/test_user_profile_model.py
Normal file
12
bookmarks/tests/test_user_profile_model.py
Normal 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)
|
@@ -18,11 +18,14 @@ urlpatterns = [
|
||||
path('bookmarks/<int:bookmark_id>/remove', views.bookmarks.remove, name='remove'),
|
||||
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')
|
||||
]
|
||||
|
@@ -1,3 +1,2 @@
|
||||
from .api import *
|
||||
from .bookmarks import *
|
||||
from .settings import *
|
||||
|
@@ -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()
|
||||
})
|
@@ -8,7 +8,8 @@ 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, archive_bookmark, unarchive_bookmark
|
||||
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
|
||||
|
||||
@@ -87,11 +88,9 @@ def new(request):
|
||||
if initial_auto_close:
|
||||
form.initial['auto_close'] = 'true'
|
||||
|
||||
all_tags = queries.get_user_tags(request.user)
|
||||
context = {
|
||||
'form': form,
|
||||
'auto_close': initial_auto_close,
|
||||
'all_tags': all_tags,
|
||||
'return_url': reverse('bookmarks:index')
|
||||
}
|
||||
|
||||
@@ -116,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 = queries.get_user_tags(request.user)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'bookmark_id': bookmark_id,
|
||||
'all_tags': all_tags,
|
||||
'return_url': return_url
|
||||
}
|
||||
|
||||
@@ -155,6 +152,29 @@ def unarchive(request, bookmark_id: int):
|
||||
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
|
||||
def close(request):
|
||||
return render(request, 'bookmarks/close.html')
|
||||
|
@@ -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,15 +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')
|
||||
application_url = request.build_absolute_uri("/bookmarks/new")
|
||||
api_token = Token.objects.get_or_create(user=request.user)[0]
|
||||
return render(request, 'settings/index.html', {
|
||||
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
|
||||
})
|
||||
|
||||
@@ -49,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
|
||||
@@ -65,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.'
|
||||
})
|
||||
|
||||
|
@@ -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 \
|
||||
|
@@ -59,7 +59,7 @@ Example response:
|
||||
}
|
||||
```
|
||||
|
||||
**Archived**
|
||||
**List Archived**
|
||||
|
||||
```
|
||||
GET /api/bookmarks/archived/
|
||||
@@ -121,6 +121,22 @@ Example payload:
|
||||
}
|
||||
```
|
||||
|
||||
**Archive**
|
||||
|
||||
```
|
||||
POST /api/bookmarks/<id>/archive/
|
||||
```
|
||||
|
||||
Archives a bookmark.
|
||||
|
||||
**Unarchive**
|
||||
|
||||
```
|
||||
POST /api/bookmarks/<id>/unarchive/
|
||||
```
|
||||
|
||||
Unarchives a bookmark.
|
||||
|
||||
**Delete**
|
||||
|
||||
```
|
52
docs/backup.md
Normal file
52
docs/backup.md
Normal 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 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.3.1",
|
||||
"version": "1.4.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -41,7 +41,7 @@ INSTALLED_APPS = [
|
||||
'widget_tweaks',
|
||||
'django_generate_secret_key',
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken'
|
||||
'rest_framework.authtoken',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
@@ -11,6 +11,14 @@ 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 = {
|
||||
'version': 1,
|
||||
|
@@ -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')))
|
||||
|
@@ -1 +1 @@
|
||||
1.3.1
|
||||
1.6.0
|
||||
|
Reference in New Issue
Block a user