Compare commits

..

9 Commits

Author SHA1 Message Date
Sascha Ißbrücker
a867614461 Bump version 2021-02-14 18:03:40 +01:00
Sascha Ißbrücker
194f5884df Fix bookmarklet (#46) 2021-02-14 17:57:30 +01:00
Sascha Ißbrücker
8e1cda1104 Update modified timestamp on archive/unarchive (#46) 2021-02-14 17:30:51 +01:00
Sascha Ißbrücker
f9659f4342 Move bookmarklet to settings (#46) 2021-02-14 17:14:58 +01:00
Sascha Ißbrücker
6fab248c95 Implement archive mode for search component (#46) 2021-02-14 16:56:12 +01:00
Sascha Ißbrücker
b7676227c0 Implement archived bookmarks endpoint (#46) 2021-02-14 12:14:46 +01:00
Sascha Ißbrücker
0db7610d68 Filter tags for archived/unarchived (#46) 2021-02-14 11:43:09 +01:00
Sascha Ißbrücker
be7b92d608 Implement archive view (#46) 2021-02-14 11:21:05 +01:00
Sascha Ißbrücker
256084f6cb Implement archive function (#46) 2021-02-14 10:51:32 +01:00
77 changed files with 314 additions and 2343 deletions

View File

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

View File

@@ -59,7 +59,7 @@ Example response:
}
```
**List Archived**
**Archived**
```
GET /api/bookmarks/archived/
@@ -121,22 +121,6 @@ Example payload:
}
```
**Archive**
```
POST /api/bookmarks/<id>/archive/
```
Archives a bookmark.
**Unarchive**
```
POST /api/bookmarks/<id>/unarchive/
```
Unarchives a bookmark.
**Delete**
```

View File

@@ -1,69 +1,5 @@
# Changelog
## v1.6.3 (07/04/2021)
- [**bug**] relative names use the wrong "today" after day change [#107](https://github.com/sissbruecker/linkding/issues/107)
---
## v1.6.2 (04/04/2021)
- [**enhancement**] Expose `date_added` in UI [#85](https://github.com/sissbruecker/linkding/issues/85)
- [**closed**] Archived bookmarks - no result when searching for a word which is used only as tag [#83](https://github.com/sissbruecker/linkding/issues/83)
- [**closed**] Add archive/unarchive button to edit bookmark page [#82](https://github.com/sissbruecker/linkding/issues/82)
- [**enhancement**] Make scraped title and description editable [#80](https://github.com/sissbruecker/linkding/issues/80)
---
## v1.6.1 (31/03/2021)
- Expose date_added in UI [#85](https://github.com/sissbruecker/linkding/issues/85)
---
## v1.6.0 (29/03/2021)
- Bulk edit mode [#101](https://github.com/sissbruecker/linkding/pull/101)
---
## v1.5.0 (28/03/2021)
- [**closed**] Add a dark mode [#49](https://github.com/sissbruecker/linkding/issues/49)
---
## v1.4.1 (20/03/2021)
- Security patches
- Documentation improvements
---
## v1.4.0 (24/02/2021)
- [**enhancement**] Improve admin utilization [#76](https://github.com/sissbruecker/linkding/issues/76)
---
## v1.3.3 (18/02/2021)
- [**closed**] Missing "description" request body parameter in API causes 500 [#78](https://github.com/sissbruecker/linkding/issues/78)
---
## v1.3.2 (18/02/2021)
- [**closed**] /archive and /unarchive API routes return 404 [#77](https://github.com/sissbruecker/linkding/issues/77)
- [**closed**] API - /api/check_url?url= with token authetification [#55](https://github.com/sissbruecker/linkding/issues/55)
---
## v1.3.1 (15/02/2021)
[enhancement] Enhance delete links with inline confirmation
---
## v1.3.0 (14/02/2021)
- [**closed**] Novice help. [#71](https://github.com/sissbruecker/linkding/issues/71)
- [**closed**] Option to create bookmarks public [#70](https://github.com/sissbruecker/linkding/issues/70)
- [**enhancement**] Show URL if title is not available [#64](https://github.com/sissbruecker/linkding/issues/64)
- [**bug**] minor ui nitpicks [#62](https://github.com/sissbruecker/linkding/issues/62)
- [**enhancement**] add an archive function [#46](https://github.com/sissbruecker/linkding/issues/46)
- [**closed**] remove non fqdn check and alert [#36](https://github.com/sissbruecker/linkding/issues/36)
- [**closed**] Add Lotus Notes links [#22](https://github.com/sissbruecker/linkding/issues/22)
---
## v1.2.1 (12/01/2021)
- [**bug**] Bug: Two equal tags with different capitalisation lead to 500 server errors [#65](https://github.com/sissbruecker/linkding/issues/65)
- [**closed**] Enhancement: category and pagination [#11](https://github.com/sissbruecker/linkding/issues/11)

View File

@@ -1,52 +1,22 @@
FROM node:current-alpine AS node-build
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
WORKDIR /etc/linkding
# 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
RUN pip install -U pip
RUN 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 application
COPY . .
# Expose uwsgi server at port 9090
EXPOSE 9090
# Activate virtual env
ENV VIRTUAL_ENV /opt/venv
ENV PATH /opt/venv/bin:$PATH
# Run bootstrap logic
RUN ["chmod", "+x", "./bootstrap.sh"]
CMD ["./bootstrap.sh"]

View File

@@ -1,31 +1,12 @@
# linkding
*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.
*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 🙂.
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:**
@@ -34,7 +15,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 is 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 should be 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.
@@ -68,7 +49,7 @@ docker-compose up -d
### User setup
Finally you need to create a user so that you can access the application. Replace the credentials in the following command and run it:
Finally you need to create a user so that you can access the frontend. Replace the credentials in the following command and run it:
**Docker**
```shell
@@ -86,6 +67,10 @@ The command will prompt you for a secure password. After the command has complet
If you can not or don't want to use Docker you can install the application manually on your server. To do so you can basically follow the steps from the *Development* section below while cross-referencing the `Dockerfile` and `bootstrap.sh` on how to make the application production-ready.
### Options
Check the [options document](Options.md) on how to configure your linkding installation.
### Hosting
The application runs in a web-server called [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) that is production-ready and that you can expose to the web. If you don't know how to configure your server to expose the application to the web there are several more steps involved. I can not support support the process here, but I can give some pointers on what to search for:
@@ -93,25 +78,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
## Options
### Backups
Check the [options document](docs/Options.md) on how to configure your linkding installation.
For backups you have two options: manually or automatic.
## Administration
For manual backups you can export your bookmarks from the UI and store them on a backup device or online service.
Check the [administration document](docs/Admin.md) on how to use the admin app that is bundled with linkding.
## Backups
Check the [backups document](docs/backup.md) for options on how to create backups.
## How To
Check the [how-to document](docs/how-to.md) for tips and tricks around using linkding.
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.
## API
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.
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.
## Troubleshooting
@@ -171,4 +148,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)

View File

@@ -1,100 +1,17 @@
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 django.contrib import admin
from bookmarks.models import Bookmark, Tag
@admin.register(Bookmark)
class AdminBookmark(admin.ModelAdmin):
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)
list_display = ('title', 'url', 'date_added')
search_fields = ('title', 'url', 'tags__name')
list_filter = ('tags',)
ordering = ('-date_added', )
@admin.register(Tag)
class AdminTag(admin.ModelAdmin):
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)
list_display = ('name', 'date_added', 'owner')
search_fields = ('name', 'owner__username')
list_filter = ('owner__username', )
ordering = ('-date_added', )

View File

@@ -1,4 +1,3 @@
from django.urls import reverse
from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action
from rest_framework.response import Response
@@ -7,8 +6,6 @@ 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,
@@ -42,37 +39,6 @@ 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,

View File

@@ -30,11 +30,8 @@ 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(required=False, default=[])
tag_names = TagListField()
def create(self, validated_data):
bookmark = Bookmark()

View File

@@ -264,4 +264,17 @@
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>

View File

@@ -4,10 +4,8 @@
export let id;
export let name;
export let value;
export let apiClient;
export let variant = 'default';
export let tags;
let tags = [];
let isFocus = false;
let isOpen = false;
let input = null;
@@ -15,18 +13,6 @@
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;
}
@@ -41,9 +27,7 @@
const word = getCurrentWord(input);
suggestions = word
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
: [];
suggestions = word ? tags.filter(tag => tag.indexOf(word) === 0) : [];
if (word && suggestions.length > 0) {
open();
@@ -86,7 +70,7 @@
function complete(suggestion) {
const bounds = getCurrentWordBounds(input);
const value = input.value;
input.value = value.substring(0, bounds.start) + suggestion.name + value.substring(bounds.end);
input.value = value.substring(0, bounds.start) + suggestion + value.substring(bounds.end);
close();
}
@@ -103,11 +87,11 @@
}
</script>
<div class="form-autocomplete" class:small={variant === 'small'}>
<div class="form-autocomplete">
<!-- 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 ||''}" placeholder="&nbsp;"
<input id="{id}" name="{name}" value="{value ||''}"
class="form-input" type="text" autocomplete="off"
on:input={handleInput} on:keydown={handleKeyDown}
on:focus={handleFocus} on:blur={handleBlur}>
@@ -121,7 +105,7 @@
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
<div class="tile tile-centered">
<div class="tile-content">
{tag.name}
{tag}
</div>
</div>
</a>
@@ -141,16 +125,9 @@
display: block;
}
.form-autocomplete.small .form-autocomplete-input {
height: 1.4rem;
min-height: 1.4rem;
/* TODO: Should be read from theme */
.menu-item.selected > a {
background: #f1f1fc;
color: #5755d9;
}
.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>
</style>

View File

@@ -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,15 +14,7 @@ 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}`
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}`
const url = `${this.baseUrl}bookmarks/archived?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
return fetch(url)
.then(response => response.json())

View File

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

View File

@@ -1,18 +0,0 @@
# Generated by Django 2.2.18 on 2021-03-30 10:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0007_userprofile'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='bookmark_date_display',
field=models.CharField(choices=[('relative', 'Relative'), ('absolute', 'Absolute'), ('hidden', 'Hidden')], default='relative', max_length=10),
),
]

View File

@@ -2,10 +2,7 @@ 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
@@ -96,43 +93,3 @@ 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'),
]
BOOKMARK_DATE_DISPLAY_RELATIVE = 'relative'
BOOKMARK_DATE_DISPLAY_ABSOLUTE = 'absolute'
BOOKMARK_DATE_DISPLAY_HIDDEN = 'hidden'
BOOKMARK_DATE_DISPLAY_CHOICES = [
(BOOKMARK_DATE_DISPLAY_RELATIVE, 'Relative'),
(BOOKMARK_DATE_DISPLAY_ABSOLUTE, 'Absolute'),
(BOOKMARK_DATE_DISPLAY_HIDDEN, 'Hidden'),
]
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)
bookmark_date_display = models.CharField(max_length=10, choices=BOOKMARK_DATE_DISPLAY_CHOICES, blank=False,
default=BOOKMARK_DATE_DISPLAY_RELATIVE)
class UserProfileForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = ['theme', 'bookmark_date_display']
@receiver(post_save, sender=get_user_model())
def create_user_profile(sender, instance, created, **kwargs):
if created:
UserProfile.objects.create(user=instance)
@receiver(post_save, sender=get_user_model())
def save_user_profile(sender, instance, **kwargs):
instance.profile.save()

View File

@@ -55,8 +55,8 @@ def _base_bookmarks_query(user: User, query_string: str) -> QuerySet:
tags__name__iexact=tag_name
)
# Sort by date added
query_set = query_set.order_by('-date_added')
# Sort by modification date
query_set = query_set.order_by('-date_modified')
return query_set

View File

@@ -1,5 +1,3 @@
from typing import Union
from django.contrib.auth.models import User
from django.utils import timezone
@@ -48,13 +46,6 @@ 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()
@@ -62,46 +53,6 @@ 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
@@ -117,8 +68,3 @@ def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
tag_names = parse_tag_string(tag_string, ' ')
tags = get_or_create_tags(tag_names, user)
bookmark.tags.set(tags)
def _sanitize_id_list(bookmark_ids: [Union[int, str]]) -> [int]:
# Convert string ids to int if necessary
return [int(bm_id) if isinstance(bm_id, str) else bm_id for bm_id in bookmark_ids]

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

View File

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

View File

@@ -1,10 +1,5 @@
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 {
@@ -15,22 +10,20 @@ header {
.navbar-brand {
display: flex;
align-items: center;
.logo {
width: 28px;
height: 28px;
background-color: $primary-color;
color: $light-color;
padding: 14px;
}
h1 {
text-transform: uppercase;
display: inline-block;
margin: 0 0 0 8px;
}
}
.dropdown-toggle {
padding: 0;
}
}
@@ -46,18 +39,9 @@ h2 {
color: $gray-color-dark;
}
// 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;
// Button color should not change for anchor elements
.btn:visited:not(.btn-primary) {
color: $primary-color;
}
// Increase spacing between columns
@@ -68,25 +52,4 @@ a:visited:hover {
// Remove left padding from first pagination link
.pagination .page-item:first-child a {
padding-left: 0;
}
// Override border color for tab block
.tab-block {
border-bottom: solid 1px $border-color;
}
// Form auto-complete menu
.form-autocomplete .menu {
.menu-item.selected > a, .menu-item > a:hover {
background: $secondary-color;
color: $primary-color;
}
.group-item, .group-item:hover {
color: $gray-color;
text-transform: uppercase;
background: none;
font-size: 0.6rem;
font-weight: bold;
}
}
}

View File

@@ -30,12 +30,6 @@
}
}
.bookmarks-page .content-area-header {
span.btn {
margin-left: 8px;
}
}
ul.bookmark-list {
list-style: none;
@@ -45,33 +39,23 @@ ul.bookmark-list {
.description {
color: $gray-color-dark;
a, a:visited:hover {
a {
color: $alternative-color;
}
}
.actions > *:not(:last-child) {
margin-right: 0.1rem;
}
.actions .btn-link {
color: $gray-color;
padding: 0;
height: auto;
vertical-align: unset;
border: none;
padding-left: 0;
padding-right: 0;
&:focus,
&:hover,
&:active,
&.active {
color: $gray-color-dark;
color: darken($gray-color, 10%);
}
}
.bulk-edit-toggle {
display: none;
}
}
.bookmark-pagination {
@@ -80,7 +64,7 @@ ul.bookmark-list {
.tag-cloud {
a, a:visited:hover {
a {
color: $alternative-color;
}
@@ -97,41 +81,12 @@ ul.bookmark-list {
.bookmarks-form {
.btn.form-icon {
padding: 0;
width: 20px;
height: 20px;
visibility: hidden;
color: $gray-color;
&:focus,
&:hover,
&:active,
&.active {
color: $gray-color-dark;
}
> svg {
width: 20px;
height: 20px;
}
}
.has-icon-right > input, .has-icon-right > textarea {
padding-right: 30px;
}
.has-icon-right > input:placeholder-shown ~ .btn.form-icon,
.has-icon-right > textarea:placeholder-shown ~ .btn.form-icon {
visibility: visible;
}
.form-icon.loading {
visibility: hidden;
}
.form-input-hint.bookmark-exists {
display: none;
visibility: hidden;
color: $warning-color;
a {
@@ -141,101 +96,3 @@ ul.bookmark-list {
}
}
}
/* Bulk edit */
$bulk-edit-toggle-width: 16px;
$bulk-edit-toggle-offset: 8px;
$bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset);
$bulk-edit-transition-duration: 400ms;
.bulk-edit-form {
.bulk-edit-bar {
margin-top: -17px;
margin-bottom: 16px;
margin-left: -$bulk-edit-bar-offset;
max-height: 0;
overflow: hidden;
transition: max-height $bulk-edit-transition-duration;
}
.bulk-edit-actions {
display: flex;
align-items: baseline;
padding: 4px 0;
border-top: solid 1px $border-color;
button:hover {
text-decoration: underline;
}
> label.form-checkbox {
min-height: 1rem;
}
> button {
padding: 0;
margin-left: 8px;
}
> span {
margin-left: 8px;
}
> input, .form-autocomplete {
width: auto;
margin-left: 4px;
max-width: 200px;
-webkit-appearance: none;
}
span.confirmation {
display: flex;
}
span.confirmation button {
padding: 0;
}
}
.bulk-edit-all-toggle {
width: $bulk-edit-toggle-width;
margin: 0 0 0 $bulk-edit-toggle-offset;
padding: 0;
}
ul.bookmark-list li {
position: relative;
}
ul.bookmark-list li .bulk-edit-toggle {
display: block;
position: absolute;
width: $bulk-edit-toggle-width;
left: -$bulk-edit-toggle-width - $bulk-edit-toggle-offset;
top: 0;
padding: 0;
margin: 0;
visibility: hidden;
opacity: 0;
transition: all $bulk-edit-transition-duration;
i {
top: 0.2rem;
}
}
}
#bulk-edit-mode {
display: none;
}
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-toggle {
visibility: visible;
opacity: 1;
}
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-bar {
max-height: 37px;
border-bottom: solid 1px $border-color;
}

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
// Content area component
section.content-area {
.content-area-header {
@@ -12,11 +11,3 @@ section.content-area {
}
}
}
// Confirm button component
.btn-confirmation-action {
color: $error-color !important;
&:hover {
text-decoration: underline;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,9 +4,6 @@
{% load bookmarks %}
{% block content %}
{% include 'bookmarks/bulk_edit/state.html' %}
<div class="bookmarks-page columns">
{# Bookmark list #}
@@ -15,20 +12,13 @@
<h2>Archived bookmarks</h2>
<div class="spacer"></div>
{% bookmark_search query tags mode='archive' %}
{% include 'bookmarks/bulk_edit/toggle.html' %}
</div>
<form class="bulk-edit-form" action="{% url 'bookmarks:bulk_edit' %}?return_url={{ return_url }}"
method="post">
{% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with mode='archive' %}
{% if empty %}
{% include 'bookmarks/empty_bookmarks.html' %}
{% else %}
{% bookmark_list bookmarks return_url %}
{% endif %}
</form>
{% if empty %}
{% include 'bookmarks/empty_bookmarks.html' %}
{% else %}
{% bookmark_list bookmarks return_url %}
{% endif %}
</section>
{# Tag list #}
@@ -41,6 +31,4 @@
</div>
<script src="{% static "bundle.js" %}"></script>
<script src="{% static "shared.js" %}"></script>
<script src="{% static "bulk_edit.js" %}"></script>
{% endblock %}

View File

@@ -4,20 +4,16 @@
<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 %}
@@ -26,14 +22,6 @@
{% endif %}
</div>
<div class="actions">
{% if request.user.profile.bookmark_date_display == 'relative' %}
<span class="text-gray text-sm">{{ bookmark.date_added|humanize_relative_date }}</span>
<span class="text-gray text-sm">|</span>
{% endif %}
{% if request.user.profile.bookmark_date_display == 'absolute' %}
<span class="text-gray text-sm">{{ bookmark.date_added|humanize_absolute_date }}</span>
<span class="text-gray text-sm">|</span>
{% endif %}
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm">Edit</a>
{% if bookmark.is_archived %}
@@ -44,7 +32,8 @@
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 btn-confirmation">Remove</a>
class="btn btn-link btn-sm"
onclick="return confirm('Do you really want to delete this bookmark?')">Remove</a>
</div>
</li>
{% endfor %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,20 +7,19 @@
{{ 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"|attr:"placeholder: " }}
{{ form.url|add_class:"form-input"|attr:"autofocus" }}
{% if form.url.errors %}
<div class="form-input-hint">
{{ form.url.errors }}
</div>
{% endif %}
<div class="form-input-hint bookmark-exists">
This URL is already bookmarked. You can <a href="#">edit</a> it or you can overwrite the existing bookmark
by saving this form.
This URL is already bookmarked. You can <a href="#">edit</a> it or you can overwrite the existing bookmark by saving this form.
</div>
</div>
<div class="form-group">
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
{{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off" }}
{{ form.tag_string|add_class:"form-input" }}
<div class="form-input-hint">
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not
exist it will be
@@ -31,16 +30,8 @@
<div class="form-group has-icon-right">
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
<div class="has-icon-right">
{{ form.title|add_class:"form-input"|attr:"autocomplete:off" }}
{{ form.title|add_class:"form-input" }}
<i class="form-icon loading"></i>
<a class="btn btn-link form-icon" title="Edit title from website">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"/>
<path fill-rule="evenodd"
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
clip-rule="evenodd"/>
</svg>
</a>
</div>
<div class="form-input-hint">
Optional, leave empty to use title from website.
@@ -52,14 +43,6 @@
<div class="has-icon-right">
{{ form.description|add_class:"form-input"|attr:"rows:4" }}
<i class="form-icon loading"></i>
<a class="btn btn-link form-icon" title="Edit description from website">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"/>
<path fill-rule="evenodd"
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
clip-rule="evenodd"/>
</svg>
</a>
</div>
<div class="form-input-hint">
Optional, leave empty to use description from website.
@@ -81,7 +64,8 @@
<script type="application/javascript">
const wrapper = document.createElement('div');
const tagInput = document.getElementById('{{ form.tag_string.id_for_label }}');
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
const allTagsString = '{{ all_tags }}';
const allTags = allTagsString.split(' ');
new linkding.TagAutoComplete({
target: wrapper,
@@ -89,7 +73,7 @@
id: '{{ form.tag_string.id_for_label }}',
name: '{{ form.tag_string.name }}',
value: tagInput.value,
apiClient: apiClient
tags: allTags
}
});
@@ -99,72 +83,49 @@
/**
* - Pre-fill title and description placeholders with metadata from website as soon as URL changes
* - Show hint if URL is already bookmarked
* - Setup buttons that allow editing of scraped website values
*/
(function init() {
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
const titleInput = document.getElementById('{{ form.title.id_for_label }}');
const descriptionInput = document.getElementById('{{ form.description.id_for_label }}');
const editedBookmarkId = {{ bookmark_id }};
const editedBookmarkId = {{ bookmark_id }}
function toggleLoadingIcon(input, show) {
const icon = input.parentNode.querySelector('i.form-icon');
icon.style['visibility'] = show ? 'visible' : 'hidden';
}
function updatePlaceholder(input, value) {
if (value) {
input.setAttribute('placeholder', value);
} else {
input.removeAttribute('placeholder');
}
}
urlInput.addEventListener('input', checkUrl);
function checkUrl() {
toggleLoadingIcon(titleInput, true);
toggleLoadingIcon(descriptionInput, true);
updatePlaceholder(titleInput, null);
updatePlaceholder(descriptionInput, null);
toggleIcon(titleInput, true);
toggleIcon(descriptionInput, true);
const websiteUrl = encodeURIComponent(urlInput.value);
const requestUrl = `{% url 'bookmarks:api-root' %}bookmarks/check?url=${websiteUrl}`;
const requestUrl = `{% url 'bookmarks:api.check_url' %}?url=${websiteUrl}`;
fetch(requestUrl)
.then(response => response.json())
.then(data => {
const metadata = data.metadata;
updatePlaceholder(titleInput, metadata.title);
updatePlaceholder(descriptionInput, metadata.description);
toggleLoadingIcon(titleInput, false);
toggleLoadingIcon(descriptionInput, false);
const metadata = data.metadata
titleInput.setAttribute('placeholder', metadata.title || '');
descriptionInput.setAttribute('placeholder', metadata.description || '');
toggleIcon(titleInput, false);
toggleIcon(descriptionInput, false);
// Display hint if URL is already bookmarked
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
const editExistingBookmarkLink = bookmarkExistsHint.querySelector('a');
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists')
const editExistingBookmarkLink = bookmarkExistsHint.querySelector('a')
if (data.bookmark && data.bookmark.id !== editedBookmarkId) {
bookmarkExistsHint.style['display'] = 'block';
editExistingBookmarkLink.href = data.bookmark.edit_url;
if(data.bookmark && data.bookmark.id !== editedBookmarkId) {
bookmarkExistsHint.style['visibility'] = 'visible'
editExistingBookmarkLink.href = data.bookmark.edit_url
} else {
bookmarkExistsHint.style['display'] = 'none';
bookmarkExistsHint.style['visibility'] = 'hidden'
}
});
}
function setupEditAutoValueButton(input) {
const editAutoValueButton = input.parentNode.querySelector('.btn.form-icon');
if (!editAutoValueButton) return;
editAutoValueButton.addEventListener('click', function (event) {
event.preventDefault();
input.value = input.getAttribute('placeholder');
input.focus();
input.select();
});
function toggleIcon(input, show) {
const icon = input.parentNode.querySelector('i.form-icon');
icon.style['visibility'] = show ? 'visible' : 'hidden';
}
if (urlInput.value) checkUrl();
urlInput.addEventListener('input', checkUrl);
setupEditAutoValueButton(titleInput);
setupEditAutoValueButton(descriptionInput);
})();
</script>
</div>

View File

@@ -4,9 +4,6 @@
{% load bookmarks %}
{% block content %}
{% include 'bookmarks/bulk_edit/state.html' %}
<div class="bookmarks-page columns">
{# Bookmark list #}
@@ -15,20 +12,13 @@
<h2>Bookmarks</h2>
<div class="spacer"></div>
{% bookmark_search query tags %}
{% include 'bookmarks/bulk_edit/toggle.html' %}
</div>
<form class="bulk-edit-form" action="{% url 'bookmarks:bulk_edit' %}?return_url={{ return_url }}"
method="post">
{% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with mode='default' %}
{% if empty %}
{% include 'bookmarks/empty_bookmarks.html' %}
{% else %}
{% bookmark_list bookmarks return_url %}
{% endif %}
</form>
{% if empty %}
{% include 'bookmarks/empty_bookmarks.html' %}
{% else %}
{% bookmark_list bookmarks return_url %}
{% endif %}
</section>
{# Tag list #}
@@ -41,6 +31,4 @@
</div>
<script src="{% static "bundle.js" %}"></script>
<script src="{% static "shared.js" %}"></script>
<script src="{% static "bulk_edit.js" %}"></script>
{% endblock %}

View File

@@ -2,8 +2,7 @@
{% load sass_tags %}
<!DOCTYPE html>
{# Use data attributes as storage for access in static scripts #}
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}">
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="{% static 'favicon.png' %}"/>
@@ -13,24 +12,14 @@
<meta name="author" content="Sascha Ißbrücker">
<title>linkding</title>
{# Include SASS styles, files are resolved from bookmarks/styles #}
{# 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 %}
<link href="{% sass_src 'index.scss' %}" rel="stylesheet" type="text/css"/>
</head>
<body>
<header class="navbar container grid-lg">
<section class="navbar-section">
<a href="/" class="navbar-brand text-bold">
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
<i class="logo icon icon-link s-circle"></i>
<h1>linkding</h1>
</a>
</section>

View File

@@ -7,16 +7,12 @@
</div>
{# Menu drop-down for smaller devices #}
<div class="show-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">
<i class="icon icon-plus"></i>
</a>
<div class="dropdown dropdown-right">
<a href="#" id="mobile-nav-menu-trigger" class="btn btn-link dropdown-toggle" tabindex="0">
<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>
<i class="icon icon-menu icon-2x"></i>
</a>
<!-- menu component -->
<ul class="menu">
@@ -50,4 +46,4 @@
mobileNavMenuTrigger.addEventListener('blur', function () {
document.removeEventListener('click', mobileNavMenuOutsideClickHandler);
})
</script>
</script>

View File

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

View File

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

View File

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

View File

@@ -1,33 +1,13 @@
{% 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">
<label for="{{ form.bookmark_date_display.id_for_label }}" class="form-label">Bookmark date format</label>
{{ form.bookmark_date_display|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">
<h2>Import</h2>
<div class="content-area-header">
<h2>Import</h2>
</div>
<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' %}">
@@ -57,7 +37,9 @@
{# Export section #}
<section class="content-area">
<h2>Export</h2>
<div class="content-area-header">
<h2>Export</h2>
</div>
<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 %}
@@ -69,6 +51,41 @@
{% 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 %}

View File

@@ -1,33 +0,0 @@
{% extends "bookmarks/layout.html" %}
{% block content %}
<div class="settings-page">
{% include 'settings/nav.html' %}
{# Integrations section #}
<section class="content-area">
<h2>Browser Extension</h2>
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The extension is available in the official extension stores for:</p>
<ul>
<li><a href="https://addons.mozilla.org/de/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe" target="_blank">Chrome</a></li>
</ul>
<p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a> as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p>
<h2>Bookmarklet</h2>
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding application
first. Here's how it works:</p>
<ul>
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
<li>Open the website that you want to bookmark</li>
<li>Click the bookmarklet in your browsers toolbar</li>
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
</ul>
<p>Drag the following bookmarklet to your browsers toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
class="btn btn-primary">📎 Add bookmark</a>
</section>
</div>
{% endblock %}

View File

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

View File

@@ -9,10 +9,15 @@ register = template.Library()
@register.inclusion_tag('bookmarks/form.html', name='bookmark_form')
def bookmark_form(form: BookmarkForm, cancel_url: str, bookmark_id: int = 0, auto_close: bool = False):
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, ' ')
return {
'form': form,
'auto_close': auto_close,
'all_tags': all_tags_string,
'bookmark_id': bookmark_id,
'cancel_url': cancel_url
}
@@ -53,7 +58,6 @@ def tag_cloud(context, tags: List[Tag]):
@register.inclusion_tag('bookmarks/bookmark_list.html', name='bookmark_list', takes_context=True)
def bookmark_list(context, bookmarks: Page, return_url: str):
return {
'request': context['request'],
'bookmarks': bookmarks,
'return_url': return_url
}

View File

@@ -1,7 +1,5 @@
from django import template
from bookmarks import utils
register = template.Library()
@@ -45,17 +43,3 @@ def first_char(text):
@register.filter(name='remaining_chars')
def remaining_chars(text, index):
return text[index:]
@register.filter(name='humanize_absolute_date')
def humanize_absolute_date(value):
if value in (None, ''):
return ''
return utils.humanize_absolute_date(value)
@register.filter(name='humanize_relative_date')
def humanize_relative_date(value):
if value in (None, ''):
return ''
return utils.humanize_relative_date(value)

View File

@@ -1,64 +0,0 @@
from django.contrib.auth.models import User
from django.utils import timezone
from django.utils.crypto import get_random_string
from rest_framework import status
from rest_framework.test import APITestCase
from bookmarks.models import Bookmark, Tag
class BookmarkFactoryMixin:
user = None
def get_or_create_test_user(self):
if self.user is None:
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
return self.user
def setup_bookmark(self, is_archived: bool = False, tags: [Tag] = [], user: User = None):
if user is None:
user = self.get_or_create_test_user()
unique_id = get_random_string(length=32)
bookmark = Bookmark(
url='https://example.com/' + unique_id,
date_added=timezone.now(),
date_modified=timezone.now(),
owner=user,
is_archived=is_archived
)
bookmark.save()
for tag in tags:
bookmark.tags.add(tag)
bookmark.save()
return bookmark
def setup_tag(self, user: User = None):
if user is None:
user = self.get_or_create_test_user()
name = get_random_string(length=32)
tag = Tag(name=name, date_added=timezone.now(), owner=user)
tag.save()
return tag
class LinkdingApiTestCase(APITestCase):
def get(self, url, expected_status_code=status.HTTP_200_OK):
response = self.client.get(url)
self.assertEqual(response.status_code, expected_status_code)
return response
def post(self, url, data=None, expected_status_code=status.HTTP_200_OK):
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, expected_status_code)
return response
def put(self, url, data=None, expected_status_code=status.HTTP_200_OK):
response = self.client.put(url, data, format='json')
self.assertEqual(response.status_code, expected_status_code)
return response
def delete(self, url, expected_status_code=status.HTTP_200_OK):
response = self.client.delete(url)
self.assertEqual(response.status_code, expected_status_code)
return response

View File

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

View File

@@ -1,51 +0,0 @@
from dateutil.relativedelta import relativedelta
from django.core.paginator import Paginator
from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory
from django.utils import timezone, formats
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.models import UserProfile
class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
def render_template(self, bookmarks) -> str:
rf = RequestFactory()
request = rf.get('/test')
request.user = self.get_or_create_test_user()
paginator = Paginator(bookmarks, 10)
page = paginator.page(1)
context = RequestContext(request, {'bookmarks': page, 'return_url': '/test'})
template_to_render = Template(
'{% load bookmarks %}'
'{% bookmark_list bookmarks return_url %}'
)
return template_to_render.render(context)
def setup_date_format_test(self, date_display_setting):
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.save()
user = self.get_or_create_test_user()
user.profile.bookmark_date_display = date_display_setting
user.profile.save()
return bookmark
def test_should_respect_absolute_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE)
html = self.render_template([bookmark])
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
self.assertInHTML(f'''
<span class="text-gray text-sm">{formatted_date}</span>
''', html)
def test_should_respect_relative_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE)
html = self.render_template([bookmark])
self.assertInHTML('''
<span class="text-gray text-sm">1 week ago</span>
''', html)

View File

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

View File

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

View File

@@ -1,13 +1,38 @@
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, BookmarkFactoryMixin):
class QueriesTestCase(TestCase):
def setUp(self) -> None:
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
def setup_bookmark(self, is_archived: bool = False, tags: [Tag] = []):
unique_id = get_random_string(length=32)
bookmark = Bookmark(
url='https://example.com/' + unique_id,
date_added=timezone.now(),
date_modified=timezone.now(),
owner=self.user,
is_archived=is_archived
)
bookmark.save()
for tag in tags:
bookmark.tags.add(tag)
bookmark.save()
return bookmark
def setup_tag(self):
name = get_random_string(length=32)
tag = Tag(name=name, date_added=timezone.now(), owner=self.user)
tag.save()
return tag
def test_query_bookmarks_should_not_return_archived_bookmarks(self):
bookmark1 = self.setup_bookmark()
@@ -16,7 +41,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True)
query = queries.query_bookmarks(self.get_or_create_test_user(), '')
query = queries.query_bookmarks(self.user, '')
self.assertCountEqual([bookmark1, bookmark2], list(query))
@@ -27,7 +52,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark()
self.setup_bookmark()
query = queries.query_archived_bookmarks(self.get_or_create_test_user(), '')
query = queries.query_archived_bookmarks(self.user, '')
self.assertCountEqual([bookmark1, bookmark2], list(query))
@@ -38,7 +63,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark()
self.setup_bookmark(is_archived=True, tags=[tag2])
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '')
query = queries.query_bookmark_tags(self.user, '')
self.assertCountEqual([tag1], list(query))
@@ -48,7 +73,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(tags=[tag])
self.setup_bookmark(tags=[tag])
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '')
query = queries.query_bookmark_tags(self.user, '')
self.assertCountEqual([tag], list(query))
@@ -59,7 +84,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark()
self.setup_bookmark(is_archived=True, tags=[tag2])
query = queries.query_archived_bookmark_tags(self.get_or_create_test_user(), '')
query = queries.query_archived_bookmark_tags(self.user, '')
self.assertCountEqual([tag2], list(query))
@@ -69,6 +94,6 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, tags=[tag])
self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmark_tags(self.get_or_create_test_user(), '')
query = queries.query_archived_bookmark_tags(self.user, '')
self.assertCountEqual([tag], list(query))

View File

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

View File

@@ -1,65 +0,0 @@
from unittest.mock import patch
from django.test import TestCase
from django.utils import timezone
from bookmarks.utils import humanize_absolute_date, humanize_relative_date
class UtilsTestCase(TestCase):
def test_humanize_absolute_date(self):
test_cases = [
(timezone.datetime(2021, 1, 1), timezone.datetime(2023, 1, 1), '01/01/2021'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 2, 1), '01/01/2021'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 8), '01/01/2021'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 7), 'Friday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 7, 23, 59), 'Friday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 3), 'Friday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 2), 'Yesterday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 2, 23, 59), 'Yesterday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 1), 'Today'),
]
for test_case in test_cases:
result = humanize_absolute_date(test_case[0], test_case[1])
self.assertEqual(test_case[2], result)
def test_humanize_absolute_date_should_use_current_date_as_default(self):
with patch.object(timezone, 'now', return_value=timezone.datetime(2021, 1, 1)):
self.assertEqual(humanize_absolute_date(timezone.datetime(2021, 1, 1)), 'Today')
# Regression: Test that subsequent calls use current date instead of cached date (#107)
with patch.object(timezone, 'now', return_value=timezone.datetime(2021, 1, 13)):
self.assertEqual(humanize_absolute_date(timezone.datetime(2021, 1, 13)), 'Today')
def test_humanize_relative_date(self):
test_cases = [
(timezone.datetime(2021, 1, 1), timezone.datetime(2022, 1, 1), '1 year ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2022, 12, 31), '1 year ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2023, 1, 1), '2 years ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2023, 12, 31), '2 years ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 12, 31), '11 months ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 2, 1), '1 month ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 31), '4 weeks ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 14), '1 week ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 8), '1 week ago'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 7), 'Friday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 7, 23, 59), 'Friday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 3), 'Friday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 2), 'Yesterday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 2, 23, 59), 'Yesterday'),
(timezone.datetime(2021, 1, 1), timezone.datetime(2021, 1, 1), 'Today'),
]
for test_case in test_cases:
result = humanize_relative_date(test_case[0], test_case[1])
self.assertEqual(test_case[2], result)
def test_humanize_relative_date_should_use_current_date_as_default(self):
with patch.object(timezone, 'now', return_value=timezone.datetime(2021, 1, 1)):
self.assertEqual(humanize_relative_date(timezone.datetime(2021, 1, 1)), 'Today')
# Regression: Test that subsequent calls use current date instead of cached date (#107)
with patch.object(timezone, 'now', return_value=timezone.datetime(2021, 1, 13)):
self.assertEqual(humanize_relative_date(timezone.datetime(2021, 1, 13)), 'Today')

View File

@@ -18,14 +18,11 @@ 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.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', views.settings.index, name='settings.index'),
path('settings/import', views.settings.bookmark_import, name='settings.import'),
path('settings/export', views.settings.bookmark_export, name='settings.export'),
# API
path('api/check_url', views.api.check_url, name='api.check_url'),
path('api/', include(router.urls), name='api')
]

View File

@@ -1,60 +1,2 @@
from datetime import datetime
from typing import Optional
from dateutil.relativedelta import relativedelta
from django.template.defaultfilters import pluralize
from django.utils import timezone, formats
def unique(elements, key):
return list({key(element): element for element in elements}.values())
weekday_names = {
1: 'Monday',
2: 'Tuesday',
3: 'Wednesday',
4: 'Thursday',
5: 'Friday',
6: 'Saturday',
7: 'Sunday',
}
def humanize_absolute_date(value: datetime, now: Optional[datetime] = None):
if not now:
now = timezone.now()
delta = relativedelta(now, value)
yesterday = now - relativedelta(days=1)
is_older_than_a_week = delta.years > 0 or delta.months > 0 or delta.weeks > 0
if is_older_than_a_week:
return formats.date_format(value, 'SHORT_DATE_FORMAT')
elif value.day == now.day:
return 'Today'
elif value.day == yesterday.day:
return 'Yesterday'
else:
return weekday_names[value.isoweekday()]
def humanize_relative_date(value: datetime, now: Optional[datetime] = None):
if not now:
now = timezone.now()
delta = relativedelta(now, value)
if delta.years > 0:
return f'{delta.years} year{pluralize(delta.years)} ago'
elif delta.months > 0:
return f'{delta.months} month{pluralize(delta.months)} ago'
elif delta.weeks > 0:
return f'{delta.weeks} week{pluralize(delta.weeks)} ago'
else:
yesterday = now - relativedelta(days=1)
if value.day == now.day:
return 'Today'
elif value.day == yesterday.day:
return 'Yesterday'
else:
return weekday_names[value.isoweekday()]

View File

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

27
bookmarks/views/api.py Normal file
View File

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

View File

@@ -8,8 +8,7 @@ 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, archive_bookmarks, \
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, unarchive_bookmark
_default_page_size = 30
@@ -88,9 +87,11 @@ 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')
}
@@ -115,10 +116,12 @@ 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
}
@@ -152,29 +155,6 @@ 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')

View File

@@ -7,7 +7,6 @@ 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
@@ -16,35 +15,15 @@ logger = logging.getLogger(__name__)
@login_required
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)
def index(request):
import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success')
import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors')
return render(request, 'settings/general.html', {
'form': form,
application_url = request.build_absolute_uri("/bookmarks/new")
api_token = Token.objects.get_or_create(user=request.user)[0]
return render(request, 'settings/index.html', {
'import_success_message': import_success_message,
'import_errors_message': import_errors_message,
})
@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
})
@@ -70,7 +49,7 @@ def bookmark_import(request):
messages.error(request, 'An error occurred during bookmark import.', 'bookmark_import_errors')
pass
return HttpResponseRedirect(reverse('bookmarks:settings.general'))
return HttpResponseRedirect(reverse('bookmarks:settings.index'))
@login_required
@@ -86,7 +65,7 @@ def bookmark_export(request):
return response
except:
return render(request, 'settings/general.html', {
return render(request, 'settings/index.html', {
'export_error': 'An error occurred during bookmark export.'
})

View File

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

View File

@@ -1,65 +0,0 @@
# Administration
This document describes how to make use of the admin app that comes as part of each linkding installation. This is the default Django admin app with some linkding specific customizations.
The admin app provides several features that are not available in the linkding UI:
- User management and user self-management
- Bookmark and tag management, including bulk operations
## Linkding administration page
To open the Admin app, go the *Settings* view and click on the *Admin* tab. This should open a new window with the admin app.
Alternatively you can open the URL directly by adding `/admin` to the URL of your linkding installation.
## User management
Go to the linkding administration page and select *Users*.
Here you can add and delete users, and change the password of a user.
Once you have added a user you can, if needed, give the user staff status, which means this user can also access the linkding administration page.
This page also allows you to change your own password if necessary.
## Bookmark management
While the linkding UI itself now has a bulk edit feature for bookmarks you can also use the admin app to manage bookmarks or to do bulk operations.
In the main linkding administration page, choose *Bookmarks*.
First select the bookmarks to operate on:
- Specify a filter to determine which bookmarks to operate on:
- In the column *by username*, you can choose to filter for bookmarks for a specific user
- In the column *by is archived*, you can choose to filter for bookmarks that are either archived or not
- In the column *by tags*, you can choose to filter for specific tags
- In the search box you can also add a text filter (note that this doesn't use the same search syntax as the linkding UI itself)
Now a list of bookmarks which match your filter is displayed, each bookmark on a separate line.
Each line starts with a checkbox.
Either choose the individual bookmarks you want to do a bulk operation on, or choose the top checkbox to select all shown bookmarks.
Open the "Action" select box to choose the desired bulk operation:
- Delete
- Archive
- Unarchive
Click the button next to the checkbox to execute the operation.
## Tag management
While linkding UI currently only allows to create or assign tags, you can use the admin app to manage your tags. This can be especially useful if you want to clean up your tag collection.
In the main linkding administration page, choose *Tags*.
Similar to bookmarks management described above you can now specify which tags to operate on by specifying a filter and then selecting the individual tags.
Open the "Action" select box to choose the desired bulk operation:
- Delete
- Delete unused tags - this will only delete the selected tags that are currently not assigned to any bookmark
Click the button next to the checkbox to execute the operation.
Note that deleting a tag does not affect the bookmarks that are tagged with this tag, it only removes the tag from those bookmarks.

View File

@@ -1,52 +0,0 @@
# 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.

View File

@@ -1,46 +0,0 @@
# How To
Collection of tips and tricks around using linkding.
## Using the bookmarklet on Android/Chrome
This how-to explains the usage of the standard linkding bookmarklet on Android / Chrome.
Chrome on Android does not permit running bookmarklets in the same way you can on a desktop system. There is however a workaround that is explained here.
**Note** that this only works with Chrome and not with other browsers on Android.
Create a bookmark of your linkding deployment by clicking the star icon which you find in the three dots menu in the top right. Next you have to edit the bookmark. Edit the URL and replace it it with the bookmarklet code of your instance and give it an easy to type name like `bm` for bookmark or `ld` for linkding:
```
javascript: (function() { var bookmarkUrl = window.location; var applicationUrl = 'http://<YOUR_INSTANCE_HERE>/bookmarks/new'; applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl); applicationUrl += '&auto_close'; window.open(applicationUrl);})();
```
Now when you are browsing the web and you want to save the current page as a bookmark to your linkding instance simply type `bm` into the address bar and select it from the results. The bookmarklet code will trigger and you will be redirected so save the current page.
For more info see here: https://paul.kinlan.me/use-bookmarklets-on-chrome-on-android/
## Create a share action on iOS for adding bookmarks to linkding
This how-to explains how to make use of the app shortcuts iOS app to create a share action that can be used in Safari for adding bookmarks to your linkding instance.
**In the shortcuts app:**
- create new shortcut
- go to shortcut details, enable to option to show the shortcut in share menu
- from the available share input types only select "URL"
- add Safari action "Display website in Safari" (paraphrasing, not sure how it's called in english)
- for URL enter your linkding instance URL and specifically point to the new bookmark form, then add the shortcut input variable from the list of suggested variables after the URL parameter. Visually it should look something like this: `https://linkding.mydomain.com/bookmarks/new?url=[Shortcut input]`, where `[Shortcut input]` is a visual block that was inserted after selecting the shortcut input variable suggestion. This is basically a placeholder that will get replaced with the actual URL that you want to bookmark. See screenshot at the end for an example on how this looks.
- save, give the shortcut a nice name + glyph
Example of how the shortcut configuration should look:
![Screenshot](/docs/ios-app-shortcut-example.png?raw=true "Screenshot demonstrating how to insert the input placeholder into the URL")
**Using the share action from Safari:**
- browse to the website that you want to share
- click the share button
- your new app shortcut should now be available as share action
- select the app shortcut
- this should open a new Safari overlay showing the add bookmark form with the URL field prefilled
- after saving the bookmark you can close the overlay and continue browsing

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

After

Width:  |  Height:  |  Size: 296 KiB

View File

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

View File

@@ -1,20 +1,23 @@
beautifulsoup4==4.7.1
certifi==2019.6.16
chardet==3.0.4
confusable-homoglyphs==3.2.0
Django==2.2.20
Django==2.2.13
django-appconf==1.0.3
django-compressor==2.3
django-generate-secret-key==1.0.2
django-picklefield==2.0
django-registration==3.1.2
django-registration==3.0.1
django-sass-processor==0.7.3
django-widget-tweaks==1.4.5
djangorestframework==3.11.2
djangorestframework==3.11.1
idna==2.8
pyparsing==2.4.7
python-dateutil==2.8.1
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.8
urllib3==1.25.3
uWSGI==2.0.18

View File

@@ -2,20 +2,18 @@ beautifulsoup4==4.7.1
certifi==2019.6.16
chardet==3.0.4
confusable-homoglyphs==3.2.0
Django==2.2.20
Django==2.2.13
django-appconf==1.0.3
django-compressor==2.3
django-debug-toolbar==3.2.1
django-generate-secret-key==1.0.2
django-picklefield==2.0
django-registration==3.1.2
django-registration==3.0.1
django-sass-processor==0.7.3
django-widget-tweaks==1.4.5
djangorestframework==3.11.2
djangorestframework==3.11.1
idna==2.8
libsass==0.19.2
pyparsing==2.4.7
python-dateutil==2.8.1
pytz==2019.1
rcssmin==1.0.6
requests==2.22.0
@@ -23,4 +21,4 @@ rjsmin==1.1.0
six==1.12.0
soupsieve==1.9.2
sqlparse==0.3.0
urllib3==1.25.8
urllib3==1.25.3

View File

@@ -41,7 +41,7 @@ INSTALLED_APPS = [
'widget_tweaks',
'django_generate_secret_key',
'rest_framework',
'rest_framework.authtoken',
'rest_framework.authtoken'
]
MIDDLEWARE = [
@@ -52,7 +52,6 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.locale.LocaleMiddleware',
]
ROOT_URLCONF = 'siteroot.urls'

View File

@@ -11,14 +11,6 @@ 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,

View File

@@ -13,14 +13,13 @@ 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 bookmarks.admin import linkding_admin_site
from .settings import ALLOW_REGISTRATION, DEBUG
from .settings import ALLOW_REGISTRATION
urlpatterns = [
path('admin/', linkding_admin_site.urls),
path('admin/', admin.site.urls),
path('login/', auth_views.LoginView.as_view(redirect_authenticated_user=True,
extra_context=dict(allow_registration=ALLOW_REGISTRATION)),
name='login'),
@@ -28,9 +27,5 @@ urlpatterns = [
path('', include('bookmarks.urls')),
]
if DEBUG:
import debug_toolbar
urlpatterns.append(path('__debug__/', include(debug_toolbar.urls)))
if ALLOW_REGISTRATION:
urlpatterns.append(path('', include('django_registration.backends.one_step.urls')))

View File

@@ -1 +1 @@
1.6.4
1.3.0