mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-16 06:59:22 +02:00
Compare commits
34 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
7a14c6e2d1 | ||
![]() |
f7e6fbc588 | ||
![]() |
778f1b2ff3 | ||
![]() |
79dd4179d2 | ||
![]() |
0980e6a2b2 | ||
![]() |
83ccf5279f | ||
![]() |
3bab7db023 | ||
![]() |
b6b7d3f662 | ||
![]() |
9c51487d3b | ||
![]() |
c61e8ee2cd | ||
![]() |
f555bba9e9 | ||
![]() |
91d876a7f1 | ||
![]() |
085027b00a | ||
![]() |
94eb55896d | ||
![]() |
bea0fe3b70 | ||
![]() |
2d62ba3710 | ||
![]() |
63acde36de | ||
![]() |
70953a52b9 | ||
![]() |
f8fc360d84 | ||
![]() |
b2aeec2cac | ||
![]() |
cb7abbfacb | ||
![]() |
b844293342 | ||
![]() |
0f231bcd9f | ||
![]() |
9df270557f | ||
![]() |
f98c89e99d | ||
![]() |
6addee1377 | ||
![]() |
16ba7f390d | ||
![]() |
64914fb0d5 | ||
![]() |
ac0f0a7831 | ||
![]() |
f45310fc2b | ||
![]() |
17dccdf0e9 | ||
![]() |
c13b27e170 | ||
![]() |
816a887d99 | ||
![]() |
779de41b65 |
9
.env.sample
Normal file
9
.env.sample
Normal file
@@ -0,0 +1,9 @@
|
||||
# Docker container name
|
||||
LD_CONTAINER_NAME=linkding
|
||||
# Port on the host system that the application should be published on
|
||||
LD_HOST_PORT=9090
|
||||
# Directory on the host system that should be mounted as data dir into the Docker container
|
||||
LD_HOST_DATA_DIR=./data
|
||||
|
||||
# Option to disable URL validation for bookmarks completely
|
||||
LD_DISABLE_URL_VALIDATION=False
|
18
.github/workflows/main.yaml
vendored
Normal file
18
.github/workflows/main.yaml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: linkding CI
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
run_tests:
|
||||
name: Run Django Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
- name: Run tests
|
||||
run: python manage.py test
|
30
API.md
30
API.md
@@ -45,9 +45,11 @@ Example response:
|
||||
"url": "https://example.com",
|
||||
"title": "Example title",
|
||||
"description": "Example description",
|
||||
"website_title": "Website title",
|
||||
"website_description": "Website description",
|
||||
"tag_names": [
|
||||
"tag1",
|
||||
"tag2"
|
||||
"tag2"
|
||||
],
|
||||
"date_added": "2020-09-26T09:46:23.006313Z",
|
||||
"date_modified": "2020-09-26T16:01:14.275335Z"
|
||||
@@ -57,6 +59,16 @@ Example response:
|
||||
}
|
||||
```
|
||||
|
||||
**List Archived**
|
||||
|
||||
```
|
||||
GET /api/bookmarks/archived/
|
||||
```
|
||||
|
||||
List archived bookmarks.
|
||||
|
||||
Parameters and response are the same as for the regular list endpoint.
|
||||
|
||||
**Retrieve**
|
||||
|
||||
```
|
||||
@@ -109,6 +121,22 @@ Example payload:
|
||||
}
|
||||
```
|
||||
|
||||
**Archive**
|
||||
|
||||
```
|
||||
POST /api/bookmarks/<id>/archive/
|
||||
```
|
||||
|
||||
Archives a bookmark.
|
||||
|
||||
**Unarchive**
|
||||
|
||||
```
|
||||
POST /api/bookmarks/<id>/unarchive/
|
||||
```
|
||||
|
||||
Unarchives a bookmark.
|
||||
|
||||
**Delete**
|
||||
|
||||
```
|
||||
|
37
CHANGELOG.md
37
CHANGELOG.md
@@ -1,5 +1,42 @@
|
||||
# Changelog
|
||||
|
||||
## v1.3.1 (15/02/2021)
|
||||
*No changelog for this release.*
|
||||
|
||||
---
|
||||
|
||||
## v1.3.0 (14/02/2021)
|
||||
- [**closed**] Novice help. [#71](https://github.com/sissbruecker/linkding/issues/71)
|
||||
- [**closed**] Option to create bookmarks public [#70](https://github.com/sissbruecker/linkding/issues/70)
|
||||
- [**enhancement**] Show URL if title is not available [#64](https://github.com/sissbruecker/linkding/issues/64)
|
||||
- [**bug**] minor ui nitpicks [#62](https://github.com/sissbruecker/linkding/issues/62)
|
||||
- [**enhancement**] add an archive function [#46](https://github.com/sissbruecker/linkding/issues/46)
|
||||
- [**closed**] remove non fqdn check and alert [#36](https://github.com/sissbruecker/linkding/issues/36)
|
||||
- [**closed**] Add Lotus Notes links [#22](https://github.com/sissbruecker/linkding/issues/22)
|
||||
|
||||
---
|
||||
|
||||
## v1.2.1 (12/01/2021)
|
||||
- [**bug**] Bug: Two equal tags with different capitalisation lead to 500 server errors [#65](https://github.com/sissbruecker/linkding/issues/65)
|
||||
- [**closed**] Enhancement: category and pagination [#11](https://github.com/sissbruecker/linkding/issues/11)
|
||||
|
||||
---
|
||||
|
||||
## v1.2.0 (09/01/2021)
|
||||
- [**closed**] Add Favicon [#58](https://github.com/sissbruecker/linkding/issues/58)
|
||||
- [**closed**] Make tags case-insensitive [#45](https://github.com/sissbruecker/linkding/issues/45)
|
||||
|
||||
---
|
||||
|
||||
## v1.1.1 (01/01/2021)
|
||||
- [**enhancement**] Add docker-compose support [#54](https://github.com/sissbruecker/linkding/pull/54)
|
||||
---
|
||||
|
||||
## v1.1.0 (31/12/2020)
|
||||
- [**enhancement**] Search autocomplete [#52](https://github.com/sissbruecker/linkding/issues/52)
|
||||
- [**enhancement**] Improve Netscape bookmarks file parsing [#50](https://github.com/sissbruecker/linkding/issues/50)
|
||||
---
|
||||
|
||||
## v1.0.0 (31/12/2020)
|
||||
- [**bug**] Import does not import bookmark descriptions [#47](https://github.com/sissbruecker/linkding/issues/47)
|
||||
- [**enhancement**] Enhancement: return to same page we were on after editing a bookmark [#26](https://github.com/sissbruecker/linkding/issues/26)
|
||||
|
38
Options.md
Normal file
38
Options.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Options
|
||||
|
||||
This document lists the options that linkding can be configured with and explains how to use them in the individual install scenarios.
|
||||
|
||||
## Using options
|
||||
|
||||
### Docker
|
||||
|
||||
Options are passed as environment variables to the Docker container by using the `-e` argument when using `docker run`. For example:
|
||||
|
||||
```
|
||||
docker run --name linkding -p 9090:9090 -d -e LD_DISABLE_URL_VALIDATION=True sissbruecker/linkding:latest
|
||||
```
|
||||
|
||||
For multiple options, use one `-e` argument per option.
|
||||
|
||||
### Docker-compose
|
||||
|
||||
For docker-compose options are configured using an `.env` file.
|
||||
Follow the docker-compose setup in the README and copy `.env.sample` to `.env`. Then modify the options in `.env`.
|
||||
|
||||
### Manual setup
|
||||
|
||||
All options need to be defined as environment variables in the environment that linkding runs in.
|
||||
|
||||
## List of options
|
||||
|
||||
### `LD_DISABLE_URL_VALIDATION`
|
||||
|
||||
Values: `True`, `False` | Default = `False`
|
||||
|
||||
Completely disables URL validation for bookmarks. This can be useful if you intend to store non fully qualified domain name URLs, such as network paths, or you want to store URLs that use another protocol than `http` or `https`.
|
||||
|
||||
### `LD_REQUEST_TIMEOUT`
|
||||
|
||||
Values: `Integer` as seconds | Default = `60`
|
||||
|
||||
Configures the request timeout in the uwsgi application server. This can be useful if you want to import a bookmark file with a high number of bookmarks and run into request timeouts.
|
36
README.md
36
README.md
@@ -28,7 +28,7 @@ docker run --name linkding -p 9090:9090 -d sissbruecker/linkding:latest
|
||||
By default the application runs on port `9090`, but you can map it to a different host port by modifying the command above.
|
||||
|
||||
However for **production use** you also want to mount a data folder on your system, so that the applications database is not stored in the container, but on your hosts file system. This is safer in case something happens to the container and makes it easier to update the container later on, or to run backups. To do so you can use the following extended command, where you replace `{host-data-folder}` with the absolute path to a folder on your system where you want to store the data:
|
||||
```
|
||||
```shell
|
||||
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest
|
||||
```
|
||||
|
||||
@@ -38,20 +38,39 @@ If everything completed successfully the application should now be running and c
|
||||
|
||||
If you are using a Linux system you can use the following [shell script](https://github.com/sissbruecker/linkding/blob/master/install-linkding.sh) for an automated setup. The script does basically everything described above, but also handles updating an existing container to a new application version (technically replaces the existing container with a new container built from a newer image, while mounting the same data folder).
|
||||
|
||||
The script can be configured using using shell variables - for more details have a look at the script itself.
|
||||
The script can be configured using shell variables - for more details have a look at the script itself.
|
||||
|
||||
### Docker-compose setup
|
||||
|
||||
To install linkding using docker-compose you can use the `docker-compose.yml` file. Copy the `.env.sample` file to `.env` and set your parameters, then run:
|
||||
```shell
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### User setup
|
||||
|
||||
Finally you need to create a user so that you can access the frontend. Replace the credentials in the following command and run it:
|
||||
```
|
||||
|
||||
**Docker**
|
||||
```shell
|
||||
docker exec -it linkding python manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
```
|
||||
|
||||
**Docker-compose**
|
||||
```shell
|
||||
docker-compose exec linkding python manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
```
|
||||
|
||||
The command will prompt you for a secure password. After the command has completed you can start using the application by logging into the UI with your credentials.
|
||||
|
||||
### Manual setup
|
||||
|
||||
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:
|
||||
@@ -71,6 +90,17 @@ For automatic backups you want to backup the applications database. As described
|
||||
|
||||
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
|
||||
|
||||
**Import fails with `502 Bad Gateway`**
|
||||
|
||||
The default timeout for requests is 60 seconds, after which the application server will cancel the request and return the above error.
|
||||
Depending on the system that the application runs on, and the number of bookmarks that need to be imported, the import may take longer than the default 60 seconds.
|
||||
|
||||
To increase the timeout you can configure the [`LD_REQUEST_TIMEOUT` option](Options.md#LD_REQUEST_TIMEOUT).
|
||||
|
||||
Note that any proxy servers that you are running in front of linkding may have their own timeout settings, which are not affected by the variable.
|
||||
|
||||
## Development
|
||||
|
||||
The application is open source, so you are free to modify or contribute. The application is built using the Django web framework. You can get started by checking out the excellent Django docs: https://docs.djangoproject.com/en/3.0/. The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
|
||||
|
BIN
assets/logo.afdesign
Normal file
BIN
assets/logo.afdesign
Normal file
Binary file not shown.
@@ -1,9 +1,14 @@
|
||||
from rest_framework import viewsets, mixins
|
||||
from django.urls import reverse
|
||||
from rest_framework import viewsets, mixins, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||
from bookmarks.services.website_loader import load_website_metadata
|
||||
|
||||
|
||||
class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
@@ -27,6 +32,47 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
def get_serializer_context(self):
|
||||
return {'user': self.request.user}
|
||||
|
||||
@action(methods=['get'], detail=False)
|
||||
def archived(self, request):
|
||||
user = request.user
|
||||
query_string = request.GET.get('q')
|
||||
query_set = queries.query_archived_bookmarks(user, query_string)
|
||||
page = self.paginate_queryset(query_set)
|
||||
serializer = self.get_serializer_class()
|
||||
data = serializer(page, many=True).data
|
||||
return self.get_paginated_response(data)
|
||||
|
||||
@action(methods=['post'], detail=True)
|
||||
def archive(self, request, pk):
|
||||
bookmark = self.get_object()
|
||||
archive_bookmark(bookmark)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(methods=['post'], detail=True)
|
||||
def unarchive(self, request, pk):
|
||||
bookmark = self.get_object()
|
||||
unarchive_bookmark(bookmark)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(methods=['get'], detail=False)
|
||||
def check(self, request):
|
||||
url = request.GET.get('url')
|
||||
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
||||
existing_bookmark_data = None
|
||||
|
||||
if bookmark is not None:
|
||||
existing_bookmark_data = {
|
||||
'id': bookmark.id,
|
||||
'edit_url': reverse('bookmarks:edit', args=[bookmark.id])
|
||||
}
|
||||
|
||||
metadata = load_website_metadata(url)
|
||||
|
||||
return Response({
|
||||
'bookmark': existing_bookmark_data,
|
||||
'metadata': metadata.to_dict()
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class TagViewSet(viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
|
@@ -12,8 +12,23 @@ class TagListField(serializers.ListField):
|
||||
class BookmarkSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = ['id', 'url', 'title', 'description', 'tag_names', 'date_added', 'date_modified']
|
||||
read_only_fields = ['date_added', 'date_modified']
|
||||
fields = [
|
||||
'id',
|
||||
'url',
|
||||
'title',
|
||||
'description',
|
||||
'website_title',
|
||||
'website_description',
|
||||
'tag_names',
|
||||
'date_added',
|
||||
'date_modified'
|
||||
]
|
||||
read_only_fields = [
|
||||
'website_title',
|
||||
'website_description',
|
||||
'date_added',
|
||||
'date_modified'
|
||||
]
|
||||
|
||||
# Override readonly tag_names property to allow passing a list of tag names to create/update
|
||||
tag_names = TagListField()
|
||||
|
280
bookmarks/components/SearchAutoComplete.svelte
Normal file
280
bookmarks/components/SearchAutoComplete.svelte
Normal file
@@ -0,0 +1,280 @@
|
||||
<script>
|
||||
import {SearchHistory} from "./SearchHistory";
|
||||
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "./util";
|
||||
|
||||
const searchHistory = new SearchHistory()
|
||||
|
||||
export let name;
|
||||
export let placeholder;
|
||||
export let value;
|
||||
export let tags;
|
||||
export let mode = 'default';
|
||||
export let apiClient;
|
||||
|
||||
let isFocus = false;
|
||||
let isOpen = false;
|
||||
let suggestions = []
|
||||
let selectedIndex = undefined;
|
||||
let input = null;
|
||||
|
||||
// Track current search query after loading the page
|
||||
searchHistory.pushCurrent()
|
||||
updateSuggestions()
|
||||
|
||||
function handleFocus() {
|
||||
isFocus = true;
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
isFocus = false;
|
||||
close();
|
||||
}
|
||||
|
||||
function handleInput(e) {
|
||||
value = e.target.value
|
||||
debouncedLoadSuggestions()
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
// Enter
|
||||
if (isOpen && selectedIndex !== undefined && (e.keyCode === 13 || e.keyCode === 9)) {
|
||||
const suggestion = suggestions.total[selectedIndex];
|
||||
if (suggestion) completeSuggestion(suggestion);
|
||||
e.preventDefault();
|
||||
}
|
||||
// Escape
|
||||
if (e.keyCode === 27) {
|
||||
close();
|
||||
e.preventDefault();
|
||||
}
|
||||
// Up arrow
|
||||
if (e.keyCode === 38) {
|
||||
updateSelection(-1);
|
||||
e.preventDefault();
|
||||
}
|
||||
// Down arrow
|
||||
if (e.keyCode === 40) {
|
||||
if (!isOpen) {
|
||||
loadSuggestions()
|
||||
} else {
|
||||
updateSelection(1);
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
isOpen = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen = false;
|
||||
updateSuggestions()
|
||||
selectedIndex = undefined
|
||||
}
|
||||
|
||||
function hasSuggestions() {
|
||||
return suggestions.total.length > 0
|
||||
}
|
||||
|
||||
async function loadSuggestions() {
|
||||
|
||||
let suggestionIndex = 0
|
||||
|
||||
function nextIndex() {
|
||||
return suggestionIndex++
|
||||
}
|
||||
|
||||
// Tag suggestions
|
||||
let tagSuggestions = []
|
||||
const currentWord = getCurrentWord(input)
|
||||
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
|
||||
const searchTag = currentWord.substring(1, currentWord.length)
|
||||
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
|
||||
.slice(0, 5)
|
||||
.map(tagName => ({
|
||||
type: 'tag',
|
||||
index: nextIndex(),
|
||||
label: `#${tagName}`,
|
||||
tagName: tagName
|
||||
}))
|
||||
}
|
||||
|
||||
// Recent search suggestions
|
||||
const search = searchHistory.getRecentSearches(value, 5).map(value => ({
|
||||
type: 'search',
|
||||
index: nextIndex(),
|
||||
label: value,
|
||||
value
|
||||
}))
|
||||
|
||||
// Bookmark suggestions
|
||||
let bookmarks = []
|
||||
|
||||
if (value && value.length >= 3) {
|
||||
const fetchedBookmarks = mode === 'archive'
|
||||
? await apiClient.getArchivedBookmarks(value, {limit: 5, offset: 0})
|
||||
: await apiClient.getBookmarks(value, {limit: 5, offset: 0})
|
||||
bookmarks = fetchedBookmarks.map(bookmark => {
|
||||
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
|
||||
const label = clampText(fullLabel, 60)
|
||||
return {
|
||||
type: 'bookmark',
|
||||
index: nextIndex(),
|
||||
label,
|
||||
bookmark
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
updateSuggestions(search, bookmarks, tagSuggestions)
|
||||
|
||||
if (hasSuggestions()) {
|
||||
open()
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedLoadSuggestions = debounce(loadSuggestions)
|
||||
|
||||
function updateSuggestions(search, bookmarks, tagSuggestions) {
|
||||
search = search || []
|
||||
bookmarks = bookmarks || []
|
||||
tagSuggestions = tagSuggestions || []
|
||||
suggestions = {
|
||||
search,
|
||||
bookmarks,
|
||||
tags: tagSuggestions,
|
||||
total: [
|
||||
...tagSuggestions,
|
||||
...search,
|
||||
...bookmarks,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function completeSuggestion(suggestion) {
|
||||
if (suggestion.type === 'search') {
|
||||
value = suggestion.value
|
||||
close()
|
||||
}
|
||||
if (suggestion.type === 'bookmark') {
|
||||
window.open(suggestion.bookmark.url, '_blank')
|
||||
close()
|
||||
}
|
||||
if (suggestion.type === 'tag') {
|
||||
const bounds = getCurrentWordBounds(input);
|
||||
const inputValue = input.value;
|
||||
input.value = inputValue.substring(0, bounds.start) + `#${suggestion.tagName} ` + inputValue.substring(bounds.end);
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelection(dir) {
|
||||
|
||||
const length = suggestions.total.length;
|
||||
|
||||
if (length === 0) return
|
||||
|
||||
if (selectedIndex === undefined) {
|
||||
selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0)
|
||||
return
|
||||
}
|
||||
|
||||
let newIndex = selectedIndex + dir;
|
||||
|
||||
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
|
||||
if (newIndex >= length) newIndex = 0;
|
||||
|
||||
selectedIndex = newIndex;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="form-autocomplete">
|
||||
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
|
||||
<input type="search" class="form-input" name="{name}" placeholder="{placeholder}" autocomplete="off" value="{value}"
|
||||
bind:this={input}
|
||||
on:input={handleInput} on:keydown={handleKeyDown} on:focus={handleFocus} on:blur={handleBlur}>
|
||||
</div>
|
||||
|
||||
<ul class="menu" class:open={isOpen}>
|
||||
{#if suggestions.tags.length > 0}
|
||||
<li class="menu-item group-item">Tags</li>
|
||||
{/if}
|
||||
{#each suggestions.tags as suggestion}
|
||||
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
||||
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
||||
<div class="tile tile-centered">
|
||||
<div class="tile-content">
|
||||
{suggestion.label}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
{#if suggestions.search.length > 0}
|
||||
<li class="menu-item group-item">Recent Searches</li>
|
||||
{/if}
|
||||
{#each suggestions.search as suggestion}
|
||||
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
||||
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
||||
<div class="tile tile-centered">
|
||||
<div class="tile-content">
|
||||
{suggestion.label}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
{#if suggestions.bookmarks.length > 0}
|
||||
<li class="menu-item group-item">Bookmarks</li>
|
||||
{/if}
|
||||
{#each suggestions.bookmarks as suggestion}
|
||||
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
||||
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
||||
<div class="tile tile-centered">
|
||||
<div class="tile-content">
|
||||
{suggestion.label}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.menu {
|
||||
display: none;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.menu.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-autocomplete-input {
|
||||
padding: 0;
|
||||
}
|
||||
.form-autocomplete-input.is-focused {
|
||||
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>
|
48
bookmarks/components/SearchHistory.js
Normal file
48
bookmarks/components/SearchHistory.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const SEARCH_HISTORY_KEY = 'searchHistory'
|
||||
const MAX_ENTRIES = 30
|
||||
|
||||
export class SearchHistory {
|
||||
|
||||
getHistory() {
|
||||
const historyJson = localStorage.getItem(SEARCH_HISTORY_KEY)
|
||||
return historyJson ? JSON.parse(historyJson) : {
|
||||
recent: []
|
||||
}
|
||||
}
|
||||
|
||||
pushCurrent() {
|
||||
// Skip if browser is not compatible
|
||||
if (!window.URLSearchParams) return
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const searchParam = urlParams.get('q');
|
||||
|
||||
if (!searchParam) return
|
||||
|
||||
this.push(searchParam)
|
||||
}
|
||||
|
||||
push(search) {
|
||||
const history = this.getHistory()
|
||||
|
||||
history.recent.unshift(search)
|
||||
|
||||
// Remove duplicates and clamp to max entries
|
||||
history.recent = history.recent.reduce((acc, cur) => {
|
||||
if (acc.length >= MAX_ENTRIES) return acc
|
||||
if (acc.indexOf(cur) >= 0) return acc
|
||||
acc.push(cur)
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const newHistoryJson = JSON.stringify(history)
|
||||
localStorage.setItem(SEARCH_HISTORY_KEY, newHistoryJson)
|
||||
}
|
||||
|
||||
getRecentSearches(query, max) {
|
||||
const history = this.getHistory()
|
||||
|
||||
return history.recent
|
||||
.filter(search => !query || search.toLowerCase().indexOf(query.toLowerCase()) >= 0)
|
||||
.slice(0, max)
|
||||
}
|
||||
}
|
@@ -1,4 +1,6 @@
|
||||
<script>
|
||||
import {getCurrentWord, getCurrentWordBounds} from "./util";
|
||||
|
||||
export let id;
|
||||
export let name;
|
||||
export let value;
|
||||
@@ -23,7 +25,7 @@
|
||||
function handleInput(e) {
|
||||
input = e.target;
|
||||
|
||||
const word = getCurrentWord();
|
||||
const word = getCurrentWord(input);
|
||||
|
||||
suggestions = word ? tags.filter(tag => tag.indexOf(word) === 0) : [];
|
||||
|
||||
@@ -73,27 +75,6 @@
|
||||
close();
|
||||
}
|
||||
|
||||
function getCurrentWordBounds() {
|
||||
const text = input.value;
|
||||
const end = input.selectionStart;
|
||||
let start = end;
|
||||
|
||||
let currentChar = text.charAt(start - 1);
|
||||
|
||||
while (currentChar && currentChar !== ' ' && start > 0) {
|
||||
start--;
|
||||
currentChar = text.charAt(start - 1);
|
||||
}
|
||||
|
||||
return {start, end};
|
||||
}
|
||||
|
||||
function getCurrentWord() {
|
||||
const bounds = getCurrentWordBounds(input);
|
||||
|
||||
return input.value.substring(bounds.start, bounds.end);
|
||||
}
|
||||
|
||||
function updateSelection(dir) {
|
||||
|
||||
const length = suggestions.length;
|
||||
|
23
bookmarks/components/api.js
Normal file
23
bookmarks/components/api.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export class ApiClient {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl
|
||||
}
|
||||
|
||||
getBookmarks(query, options = {limit: 100, offset: 0}) {
|
||||
const encodedQuery = encodeURIComponent(query)
|
||||
const url = `${this.baseUrl}bookmarks?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
|
||||
|
||||
return fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(data => data.results)
|
||||
}
|
||||
|
||||
getArchivedBookmarks(query, options = {limit: 100, offset: 0}) {
|
||||
const encodedQuery = encodeURIComponent(query)
|
||||
const url = `${this.baseUrl}bookmarks/archived?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
|
||||
|
||||
return fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(data => data.results)
|
||||
}
|
||||
}
|
@@ -1,6 +1,10 @@
|
||||
import TagAutoComplete from './TagAutocomplete.svelte'
|
||||
import SearchAutoComplete from './SearchAutoComplete.svelte'
|
||||
import {ApiClient} from './api'
|
||||
|
||||
export default {
|
||||
TagAutoComplete
|
||||
ApiClient,
|
||||
TagAutoComplete,
|
||||
SearchAutoComplete
|
||||
}
|
||||
|
||||
|
37
bookmarks/components/util.js
Normal file
37
bookmarks/components/util.js
Normal file
@@ -0,0 +1,37 @@
|
||||
export function debounce(callback, delay = 250) {
|
||||
let timeoutId
|
||||
return (...args) => {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = null
|
||||
callback(...args)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
export function clampText(text, maxChars = 30) {
|
||||
if(!text || text.length <= 30) return text
|
||||
|
||||
return text.substr(0, maxChars) + '...'
|
||||
}
|
||||
|
||||
export function getCurrentWordBounds(input) {
|
||||
const text = input.value;
|
||||
const end = input.selectionStart;
|
||||
let start = end;
|
||||
|
||||
let currentChar = text.charAt(start - 1);
|
||||
|
||||
while (currentChar && currentChar !== ' ' && start > 0) {
|
||||
start--;
|
||||
currentChar = text.charAt(start - 1);
|
||||
}
|
||||
|
||||
return {start, end};
|
||||
}
|
||||
|
||||
export function getCurrentWord(input) {
|
||||
const bounds = getCurrentWordBounds(input);
|
||||
|
||||
return input.value.substring(bounds.start, bounds.end);
|
||||
}
|
19
bookmarks/migrations/0005_auto_20210103_1212.py
Normal file
19
bookmarks/migrations/0005_auto_20210103_1212.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.2.13 on 2021-01-03 12:12
|
||||
|
||||
import bookmarks.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0004_auto_20200926_1028'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='bookmark',
|
||||
name='url',
|
||||
field=models.CharField(max_length=2048, validators=[bookmarks.validators.BookmarkURLValidator()]),
|
||||
),
|
||||
]
|
18
bookmarks/migrations/0006_bookmark_is_archived.py
Normal file
18
bookmarks/migrations/0006_bookmark_is_archived.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.13 on 2021-02-14 09:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0005_auto_20210103_1212'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bookmark',
|
||||
name='is_archived',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
@@ -4,6 +4,9 @@ from django import forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
|
||||
from bookmarks.utils import unique
|
||||
from bookmarks.validators import BookmarkURLValidator
|
||||
|
||||
|
||||
class Tag(models.Model):
|
||||
name = models.CharField(max_length=64)
|
||||
@@ -18,7 +21,8 @@ def parse_tag_string(tag_string: str, delimiter: str = ','):
|
||||
if not tag_string:
|
||||
return []
|
||||
names = tag_string.strip().split(delimiter)
|
||||
names = [name for name in names if name]
|
||||
names = [name.strip() for name in names if name]
|
||||
names = unique(names, str.lower)
|
||||
names.sort(key=str.lower)
|
||||
|
||||
return names
|
||||
@@ -29,12 +33,13 @@ def build_tag_string(tag_names: List[str], delimiter: str = ','):
|
||||
|
||||
|
||||
class Bookmark(models.Model):
|
||||
url = models.URLField(max_length=2048)
|
||||
url = models.CharField(max_length=2048, validators=[BookmarkURLValidator()])
|
||||
title = models.CharField(max_length=512, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
website_title = models.CharField(max_length=512, blank=True, null=True)
|
||||
website_description = models.TextField(blank=True, null=True)
|
||||
unread = models.BooleanField(default=True)
|
||||
is_archived = models.BooleanField(default=False)
|
||||
date_added = models.DateTimeField()
|
||||
date_modified = models.DateTimeField()
|
||||
date_accessed = models.DateTimeField(blank=True, null=True)
|
||||
@@ -48,7 +53,12 @@ class Bookmark(models.Model):
|
||||
|
||||
@property
|
||||
def resolved_title(self):
|
||||
return self.website_title if not self.title else self.title
|
||||
if self.title:
|
||||
return self.title
|
||||
elif self.website_title:
|
||||
return self.website_title
|
||||
else:
|
||||
return self.url
|
||||
|
||||
@property
|
||||
def resolved_description(self):
|
||||
@@ -68,7 +78,7 @@ class Bookmark(models.Model):
|
||||
|
||||
class BookmarkForm(forms.ModelForm):
|
||||
# Use URLField for URL
|
||||
url = forms.URLField()
|
||||
url = forms.CharField(validators=[BookmarkURLValidator()])
|
||||
tag_string = forms.CharField(required=False)
|
||||
# Do not require title and description in form as we fill these automatically if they are empty
|
||||
title = forms.CharField(max_length=512,
|
||||
|
@@ -1,7 +1,8 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q, Count, Aggregate, CharField, Value, BooleanField
|
||||
from django.db.models import Q, Count, Aggregate, CharField, Value, BooleanField, QuerySet
|
||||
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.utils import unique
|
||||
|
||||
|
||||
class Concat(Aggregate):
|
||||
@@ -16,7 +17,17 @@ class Concat(Aggregate):
|
||||
**extra)
|
||||
|
||||
|
||||
def query_bookmarks(user: User, query_string: str):
|
||||
def query_bookmarks(user: User, query_string: str) -> QuerySet:
|
||||
return _base_bookmarks_query(user, query_string) \
|
||||
.filter(is_archived=False)
|
||||
|
||||
|
||||
def query_archived_bookmarks(user: User, query_string: str) -> QuerySet:
|
||||
return _base_bookmarks_query(user, query_string) \
|
||||
.filter(is_archived=True)
|
||||
|
||||
|
||||
def _base_bookmarks_query(user: User, query_string: str) -> QuerySet:
|
||||
# Add aggregated tag info to bookmark instances
|
||||
query_set = Bookmark.objects \
|
||||
.annotate(tag_count=Count('tags'),
|
||||
@@ -41,7 +52,7 @@ def query_bookmarks(user: User, query_string: str):
|
||||
|
||||
for tag_name in query['tag_names']:
|
||||
query_set = query_set.filter(
|
||||
tags__name=tag_name
|
||||
tags__name__iexact=tag_name
|
||||
)
|
||||
|
||||
# Sort by modification date
|
||||
@@ -50,7 +61,19 @@ def query_bookmarks(user: User, query_string: str):
|
||||
return query_set
|
||||
|
||||
|
||||
def query_tags(user: User, query_string: str):
|
||||
def query_bookmark_tags(user: User, query_string: str) -> QuerySet:
|
||||
return _base_bookmark_tags_query(user, query_string) \
|
||||
.filter(bookmark__is_archived=False) \
|
||||
.distinct()
|
||||
|
||||
|
||||
def query_archived_bookmark_tags(user: User, query_string: str) -> QuerySet:
|
||||
return _base_bookmark_tags_query(user, query_string) \
|
||||
.filter(bookmark__is_archived=True) \
|
||||
.distinct()
|
||||
|
||||
|
||||
def _base_bookmark_tags_query(user: User, query_string: str) -> QuerySet:
|
||||
query_set = Tag.objects
|
||||
|
||||
# Filter for user
|
||||
@@ -74,7 +97,7 @@ def query_tags(user: User, query_string: str):
|
||||
|
||||
for tag_name in query['tag_names']:
|
||||
query_set = query_set.filter(
|
||||
bookmark__tags__name=tag_name
|
||||
bookmark__tags__name__iexact=tag_name
|
||||
)
|
||||
|
||||
return query_set.distinct()
|
||||
@@ -95,6 +118,7 @@ def _parse_query_string(query_string):
|
||||
|
||||
search_terms = [word for word in keywords if word[0] != '#']
|
||||
tag_names = [word[1:] for word in keywords if word[0] == '#']
|
||||
tag_names = unique(tag_names, str.lower)
|
||||
|
||||
return {
|
||||
'search_terms': search_terms,
|
||||
|
@@ -39,6 +39,20 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
||||
return bookmark
|
||||
|
||||
|
||||
def archive_bookmark(bookmark: Bookmark):
|
||||
bookmark.is_archived = True
|
||||
bookmark.date_modified = timezone.now()
|
||||
bookmark.save()
|
||||
return bookmark
|
||||
|
||||
|
||||
def unarchive_bookmark(bookmark: Bookmark):
|
||||
bookmark.is_archived = False
|
||||
bookmark.date_modified = timezone.now()
|
||||
bookmark.save()
|
||||
return bookmark
|
||||
|
||||
|
||||
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
||||
to_bookmark.title = from_bookmark.title
|
||||
to_bookmark.description = from_bookmark.description
|
||||
|
@@ -2,11 +2,10 @@ import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
import bs4
|
||||
from bs4 import BeautifulSoup
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from bookmarks.models import Bookmark, parse_tag_string
|
||||
from bookmarks.services.parser import parse, NetscapeBookmark
|
||||
from bookmarks.services.tags import get_or_create_tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,52 +22,41 @@ def import_netscape_html(html: str, user: User):
|
||||
result = ImportResult()
|
||||
|
||||
try:
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
netscape_bookmarks = parse(html)
|
||||
except:
|
||||
logging.exception('Could not read bookmarks file.')
|
||||
raise
|
||||
|
||||
bookmark_tags = soup.find_all('dt')
|
||||
|
||||
for bookmark_tag in bookmark_tags:
|
||||
for netscape_bookmark in netscape_bookmarks:
|
||||
result.total = result.total + 1
|
||||
try:
|
||||
_import_bookmark_tag(bookmark_tag, user)
|
||||
_import_bookmark_tag(netscape_bookmark, user)
|
||||
result.success = result.success + 1
|
||||
except:
|
||||
shortened_bookmark_tag_str = str(bookmark_tag)[:100] + '...'
|
||||
shortened_bookmark_tag_str = str(netscape_bookmark)[:100] + '...'
|
||||
logging.exception('Error importing bookmark: ' + shortened_bookmark_tag_str)
|
||||
result.failed = result.failed + 1
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _import_bookmark_tag(bookmark_tag: bs4.Tag, user: User):
|
||||
link_tag = bookmark_tag.a
|
||||
|
||||
if link_tag is None:
|
||||
return
|
||||
|
||||
def _import_bookmark_tag(netscape_bookmark: NetscapeBookmark, user: User):
|
||||
# Either modify existing bookmark for the URL or create new one
|
||||
url = link_tag['href']
|
||||
description = _extract_description(bookmark_tag)
|
||||
bookmark = _get_or_create_bookmark(url, user)
|
||||
bookmark = _get_or_create_bookmark(netscape_bookmark.href, user)
|
||||
|
||||
bookmark.url = url
|
||||
add_date = link_tag.get('add_date', datetime.now().timestamp())
|
||||
bookmark.date_added = datetime.utcfromtimestamp(int(add_date)).astimezone()
|
||||
bookmark.url = netscape_bookmark.href
|
||||
bookmark.date_added = datetime.utcfromtimestamp(int(netscape_bookmark.date_added)).astimezone()
|
||||
bookmark.date_modified = bookmark.date_added
|
||||
bookmark.unread = link_tag.get('toread', '0') == '1'
|
||||
bookmark.title = link_tag.string
|
||||
if description:
|
||||
bookmark.description = description
|
||||
bookmark.unread = False
|
||||
bookmark.title = netscape_bookmark.title
|
||||
if netscape_bookmark.description:
|
||||
bookmark.description = netscape_bookmark.description
|
||||
bookmark.owner = user
|
||||
|
||||
bookmark.save()
|
||||
|
||||
# Set tags
|
||||
tag_string = link_tag.get('tags', '')
|
||||
tag_names = parse_tag_string(tag_string)
|
||||
tag_names = parse_tag_string(netscape_bookmark.tag_string)
|
||||
tags = get_or_create_tags(tag_names, user)
|
||||
|
||||
bookmark.tags.set(tags)
|
||||
@@ -80,27 +68,3 @@ def _get_or_create_bookmark(url: str, user: User):
|
||||
return Bookmark.objects.get(url=url, owner=user)
|
||||
except Bookmark.DoesNotExist:
|
||||
return Bookmark()
|
||||
|
||||
|
||||
def _extract_description(bookmark_tag: bs4.Tag):
|
||||
"""
|
||||
Since the Netscape HTML format has no closing tags, all following bookmark tags are part of the description tag
|
||||
so to extract the description text we have to get creative. For now we combine the text of all text nodes until we
|
||||
detect a <dt> tag which indicates a new bookmark
|
||||
:param bookmark_tag:
|
||||
:return:
|
||||
"""
|
||||
description_tag = bookmark_tag.find('dd', recursive=False)
|
||||
|
||||
if description_tag is None:
|
||||
return None
|
||||
|
||||
description = ''
|
||||
|
||||
for content in description_tag.contents:
|
||||
if type(content) is bs4.element.Tag and content.name == 'dt':
|
||||
break
|
||||
if type(content) is bs4.element.NavigableString:
|
||||
description += content
|
||||
|
||||
return description.strip()
|
||||
|
73
bookmarks/services/parser.py
Normal file
73
bookmarks/services/parser.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
import pyparsing as pp
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetscapeBookmark:
|
||||
href: str
|
||||
title: str
|
||||
description: str
|
||||
date_added: int
|
||||
tag_string: str
|
||||
|
||||
|
||||
def extract_bookmark_link(tag):
|
||||
href = tag[0].href
|
||||
title = tag[0].text
|
||||
tag_string = tag[0].tags
|
||||
date_added_string = tag[0].add_date if tag[0].add_date else datetime.now().timestamp()
|
||||
date_added = int(date_added_string)
|
||||
|
||||
return {
|
||||
'href': href,
|
||||
'title': title,
|
||||
'tag_string': tag_string,
|
||||
'date_added': date_added
|
||||
}
|
||||
|
||||
|
||||
def extract_bookmark(tag):
|
||||
link = tag[0].link
|
||||
description = tag[0].description
|
||||
description = description[0] if description else ''
|
||||
|
||||
return {
|
||||
'link': link,
|
||||
'description': description,
|
||||
}
|
||||
|
||||
|
||||
def extract_description(tag):
|
||||
return tag[0].strip()
|
||||
|
||||
|
||||
# define grammar
|
||||
dt_start, _ = pp.makeHTMLTags("DT")
|
||||
dd_start, _ = pp.makeHTMLTags("DD")
|
||||
a_start, a_end = pp.makeHTMLTags("A")
|
||||
bookmark_link_tag = pp.Group(a_start + a_start.tag_body("text") + a_end.suppress())
|
||||
bookmark_link_tag.addParseAction(extract_bookmark_link)
|
||||
bookmark_description_tag = dd_start.suppress() + pp.SkipTo(pp.anyOpenTag | pp.anyCloseTag)("description")
|
||||
bookmark_description_tag.addParseAction(extract_description)
|
||||
bookmark_tag = pp.Group(dt_start + bookmark_link_tag("link") + pp.ZeroOrMore(bookmark_description_tag)("description"))
|
||||
bookmark_tag.addParseAction(extract_bookmark)
|
||||
|
||||
|
||||
def parse(html: str) -> [NetscapeBookmark]:
|
||||
matches = bookmark_tag.searchString(html)
|
||||
bookmarks = []
|
||||
|
||||
for match in matches:
|
||||
bookmark_match = match[0]
|
||||
bookmark = NetscapeBookmark(
|
||||
href=bookmark_match['link']['href'],
|
||||
title=bookmark_match['link']['title'],
|
||||
description=bookmark_match['description'],
|
||||
tag_string=bookmark_match['link']['tag_string'],
|
||||
date_added=bookmark_match['link']['date_added'],
|
||||
)
|
||||
bookmarks.append(bookmark)
|
||||
|
||||
return bookmarks
|
@@ -1,20 +1,37 @@
|
||||
import logging
|
||||
import operator
|
||||
from typing import List
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import Tag
|
||||
from bookmarks.utils import unique
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_or_create_tags(tag_names: List[str], user: User):
|
||||
return [get_or_create_tag(tag_name, user) for tag_name in tag_names]
|
||||
tags = [get_or_create_tag(tag_name, user) for tag_name in tag_names]
|
||||
return unique(tags, operator.attrgetter('id'))
|
||||
|
||||
|
||||
def get_or_create_tag(name: str, user: User):
|
||||
try:
|
||||
return Tag.objects.get(name=name, owner=user)
|
||||
return Tag.objects.get(name__iexact=name, owner=user)
|
||||
except Tag.DoesNotExist:
|
||||
tag = Tag(name=name, owner=user)
|
||||
tag.date_added = timezone.now()
|
||||
tag.save()
|
||||
return tag
|
||||
except Tag.MultipleObjectsReturned:
|
||||
# Legacy databases might contain duplicate tags with different capitalization
|
||||
first_tag = Tag.objects.filter(name__iexact=name, owner=user).first()
|
||||
message = (
|
||||
"Found multiple tags for the name '{0}' with different capitalization. "
|
||||
"Using the first tag with the name '{1}'. "
|
||||
"Since v.1.2 tags work case-insensitive, which means duplicates of the same name are not allowed anymore. "
|
||||
"To solve this error remove the duplicate tag in admin."
|
||||
).format(name, first_tag.name)
|
||||
logger.error(message)
|
||||
return first_tag
|
||||
|
BIN
bookmarks/static/favicon.png
Normal file
BIN
bookmarks/static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
@@ -48,3 +48,8 @@ h2 {
|
||||
.container > .columns > .column:not(:first-child) {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
// Remove left padding from first pagination link
|
||||
.pagination .page-item:first-child a {
|
||||
padding-left: 0;
|
||||
}
|
@@ -1,7 +1,32 @@
|
||||
.bookmarks-page {
|
||||
.bookmarks-page .search {
|
||||
$searchbox-height: 1.8rem;
|
||||
|
||||
.search input[type=search] {
|
||||
width: 200px;
|
||||
// Regular input
|
||||
input[type='search'] {
|
||||
width: 180px;
|
||||
height: $searchbox-height;
|
||||
-webkit-appearance: none;
|
||||
|
||||
@media (min-width: $control-width-md) {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced auto-complete input
|
||||
// This needs a bit more wrangling to make the CSS component align with the attached button
|
||||
.form-autocomplete {
|
||||
height: $searchbox-height;
|
||||
|
||||
.form-autocomplete-input {
|
||||
height: $searchbox-height;
|
||||
width: 100%;
|
||||
|
||||
input[type='search'] {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +56,17 @@ ul.bookmark-list {
|
||||
color: darken($gray-color, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
.actions .btn-link.bm-remove-confirm {
|
||||
color: $error-color;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-pagination {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tag-cloud {
|
||||
|
@@ -4,7 +4,6 @@ section.content-area {
|
||||
border-bottom: solid 1px $border-color;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
|
34
bookmarks/templates/bookmarks/archive.html
Normal file
34
bookmarks/templates/bookmarks/archive.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
{% load static %}
|
||||
{% load shared %}
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bookmarks-page columns">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area column col-8 col-md-12">
|
||||
<div class="content-area-header">
|
||||
<h2>Archived bookmarks</h2>
|
||||
<div class="spacer"></div>
|
||||
{% bookmark_search query tags mode='archive' %}
|
||||
</div>
|
||||
|
||||
{% if empty %}
|
||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||
{% else %}
|
||||
{% bookmark_list bookmarks return_url %}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# Tag list #}
|
||||
<section class="content-area column col-4 hide-md">
|
||||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
{% tag_cloud tags %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="{% static "bundle.js" %}"></script>
|
||||
{% endblock %}
|
@@ -1,4 +1,5 @@
|
||||
{% load shared %}
|
||||
{% load pagination %}
|
||||
|
||||
<ul class="bookmark-list">
|
||||
{% for bookmark in bookmarks %}
|
||||
@@ -23,20 +24,55 @@
|
||||
<div class="actions">
|
||||
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}"
|
||||
class="btn btn-link btn-sm">Edit</a>
|
||||
{% if bookmark.is_archived %}
|
||||
<a href="{% url 'bookmarks:unarchive' bookmark.id %}?return_url={{ return_url }}"
|
||||
class="btn btn-link btn-sm">Unarchive</a>
|
||||
{% else %}
|
||||
<a href="{% url 'bookmarks:archive' bookmark.id %}?return_url={{ return_url }}"
|
||||
class="btn btn-link btn-sm">Archive</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'bookmarks:remove' bookmark.id %}?return_url={{ return_url }}"
|
||||
class="btn btn-link btn-sm"
|
||||
onclick="return confirm('Do you really want to delete this bookmark?')">Remove</a>
|
||||
class="btn btn-link btn-sm bm-remove">Remove</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="pagination">
|
||||
{% if bookmarks.has_next %}
|
||||
<a href="?{% update_query_string page=bookmarks.next_page_number %}"
|
||||
class="btn mr-2">< Older</a>
|
||||
{% endif %}
|
||||
{% if bookmarks.has_previous %}
|
||||
<a href="?{% update_query_string page=bookmarks.previous_page_number %}"
|
||||
class="btn">Newer ></a>
|
||||
{% endif %}
|
||||
|
||||
<div class="bookmark-pagination">
|
||||
{% pagination bookmarks %}
|
||||
</div>
|
||||
|
||||
{# Enhance delete links to show inline confirmation #}
|
||||
<script type="application/javascript">
|
||||
window.addEventListener("load", function () {
|
||||
const linkEls = document.querySelectorAll('.bookmark-list a.bm-remove');
|
||||
|
||||
function showConfirmation(linkEl) {
|
||||
const cancelEl = document.createElement('span');
|
||||
cancelEl.innerText = 'Cancel';
|
||||
cancelEl.className = 'btn btn-link btn-sm bm-remove-confirm mr-1';
|
||||
cancelEl.addEventListener('click', function() {
|
||||
container.remove();
|
||||
linkEl.style = '';
|
||||
});
|
||||
|
||||
const confirmEl = document.createElement('a');
|
||||
confirmEl.innerText = 'Confirm';
|
||||
confirmEl.className = 'btn btn-link btn-delete btn-sm bm-remove-confirm';
|
||||
confirmEl.href = linkEl.href;
|
||||
|
||||
const container = document.createElement('span');
|
||||
container.appendChild(cancelEl);
|
||||
container.appendChild(confirmEl);
|
||||
linkEl.parentElement.appendChild(container);
|
||||
linkEl.style = 'display: none';
|
||||
}
|
||||
|
||||
linkEls.forEach(function (linkEl) {
|
||||
linkEl.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
showConfirmation(linkEl);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
@@ -1,24 +0,0 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="columns">
|
||||
<section class="content-area column col-12">
|
||||
<div class="content-area-header">
|
||||
<h2>Bookmarklet</h2>
|
||||
</div>
|
||||
<p>The bookmarklet is a quick way to add new bookmarks without opening the linkding application
|
||||
first. Here's how it works:</p>
|
||||
<ul>
|
||||
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
|
||||
<li>Open the website that you want to bookmark</li>
|
||||
<li>Click the bookmarklet in your browsers toolbar</li>
|
||||
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
|
||||
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
|
||||
</ul>
|
||||
<p>Drag the following bookmarklet to your browsers toolbar:</p>
|
||||
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
|
||||
class="btn btn-primary">📎 Add bookmark</a>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@@ -1,8 +1,8 @@
|
||||
<div class="empty">
|
||||
<p class="empty-title h5">You have no bookmarks yet</p>
|
||||
<p class="empty-subtitle">
|
||||
You can get started by <a href="{% url 'bookmarks:new' %}">adding</a> bookmarks, <a
|
||||
href="{% url 'bookmarks:settings.index' %}">importing</a> your existing bookmarks or <a
|
||||
href="{% url 'bookmarks:bookmarklet' %}">configuring</a> the bookmarklet.
|
||||
You can get started by <a href="{% url 'bookmarks:new' %}">adding</a> bookmarks,
|
||||
<a href="{% url 'bookmarks:settings.index' %}">importing</a> your existing bookmarks or configuring the
|
||||
<a href="{% url 'bookmarks:settings.index' %}#bookmarklet">bookmarklet</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
@@ -97,7 +97,7 @@
|
||||
toggleIcon(descriptionInput, true);
|
||||
|
||||
const websiteUrl = encodeURIComponent(urlInput.value);
|
||||
const requestUrl = `{% url 'bookmarks:api.check_url' %}?url=${websiteUrl}`;
|
||||
const requestUrl = `{% url 'bookmarks:api-root' %}bookmarks/check?url=${websiteUrl}`;
|
||||
fetch(requestUrl)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
|
@@ -1,4 +1,5 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
{% load static %}
|
||||
{% load shared %}
|
||||
{% load bookmarks %}
|
||||
|
||||
@@ -10,14 +11,7 @@
|
||||
<div class="content-area-header">
|
||||
<h2>Bookmarks</h2>
|
||||
<div class="spacer"></div>
|
||||
<div class="search">
|
||||
<form action="{% url 'bookmarks:index' %}" method="get">
|
||||
<div class="input-group">
|
||||
<input type="search" name="q" placeholder="Search for words or #tags" value="{{ query }}">
|
||||
<input type="submit" value="Search" class="btn input-group-btn">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% bookmark_search query tags %}
|
||||
</div>
|
||||
|
||||
{% if empty %}
|
||||
@@ -36,4 +30,5 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="{% static "bundle.js" %}"></script>
|
||||
{% endblock %}
|
||||
|
@@ -5,6 +5,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="{% static 'favicon.png' %}"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
|
||||
<meta name="description" content="Self-hosted bookmark service">
|
||||
<meta name="robots" content="index,follow">
|
||||
@@ -22,39 +23,10 @@
|
||||
<h1>linkding</h1>
|
||||
</a>
|
||||
</section>
|
||||
{# Only nav items menu when logged in #}
|
||||
{# Only show nav items menu when logged in #}
|
||||
{% if request.user.is_authenticated %}
|
||||
<section class="navbar-section">
|
||||
{# Basic menu list #}
|
||||
<div class="hide-md">
|
||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
|
||||
<a href="{% url 'bookmarks:bookmarklet' %}" class="btn btn-link">Bookmarklet</a>
|
||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
||||
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
|
||||
</div>
|
||||
{# Menu drop-down for smaller devices #}
|
||||
<div class="show-md">
|
||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">
|
||||
<i class="icon icon-plus"></i>
|
||||
</a>
|
||||
<div class="dropdown dropdown-right">
|
||||
<a href="#" class="btn btn-link dropdown-toggle" tabindex="0">
|
||||
<i class="icon icon-menu icon-2x"></i>
|
||||
</a>
|
||||
<!-- menu component -->
|
||||
<ul class="menu">
|
||||
<li>
|
||||
<a href="{% url 'bookmarks:bookmarklet' %}" class="btn btn-link">Bookmarklet</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'bookmarks/nav_menu.html' %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
49
bookmarks/templates/bookmarks/nav_menu.html
Normal file
49
bookmarks/templates/bookmarks/nav_menu.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{# Basic menu list #}
|
||||
<div class="hide-md">
|
||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
|
||||
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archive</a>
|
||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
||||
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
|
||||
</div>
|
||||
{# Menu drop-down for smaller devices #}
|
||||
<div class="show-md">
|
||||
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">
|
||||
<i class="icon icon-plus"></i>
|
||||
</a>
|
||||
<div class="dropdown dropdown-right">
|
||||
<a href="#" id="mobile-nav-menu-trigger" class="btn btn-link dropdown-toggle" tabindex="0">
|
||||
<i class="icon icon-menu icon-2x"></i>
|
||||
</a>
|
||||
<!-- menu component -->
|
||||
<ul class="menu">
|
||||
<li>
|
||||
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archive</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Hide mobile menu on outside click
|
||||
// The Spectre CSS component relies on focus changes to show/hide the dropdown, however mobile browsers like
|
||||
// Safari will not trigger a blur event when clicking on a non-focusable element, so we have to simulate the
|
||||
// behaviour through Javascript
|
||||
const mobileNavMenuTrigger = document.getElementById('mobile-nav-menu-trigger');
|
||||
|
||||
function mobileNavMenuOutsideClickHandler(clickEvent) {
|
||||
if (mobileNavMenuTrigger.parentElement.contains(clickEvent.target)) return
|
||||
mobileNavMenuTrigger.blur();
|
||||
}
|
||||
|
||||
mobileNavMenuTrigger.addEventListener('focus', function () {
|
||||
document.addEventListener('click', mobileNavMenuOutsideClickHandler);
|
||||
})
|
||||
mobileNavMenuTrigger.addEventListener('blur', function () {
|
||||
document.removeEventListener('click', mobileNavMenuOutsideClickHandler);
|
||||
})
|
||||
</script>
|
35
bookmarks/templates/bookmarks/pagination.html
Normal file
35
bookmarks/templates/bookmarks/pagination.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{% load shared %}
|
||||
|
||||
<ul class="pagination">
|
||||
{% if page.has_previous %}
|
||||
<li class="page-item">
|
||||
<a href="?{% update_query_string page=page.previous_page_number %}" tabindex="-1">Previous</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<a href="#" tabindex="-1">Previous</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for page_number in visible_page_numbers %}
|
||||
{% if page_number >= 0 %}
|
||||
<li class="page-item {% if page.number == page_number %}active{% endif %}">
|
||||
<a href="?{% update_query_string page=page_number %}">{{ page_number }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item">
|
||||
<span>...</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page.has_next %}
|
||||
<li class="page-item">
|
||||
<a href="?{% update_query_string page=page.next_page_number %}" tabindex="-1">Next</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<a href="#" tabindex="-1">Next</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
34
bookmarks/templates/bookmarks/search.html
Normal file
34
bookmarks/templates/bookmarks/search.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<div class="search">
|
||||
<form action="" method="get" role="search">
|
||||
<div class="input-group">
|
||||
<span id="search-input-wrap">
|
||||
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
|
||||
value="{{ query }}">
|
||||
</span>
|
||||
<input type="submit" value="Search" class="btn input-group-btn">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Replace search input with auto-complete component #}
|
||||
<script type="application/javascript">
|
||||
window.addEventListener("load", function() {
|
||||
const currentTagsString = '{{ tags_string }}';
|
||||
const currentTags = currentTagsString.split(' ');
|
||||
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
|
||||
const wrapper = document.getElementById('search-input-wrap')
|
||||
const newWrapper = document.createElement('div')
|
||||
new linkding.SearchAutoComplete({
|
||||
target: newWrapper,
|
||||
props: {
|
||||
name: 'q',
|
||||
placeholder: 'Search for words or #tags',
|
||||
value: '{{ query }}',
|
||||
tags: currentTags,
|
||||
mode: '{{ mode }}',
|
||||
apiClient
|
||||
}
|
||||
})
|
||||
wrapper.parentElement.replaceChild(newWrapper, wrapper)
|
||||
});
|
||||
</script>
|
@@ -51,6 +51,25 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# Integrations section #}
|
||||
<section class="content-area">
|
||||
<div class="content-area-header">
|
||||
<a id="bookmarklet"><h2>Bookmarklet</h2></a>
|
||||
</div>
|
||||
<p>The bookmarklet is a quick way to add new bookmarks without opening the linkding application
|
||||
first. Here's how it works:</p>
|
||||
<ul>
|
||||
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
|
||||
<li>Open the website that you want to bookmark</li>
|
||||
<li>Click the bookmarklet in your browsers toolbar</li>
|
||||
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
|
||||
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
|
||||
</ul>
|
||||
<p>Drag the following bookmarklet to your browsers toolbar:</p>
|
||||
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
|
||||
class="btn btn-primary">📎 Add bookmark</a>
|
||||
</section>
|
||||
|
||||
{# API token section #}
|
||||
<section class="content-area">
|
||||
<div class="content-area-header">
|
||||
|
@@ -61,3 +61,14 @@ def bookmark_list(context, bookmarks: Page, return_url: str):
|
||||
'bookmarks': bookmarks,
|
||||
'return_url': return_url
|
||||
}
|
||||
|
||||
|
||||
@register.inclusion_tag('bookmarks/search.html', name='bookmark_search', takes_context=True)
|
||||
def bookmark_search(context, query: str, tags: [Tag], mode: str = 'default'):
|
||||
tag_names = [tag.name for tag in tags]
|
||||
tags_string = build_tag_string(tag_names, ' ')
|
||||
return {
|
||||
'query': query,
|
||||
'tags_string': tags_string,
|
||||
'mode': mode,
|
||||
}
|
||||
|
55
bookmarks/templatetags/pagination.py
Normal file
55
bookmarks/templatetags/pagination.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from functools import reduce
|
||||
|
||||
from django import template
|
||||
from django.core.paginator import Page
|
||||
|
||||
NUM_ADJACENT_PAGES = 2
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.inclusion_tag('bookmarks/pagination.html', name='pagination', takes_context=True)
|
||||
def pagination(context, page: Page):
|
||||
visible_page_numbers = get_visible_page_numbers(page.number, page.paginator.num_pages)
|
||||
|
||||
return {
|
||||
'page': page,
|
||||
'visible_page_numbers': visible_page_numbers
|
||||
}
|
||||
|
||||
|
||||
def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
|
||||
"""
|
||||
Generates a list of page indexes that should be rendered
|
||||
The list can contain "holes" which indicate that a range of pages are truncated
|
||||
Holes are indicated with a value of `-1`
|
||||
:param current_page_number:
|
||||
:param num_pages:
|
||||
"""
|
||||
visible_pages = set()
|
||||
|
||||
# Add adjacent pages around current page
|
||||
visible_pages |= set(range(
|
||||
max(1, current_page_number - NUM_ADJACENT_PAGES),
|
||||
min(num_pages, current_page_number + NUM_ADJACENT_PAGES) + 1
|
||||
))
|
||||
|
||||
# Add first page
|
||||
visible_pages.add(1)
|
||||
|
||||
# Add last page
|
||||
visible_pages.add(num_pages)
|
||||
|
||||
# Convert to sorted list
|
||||
visible_pages = list(visible_pages)
|
||||
visible_pages.sort()
|
||||
|
||||
def append_page(result: [int], page_number: int):
|
||||
# Look for holes and insert a -1 as indicator
|
||||
is_hole = len(result) > 0 and result[-1] < page_number - 1
|
||||
if is_hole:
|
||||
result.append(-1)
|
||||
result.append(page_number)
|
||||
return result
|
||||
|
||||
return reduce(append_page, visible_pages, [])
|
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
0
bookmarks/tests/__init__.py
Normal file
0
bookmarks/tests/__init__.py
Normal file
90
bookmarks/tests/test_bookmark_validation.py
Normal file
90
bookmarks/tests/test_bookmark_validation.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import datetime
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from bookmarks.models import BookmarkForm, Bookmark
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
ENABLED_URL_VALIDATION_TEST_CASES = [
|
||||
('thisisnotavalidurl', False),
|
||||
('http://domain', False),
|
||||
('unknownscheme://domain.com', False),
|
||||
('http://domain.com', True),
|
||||
('http://www.domain.com', True),
|
||||
('https://domain.com', True),
|
||||
('https://www.domain.com', True),
|
||||
]
|
||||
|
||||
DISABLED_URL_VALIDATION_TEST_CASES = [
|
||||
('thisisnotavalidurl', True),
|
||||
('http://domain', True),
|
||||
('unknownscheme://domain.com', True),
|
||||
('http://domain.com', True),
|
||||
('http://www.domain.com', True),
|
||||
('https://domain.com', True),
|
||||
('https://www.domain.com', True),
|
||||
]
|
||||
|
||||
|
||||
class BookmarkValidationTestCase(TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
|
||||
|
||||
@override_settings(LD_DISABLE_URL_VALIDATION=False)
|
||||
def test_bookmark_model_should_validate_url_if_not_disabled_in_settings(self):
|
||||
self._run_bookmark_model_url_validity_checks(ENABLED_URL_VALIDATION_TEST_CASES)
|
||||
|
||||
@override_settings(LD_DISABLE_URL_VALIDATION=True)
|
||||
def test_bookmark_model_should_not_validate_url_if_disabled_in_settings(self):
|
||||
self._run_bookmark_model_url_validity_checks(DISABLED_URL_VALIDATION_TEST_CASES)
|
||||
|
||||
def test_bookmark_form_should_validate_required_fields(self):
|
||||
form = BookmarkForm(data={'url': ''})
|
||||
|
||||
self.assertEqual(len(form.errors), 1)
|
||||
self.assertIn('required', str(form.errors))
|
||||
|
||||
form = BookmarkForm(data={'url': None})
|
||||
|
||||
self.assertEqual(len(form.errors), 1)
|
||||
self.assertIn('required', str(form.errors))
|
||||
|
||||
@override_settings(LD_DISABLE_URL_VALIDATION=False)
|
||||
def test_bookmark_form_should_validate_url_if_not_disabled_in_settings(self):
|
||||
self._run_bookmark_form_url_validity_checks(ENABLED_URL_VALIDATION_TEST_CASES)
|
||||
|
||||
@override_settings(LD_DISABLE_URL_VALIDATION=True)
|
||||
def test_bookmark_form_should_not_validate_url_if_disabled_in_settings(self):
|
||||
self._run_bookmark_form_url_validity_checks(DISABLED_URL_VALIDATION_TEST_CASES)
|
||||
|
||||
def _run_bookmark_model_url_validity_checks(self, cases):
|
||||
for case in cases:
|
||||
url, expectation = case
|
||||
bookmark = Bookmark(
|
||||
url=url,
|
||||
date_added=datetime.datetime.now(),
|
||||
date_modified=datetime.datetime.now(),
|
||||
owner=self.user
|
||||
)
|
||||
|
||||
try:
|
||||
bookmark.full_clean()
|
||||
self.assertTrue(expectation, 'Did not expect validation error')
|
||||
except ValidationError as e:
|
||||
self.assertFalse(expectation, 'Expected validation error')
|
||||
self.assertTrue('url' in e.message_dict, 'Expected URL validation to fail')
|
||||
|
||||
def _run_bookmark_form_url_validity_checks(self, cases):
|
||||
for case in cases:
|
||||
url, expectation = case
|
||||
form = BookmarkForm(data={'url': url})
|
||||
|
||||
if expectation:
|
||||
self.assertEqual(len(form.errors), 0)
|
||||
else:
|
||||
self.assertEqual(len(form.errors), 1)
|
||||
self.assertIn('Enter a valid URL', str(form.errors))
|
16
bookmarks/tests/test_bookmarks_model.py
Normal file
16
bookmarks/tests/test_bookmarks_model.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from bookmarks.models import Bookmark
|
||||
|
||||
|
||||
class BookmarkTestCase(TestCase):
|
||||
|
||||
def test_bookmark_resolved_title(self):
|
||||
bookmark = Bookmark(title='Custom title', website_title='Website title', url='https://example.com')
|
||||
self.assertEqual(bookmark.resolved_title, 'Custom title')
|
||||
|
||||
bookmark = Bookmark(title='', website_title='Website title', url='https://example.com')
|
||||
self.assertEqual(bookmark.resolved_title, 'Website title')
|
||||
|
||||
bookmark = Bookmark(title='', website_title='', url='https://example.com')
|
||||
self.assertEqual(bookmark.resolved_title, 'https://example.com')
|
47
bookmarks/tests/test_bookmarks_service.py
Normal file
47
bookmarks/tests/test_bookmarks_service.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import Bookmark
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class BookmarkServiceTestCase(TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
|
||||
|
||||
def test_archive(self):
|
||||
bookmark = Bookmark(
|
||||
url='https://example.com',
|
||||
date_added=timezone.now(),
|
||||
date_modified=timezone.now(),
|
||||
owner=self.user
|
||||
)
|
||||
bookmark.save()
|
||||
|
||||
self.assertFalse(bookmark.is_archived)
|
||||
|
||||
archive_bookmark(bookmark)
|
||||
|
||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||
|
||||
self.assertTrue(updated_bookmark.is_archived)
|
||||
|
||||
def test_unarchive(self):
|
||||
bookmark = Bookmark(
|
||||
url='https://example.com',
|
||||
date_added=timezone.now(),
|
||||
date_modified=timezone.now(),
|
||||
owner=self.user,
|
||||
is_archived=True,
|
||||
)
|
||||
bookmark.save()
|
||||
|
||||
unarchive_bookmark(bookmark)
|
||||
|
||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||
|
||||
self.assertFalse(updated_bookmark.is_archived)
|
117
bookmarks/tests/test_pagination_tag.py
Normal file
117
bookmarks/tests/test_pagination_tag.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from django.core.paginator import Paginator
|
||||
from django.test import SimpleTestCase, RequestFactory
|
||||
from django.template import Template, RequestContext
|
||||
|
||||
|
||||
class PaginationTagTest(SimpleTestCase):
|
||||
|
||||
def render_template(self, num_items: int, page_size: int, current_page: int, url: str = '/test') -> str:
|
||||
rf = RequestFactory()
|
||||
request = rf.get(url)
|
||||
paginator = Paginator(range(0, num_items), page_size)
|
||||
page = paginator.page(current_page)
|
||||
|
||||
context = RequestContext(request, {'page': page})
|
||||
template_to_render = Template(
|
||||
'{% load pagination %}'
|
||||
'{% pagination page %}'
|
||||
)
|
||||
return template_to_render.render(context)
|
||||
|
||||
def assertPrevLinkDisabled(self, html: str):
|
||||
self.assertInHTML('''
|
||||
<li class="page-item disabled">
|
||||
<a href="#" tabindex="-1">Previous</a>
|
||||
</li>
|
||||
''', html)
|
||||
|
||||
def assertPrevLink(self, html: str, page_number: int, href: str = None):
|
||||
href = href if href else '?page={0}'.format(page_number)
|
||||
self.assertInHTML('''
|
||||
<li class="page-item">
|
||||
<a href="{0}" tabindex="-1">Previous</a>
|
||||
</li>
|
||||
'''.format(href), html)
|
||||
|
||||
def assertNextLinkDisabled(self, html: str):
|
||||
self.assertInHTML('''
|
||||
<li class="page-item disabled">
|
||||
<a href="#" tabindex="-1">Next</a>
|
||||
</li>
|
||||
''', html)
|
||||
|
||||
def assertNextLink(self, html: str, page_number: int, href: str = None):
|
||||
href = href if href else '?page={0}'.format(page_number)
|
||||
self.assertInHTML('''
|
||||
<li class="page-item">
|
||||
<a href="{0}" tabindex="-1">Next</a>
|
||||
</li>
|
||||
'''.format(href), html)
|
||||
|
||||
def assertPageLink(self, html: str, page_number: int, active: bool, count: int = 1, href: str = None):
|
||||
active_class = 'active' if active else ''
|
||||
href = href if href else '?page={0}'.format(page_number)
|
||||
self.assertInHTML('''
|
||||
<li class="page-item {1}">
|
||||
<a href="{2}">{0}</a>
|
||||
</li>
|
||||
'''.format(page_number, active_class, href), html, count=count)
|
||||
|
||||
def assertTruncationIndicators(self, html: str, count: int):
|
||||
self.assertInHTML('''
|
||||
<li class="page-item">
|
||||
<span>...</span>
|
||||
</li>
|
||||
''', html, count=count)
|
||||
|
||||
def test_previous_disabled_on_page_1(self):
|
||||
rendered_template = self.render_template(100, 10, 1)
|
||||
self.assertPrevLinkDisabled(rendered_template)
|
||||
|
||||
def test_previous_enabled_after_page_1(self):
|
||||
for page_number in range(2, 10):
|
||||
rendered_template = self.render_template(100, 10, page_number)
|
||||
self.assertPrevLink(rendered_template, page_number - 1)
|
||||
|
||||
def test_next_disabled_on_last_page(self):
|
||||
rendered_template = self.render_template(100, 10, 10)
|
||||
self.assertNextLinkDisabled(rendered_template)
|
||||
|
||||
def test_next_enabled_before_last_page(self):
|
||||
for page_number in range(1, 9):
|
||||
rendered_template = self.render_template(100, 10, page_number)
|
||||
self.assertNextLink(rendered_template, page_number + 1)
|
||||
|
||||
def test_truncate_pages_start(self):
|
||||
current_page = 1
|
||||
expected_visible_pages = [1, 2, 3, 10]
|
||||
rendered_template = self.render_template(100, 10, current_page)
|
||||
for page_number in range(1, 10):
|
||||
expected_occurrences = 1 if page_number in expected_visible_pages else 0
|
||||
self.assertPageLink(rendered_template, page_number, page_number == current_page, expected_occurrences)
|
||||
self.assertTruncationIndicators(rendered_template, 1)
|
||||
|
||||
def test_truncate_pages_middle(self):
|
||||
current_page = 5
|
||||
expected_visible_pages = [1, 3, 4, 5, 6, 7, 10]
|
||||
rendered_template = self.render_template(100, 10, current_page)
|
||||
for page_number in range(1, 10):
|
||||
expected_occurrences = 1 if page_number in expected_visible_pages else 0
|
||||
self.assertPageLink(rendered_template, page_number, page_number == current_page, expected_occurrences)
|
||||
self.assertTruncationIndicators(rendered_template, 2)
|
||||
|
||||
def test_truncate_pages_near_end(self):
|
||||
current_page = 9
|
||||
expected_visible_pages = [1, 7, 8, 9, 10]
|
||||
rendered_template = self.render_template(100, 10, current_page)
|
||||
for page_number in range(1, 10):
|
||||
expected_occurrences = 1 if page_number in expected_visible_pages else 0
|
||||
self.assertPageLink(rendered_template, page_number, page_number == current_page, expected_occurrences)
|
||||
self.assertTruncationIndicators(rendered_template, 1)
|
||||
|
||||
def test_extend_existing_query(self):
|
||||
rendered_template = self.render_template(100, 10, 2, url='/test?q=cake')
|
||||
self.assertPrevLink(rendered_template, 1, href='?q=cake&page=1')
|
||||
self.assertPageLink(rendered_template, 1, False, href='?q=cake&page=1')
|
||||
self.assertPageLink(rendered_template, 2, True, href='?q=cake&page=2')
|
||||
self.assertNextLink(rendered_template, 3, href='?q=cake&page=3')
|
99
bookmarks/tests/test_queries.py
Normal file
99
bookmarks/tests/test_queries.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks import queries
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class QueriesTestCase(TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
|
||||
|
||||
def setup_bookmark(self, is_archived: bool = False, tags: [Tag] = []):
|
||||
unique_id = get_random_string(length=32)
|
||||
bookmark = Bookmark(
|
||||
url='https://example.com/' + unique_id,
|
||||
date_added=timezone.now(),
|
||||
date_modified=timezone.now(),
|
||||
owner=self.user,
|
||||
is_archived=is_archived
|
||||
)
|
||||
bookmark.save()
|
||||
for tag in tags:
|
||||
bookmark.tags.add(tag)
|
||||
bookmark.save()
|
||||
return bookmark
|
||||
|
||||
def setup_tag(self):
|
||||
name = get_random_string(length=32)
|
||||
tag = Tag(name=name, date_added=timezone.now(), owner=self.user)
|
||||
tag.save()
|
||||
return tag
|
||||
|
||||
def test_query_bookmarks_should_not_return_archived_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
self.setup_bookmark(is_archived=True)
|
||||
self.setup_bookmark(is_archived=True)
|
||||
self.setup_bookmark(is_archived=True)
|
||||
|
||||
query = queries.query_bookmarks(self.user, '')
|
||||
|
||||
self.assertCountEqual([bookmark1, bookmark2], list(query))
|
||||
|
||||
def test_query_archived_bookmarks_should_not_return_unarchived_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark(is_archived=True)
|
||||
bookmark2 = self.setup_bookmark(is_archived=True)
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
|
||||
query = queries.query_archived_bookmarks(self.user, '')
|
||||
|
||||
self.assertCountEqual([bookmark1, bookmark2], list(query))
|
||||
|
||||
def test_query_bookmark_tags_should_return_tags_for_unarchived_bookmarks_only(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
self.setup_bookmark(tags=[tag1])
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark(is_archived=True, tags=[tag2])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '')
|
||||
|
||||
self.assertCountEqual([tag1], list(query))
|
||||
|
||||
def test_query_bookmark_tags_should_return_distinct_tags(self):
|
||||
tag = self.setup_tag()
|
||||
self.setup_bookmark(tags=[tag])
|
||||
self.setup_bookmark(tags=[tag])
|
||||
self.setup_bookmark(tags=[tag])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '')
|
||||
|
||||
self.assertCountEqual([tag], list(query))
|
||||
|
||||
def test_query_archived_bookmark_tags_should_return_tags_for_archived_bookmarks_only(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
self.setup_bookmark(tags=[tag1])
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark(is_archived=True, tags=[tag2])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.user, '')
|
||||
|
||||
self.assertCountEqual([tag2], list(query))
|
||||
|
||||
def test_query_archived_bookmark_tags_should_return_distinct_tags(self):
|
||||
tag = self.setup_tag()
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.user, '')
|
||||
|
||||
self.assertCountEqual([tag], list(query))
|
27
bookmarks/tests/test_tags_model.py
Normal file
27
bookmarks/tests/test_tags_model.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from bookmarks.models import parse_tag_string
|
||||
|
||||
|
||||
class TagTestCase(TestCase):
|
||||
|
||||
def test_parse_tag_string_returns_list_of_tag_names(self):
|
||||
self.assertCountEqual(parse_tag_string('book, movie, album'), ['book', 'movie', 'album'])
|
||||
|
||||
def test_parse_tag_string_respects_separator(self):
|
||||
self.assertCountEqual(parse_tag_string('book movie album', ' '), ['book', 'movie', 'album'])
|
||||
|
||||
def test_parse_tag_string_orders_tag_names_alphabetically(self):
|
||||
self.assertListEqual(parse_tag_string('book,movie,album'), ['album', 'book', 'movie'])
|
||||
self.assertListEqual(parse_tag_string('Book,movie,album'), ['album', 'Book', 'movie'])
|
||||
|
||||
def test_parse_tag_string_handles_whitespace(self):
|
||||
self.assertCountEqual(parse_tag_string('\t book, movie \t, album, \n\r'), ['album', 'book', 'movie'])
|
||||
|
||||
def test_parse_tag_string_handles_invalid_input(self):
|
||||
self.assertListEqual(parse_tag_string(None), [])
|
||||
self.assertListEqual(parse_tag_string(''), [])
|
||||
|
||||
def test_parse_tag_string_deduplicates_tag_names(self):
|
||||
self.assertEqual(len(parse_tag_string('book,book,Book,BOOK')), 1)
|
||||
|
67
bookmarks/tests/test_tags_service.py
Normal file
67
bookmarks/tests/test_tags_service.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import datetime
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from bookmarks.models import Tag
|
||||
from bookmarks.services.tags import get_or_create_tag, get_or_create_tags
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class TagServiceTestCase(TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
|
||||
|
||||
def test_get_or_create_tag_should_create_new_tag(self):
|
||||
get_or_create_tag('Book', self.user)
|
||||
|
||||
tags = Tag.objects.all()
|
||||
|
||||
self.assertEqual(len(tags), 1)
|
||||
self.assertEqual(tags[0].name, 'Book')
|
||||
self.assertEqual(tags[0].owner, self.user)
|
||||
self.assertTrue(abs(tags[0].date_added - timezone.now()) < datetime.timedelta(seconds=10))
|
||||
|
||||
def test_get_or_create_tag_should_return_existing_tag(self):
|
||||
first_tag = get_or_create_tag('Book', self.user)
|
||||
second_tag = get_or_create_tag('Book', self.user)
|
||||
|
||||
tags = Tag.objects.all()
|
||||
|
||||
self.assertEqual(len(tags), 1)
|
||||
self.assertEqual(first_tag.id, second_tag.id)
|
||||
|
||||
def test_get_or_create_tag_should_ignore_casing_when_looking_for_existing_tag(self):
|
||||
first_tag = get_or_create_tag('Book', self.user)
|
||||
second_tag = get_or_create_tag('book', self.user)
|
||||
|
||||
tags = Tag.objects.all()
|
||||
|
||||
self.assertEqual(len(tags), 1)
|
||||
self.assertEqual(first_tag.id, second_tag.id)
|
||||
|
||||
def test_get_or_create_tag_should_handle_legacy_dbs_with_existing_duplicates(self):
|
||||
first_tag = Tag.objects.create(name='book', date_added=timezone.now(), owner=self.user)
|
||||
Tag.objects.create(name='Book', date_added=timezone.now(), owner=self.user)
|
||||
retrieved_tag = get_or_create_tag('Book', self.user)
|
||||
|
||||
self.assertEqual(first_tag.id, retrieved_tag.id)
|
||||
|
||||
def test_get_or_create_tags_should_return_tags(self):
|
||||
books_tag = get_or_create_tag('Book', self.user)
|
||||
movies_tag = get_or_create_tag('Movie', self.user)
|
||||
|
||||
tags = get_or_create_tags(['book', 'movie'], self.user)
|
||||
|
||||
self.assertEqual(len(tags), 2)
|
||||
self.assertListEqual(tags, [books_tag, movies_tag])
|
||||
|
||||
def test_get_or_create_tags_should_deduplicate_tags(self):
|
||||
books_tag = get_or_create_tag('Book', self.user)
|
||||
|
||||
tags = get_or_create_tags(['book', 'Book', 'BOOK'], self.user)
|
||||
|
||||
self.assertEqual(len(tags), 1)
|
||||
self.assertListEqual(tags, [books_tag])
|
@@ -11,16 +11,17 @@ urlpatterns = [
|
||||
url(r'^$', RedirectView.as_view(pattern_name='bookmarks:index', permanent=False)),
|
||||
# Bookmarks
|
||||
path('bookmarks', views.bookmarks.index, name='index'),
|
||||
path('bookmarks/archived', views.bookmarks.archived, name='archived'),
|
||||
path('bookmarks/new', views.bookmarks.new, name='new'),
|
||||
path('bookmarks/close', views.bookmarks.close, name='close'),
|
||||
path('bookmarks/<int:bookmark_id>/edit', views.bookmarks.edit, name='edit'),
|
||||
path('bookmarks/<int:bookmark_id>/remove', views.bookmarks.remove, name='remove'),
|
||||
path('bookmarklet', views.bookmarks.bookmarklet, name='bookmarklet'),
|
||||
path('bookmarks/<int:bookmark_id>/archive', views.bookmarks.archive, name='archive'),
|
||||
path('bookmarks/<int:bookmark_id>/unarchive', views.bookmarks.unarchive, name='unarchive'),
|
||||
# Settings
|
||||
path('settings', views.settings.index, name='settings.index'),
|
||||
path('settings/import', views.settings.bookmark_import, name='settings.import'),
|
||||
path('settings/export', views.settings.bookmark_export, name='settings.export'),
|
||||
# API
|
||||
path('api/check_url', views.api.check_url, name='api.check_url'),
|
||||
url(r'^api/', include(router.urls))
|
||||
path('api/', include(router.urls), name='api')
|
||||
]
|
||||
|
2
bookmarks/utils.py
Normal file
2
bookmarks/utils.py
Normal file
@@ -0,0 +1,2 @@
|
||||
def unique(elements, key):
|
||||
return list({key(element): element for element in elements}.values())
|
14
bookmarks/validators.py
Normal file
14
bookmarks/validators.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django.conf import settings
|
||||
from django.core import validators
|
||||
|
||||
|
||||
class BookmarkURLValidator(validators.URLValidator):
|
||||
"""
|
||||
Extends default Django URLValidator and cancels validation if it is disabled in settings.
|
||||
This allows to switch URL validation on/off dynamically which helps with testing
|
||||
"""
|
||||
def __call__(self, value):
|
||||
if settings.LD_DISABLE_URL_VALIDATION:
|
||||
return
|
||||
|
||||
super().__call__(value)
|
@@ -1,3 +1,2 @@
|
||||
from .api import *
|
||||
from .bookmarks import *
|
||||
from .settings import *
|
||||
|
@@ -1,27 +0,0 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.forms import model_to_dict
|
||||
from django.http import JsonResponse
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.services.website_loader import load_website_metadata
|
||||
from bookmarks.models import Bookmark
|
||||
|
||||
|
||||
@login_required
|
||||
def check_url(request):
|
||||
url = request.GET.get('url')
|
||||
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
||||
existing_bookmark_data = None
|
||||
|
||||
if bookmark is not None:
|
||||
existing_bookmark_data = {
|
||||
'id': bookmark.id,
|
||||
'edit_url': reverse('bookmarks:edit', args=[bookmark.id])
|
||||
}
|
||||
|
||||
metadata = load_website_metadata(url)
|
||||
|
||||
return JsonResponse({
|
||||
'bookmark': existing_bookmark_data,
|
||||
'metadata': metadata.to_dict()
|
||||
})
|
@@ -8,44 +8,58 @@ from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import Bookmark, BookmarkForm, build_tag_string
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
|
||||
from bookmarks.queries import get_user_tags
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, unarchive_bookmark
|
||||
|
||||
_default_page_size = 30
|
||||
|
||||
|
||||
@login_required
|
||||
def index(request):
|
||||
page = request.GET.get('page')
|
||||
query_string = request.GET.get('q')
|
||||
query_set = queries.query_bookmarks(request.user, query_string)
|
||||
tags = queries.query_bookmark_tags(request.user, query_string)
|
||||
base_url = reverse('bookmarks:index')
|
||||
context = get_bookmark_view_context(request, query_set, tags, base_url)
|
||||
return render(request, 'bookmarks/index.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def archived(request):
|
||||
query_string = request.GET.get('q')
|
||||
query_set = queries.query_archived_bookmarks(request.user, query_string)
|
||||
tags = queries.query_archived_bookmark_tags(request.user, query_string)
|
||||
base_url = reverse('bookmarks:archived')
|
||||
context = get_bookmark_view_context(request, query_set, tags, base_url)
|
||||
return render(request, 'bookmarks/archive.html', context)
|
||||
|
||||
|
||||
def get_bookmark_view_context(request, query_set, tags, base_url):
|
||||
page = request.GET.get('page')
|
||||
query_string = request.GET.get('q')
|
||||
paginator = Paginator(query_set, _default_page_size)
|
||||
bookmarks = paginator.get_page(page)
|
||||
tags = queries.query_tags(request.user, query_string)
|
||||
return_url = generate_index_return_url(page, query_string)
|
||||
return_url = generate_return_url(base_url, page, query_string)
|
||||
|
||||
if request.GET.get('tag'):
|
||||
mod = request.GET.copy()
|
||||
mod.pop('tag')
|
||||
request.GET = mod
|
||||
|
||||
context = {
|
||||
return {
|
||||
'bookmarks': bookmarks,
|
||||
'tags': tags,
|
||||
'query': query_string if query_string else '',
|
||||
'empty': paginator.count == 0,
|
||||
'return_url': return_url
|
||||
}
|
||||
return render(request, 'bookmarks/index.html', context)
|
||||
|
||||
|
||||
def generate_index_return_url(page, query_string):
|
||||
def generate_return_url(base_url, page, query_string):
|
||||
url_query = {}
|
||||
if query_string is not None:
|
||||
url_query['q'] = query_string
|
||||
if page is not None:
|
||||
url_query['page'] = page
|
||||
base_url = reverse('bookmarks:index')
|
||||
url_params = urllib.parse.urlencode(url_query)
|
||||
return_url = base_url if url_params == '' else base_url + '?' + url_params
|
||||
return urllib.parse.quote_plus(return_url)
|
||||
@@ -73,7 +87,7 @@ def new(request):
|
||||
if initial_auto_close:
|
||||
form.initial['auto_close'] = 'true'
|
||||
|
||||
all_tags = get_user_tags(request.user)
|
||||
all_tags = queries.get_user_tags(request.user)
|
||||
context = {
|
||||
'form': form,
|
||||
'auto_close': initial_auto_close,
|
||||
@@ -102,7 +116,7 @@ def edit(request, bookmark_id: int):
|
||||
|
||||
form.initial['tag_string'] = build_tag_string(bookmark.tag_names, ' ')
|
||||
form.initial['return_url'] = return_url
|
||||
all_tags = get_user_tags(request.user)
|
||||
all_tags = queries.get_user_tags(request.user)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
@@ -124,10 +138,21 @@ def remove(request, bookmark_id: int):
|
||||
|
||||
|
||||
@login_required
|
||||
def bookmarklet(request):
|
||||
return render(request, 'bookmarks/bookmarklet.html', {
|
||||
'application_url': request.build_absolute_uri("/bookmarks/new")
|
||||
})
|
||||
def archive(request, bookmark_id: int):
|
||||
bookmark = Bookmark.objects.get(pk=bookmark_id)
|
||||
archive_bookmark(bookmark)
|
||||
return_url = request.GET.get('return_url')
|
||||
return_url = return_url if return_url else reverse('bookmarks:index')
|
||||
return HttpResponseRedirect(return_url)
|
||||
|
||||
|
||||
@login_required
|
||||
def unarchive(request, bookmark_id: int):
|
||||
bookmark = Bookmark.objects.get(pk=bookmark_id)
|
||||
unarchive_bookmark(bookmark)
|
||||
return_url = request.GET.get('return_url')
|
||||
return_url = return_url if return_url else reverse('bookmarks:archived')
|
||||
return HttpResponseRedirect(return_url)
|
||||
|
||||
|
||||
@login_required
|
||||
|
@@ -18,10 +18,12 @@ logger = logging.getLogger(__name__)
|
||||
def index(request):
|
||||
import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success')
|
||||
import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors')
|
||||
application_url = request.build_absolute_uri("/bookmarks/new")
|
||||
api_token = Token.objects.get_or_create(user=request.user)[0]
|
||||
return render(request, 'settings/index.html', {
|
||||
'import_success_message': import_success_message,
|
||||
'import_errors_message': import_errors_message,
|
||||
'application_url': application_url,
|
||||
'api_token': api_token.key
|
||||
})
|
||||
|
||||
@@ -35,7 +37,7 @@ def bookmark_import(request):
|
||||
return HttpResponseRedirect(reverse('bookmarks:settings.index'))
|
||||
|
||||
try:
|
||||
content = import_file.read()
|
||||
content = import_file.read().decode()
|
||||
result = import_netscape_html(content, request.user)
|
||||
success_msg = str(result.success) + ' bookmarks were successfully imported.'
|
||||
messages.success(request, success_msg, 'bookmark_import_success')
|
||||
|
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
linkding:
|
||||
container_name: "${LD_CONTAINER_NAME:-linkding}"
|
||||
image: sissbruecker/linkding:latest
|
||||
ports:
|
||||
- "${LD_HOST_PORT:-9090}:9090"
|
||||
volumes:
|
||||
- "${LD_HOST_DATA_DIR:-./data}:/etc/linkding/data"
|
||||
restart: unless-stopped
|
@@ -19,6 +19,7 @@
|
||||
<sourceFolder url="file://$MODULE_DIR$/polls" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/siteroot" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/bookmarks" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/build" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
363
package-lock.json
generated
363
package-lock.json
generated
@@ -1,57 +1,74 @@
|
||||
{
|
||||
"name": "linkdings",
|
||||
"name": "linkding",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": {
|
||||
"version": "7.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.5.5.tgz",
|
||||
"integrity": "sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw==",
|
||||
"version": "7.12.11",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz",
|
||||
"integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==",
|
||||
"requires": {
|
||||
"@babel/highlight": "^7.0.0"
|
||||
"@babel/highlight": "^7.10.4"
|
||||
}
|
||||
},
|
||||
"@babel/helper-validator-identifier": {
|
||||
"version": "7.12.11",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz",
|
||||
"integrity": "sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw=="
|
||||
},
|
||||
"@babel/highlight": {
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.5.0.tgz",
|
||||
"integrity": "sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ==",
|
||||
"version": "7.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz",
|
||||
"integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==",
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.10.4",
|
||||
"chalk": "^2.0.0",
|
||||
"esutils": "^2.0.2",
|
||||
"js-tokens": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"@rollup/plugin-commonjs": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-11.0.0.tgz",
|
||||
"integrity": "sha512-jnm//T5ZWOZ6zmJ61fReSCBOif+Ax8dHVoVggA+d2NA7T4qCWgQ3KYr+zN2faGEYLpe1wa03IzvhR+sqVLxUWg==",
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-17.0.0.tgz",
|
||||
"integrity": "sha512-/omBIJG1nHQc+bgkYDuLpb/V08QyutP9amOrJRUSlYJZP+b/68gM//D8sxJe3Yry2QnYIr3QjR3x4AlxJEN3GA==",
|
||||
"requires": {
|
||||
"@rollup/pluginutils": "^3.0.0",
|
||||
"estree-walker": "^0.6.1",
|
||||
"is-reference": "^1.1.2",
|
||||
"magic-string": "^0.25.2",
|
||||
"resolve": "^1.11.0"
|
||||
"@rollup/pluginutils": "^3.1.0",
|
||||
"commondir": "^1.0.1",
|
||||
"estree-walker": "^2.0.1",
|
||||
"glob": "^7.1.6",
|
||||
"is-reference": "^1.2.1",
|
||||
"magic-string": "^0.25.7",
|
||||
"resolve": "^1.17.0"
|
||||
}
|
||||
},
|
||||
"@rollup/plugin-node-resolve": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-6.0.0.tgz",
|
||||
"integrity": "sha512-GqWz1CfXOsqpeVMcoM315+O7zMxpRsmhWyhJoxLFHVSp9S64/u02i7len/FnbTNbmgYs+sZyilasijH8UiuboQ==",
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.0.1.tgz",
|
||||
"integrity": "sha512-ltlsj/4Bhwwhb+Nb5xCz/6vieuEj2/BAkkqVIKmZwC7pIdl8srmgmglE4S0jFlZa32K4qvdQ6NHdmpRKD/LwoQ==",
|
||||
"requires": {
|
||||
"@rollup/pluginutils": "^3.0.0",
|
||||
"@types/resolve": "0.0.8",
|
||||
"@rollup/pluginutils": "^3.1.0",
|
||||
"@types/resolve": "1.17.1",
|
||||
"builtin-modules": "^3.1.0",
|
||||
"deepmerge": "^4.2.2",
|
||||
"is-module": "^1.0.0",
|
||||
"resolve": "^1.11.1"
|
||||
"resolve": "^1.19.0"
|
||||
}
|
||||
},
|
||||
"@rollup/pluginutils": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.0.1.tgz",
|
||||
"integrity": "sha512-PmNurkecagFimv7ZdKCVOfQuqKDPkrcpLFxRBcQ00LYr4HAjJwhCFxBiY2Xoletll2htTIiXBg6g0Yg21h2M3w==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
|
||||
"integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==",
|
||||
"requires": {
|
||||
"estree-walker": "^0.6.1"
|
||||
"@types/estree": "0.0.39",
|
||||
"estree-walker": "^1.0.1",
|
||||
"picomatch": "^2.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"estree-walker": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz",
|
||||
"integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@types/estree": {
|
||||
@@ -65,18 +82,13 @@
|
||||
"integrity": "sha512-hx6zWtudh3Arsbl3cXay+JnkvVgCKzCWKv42C9J01N2T2np4h8w5X8u6Tpz5mj38kE3M9FM0Pazx8vKFFMnjLQ=="
|
||||
},
|
||||
"@types/resolve": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz",
|
||||
"integrity": "sha512-auApPaJf3NPfe18hSoJkp8EbZzer2ISk7o8mCC3M9he/a04+gbMF97NkpD2S8riMGvm4BMRI59/SZQSaLTKpsQ==",
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
|
||||
"integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==",
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"acorn": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.2.0.tgz",
|
||||
"integrity": "sha512-apwXVmYVpQ34m/i71vrApRrRKCWQnZZF1+npOD0WV5xZFfwWOmKGQ2RWlfdy9vWITsenisM8M0Qeq8agcFHNiQ=="
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||
@@ -85,15 +97,29 @@
|
||||
"color-convert": "^1.9.0"
|
||||
}
|
||||
},
|
||||
"balanced-match": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
|
||||
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"buffer-from": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
|
||||
"integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A=="
|
||||
},
|
||||
"builtin-modules": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.1.0.tgz",
|
||||
"integrity": "sha512-k0KL0aWZuBt2lrxrcASWDfwOLMnodeQjodT/1SxEQAXsHANgo6ZC/VEaSEHCXt7aSTZ4/4H5LKa+tBXmW7Vtvw=="
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.2.0.tgz",
|
||||
"integrity": "sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA=="
|
||||
},
|
||||
"chalk": {
|
||||
"version": "2.4.2",
|
||||
@@ -123,54 +149,129 @@
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
|
||||
},
|
||||
"commondir": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
||||
"integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs="
|
||||
},
|
||||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
|
||||
},
|
||||
"deepmerge": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
|
||||
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg=="
|
||||
},
|
||||
"escape-string-regexp": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
|
||||
},
|
||||
"estree-walker": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz",
|
||||
"integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
|
||||
},
|
||||
"esutils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
|
||||
"fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
|
||||
},
|
||||
"fsevents": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz",
|
||||
"integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==",
|
||||
"optional": true
|
||||
},
|
||||
"function-bind": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
|
||||
},
|
||||
"glob": {
|
||||
"version": "7.1.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
|
||||
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
|
||||
"requires": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.0.4",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"has": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
|
||||
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
|
||||
"requires": {
|
||||
"function-bind": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"has-flag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
|
||||
},
|
||||
"inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
|
||||
"requires": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"is-core-module": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.2.0.tgz",
|
||||
"integrity": "sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==",
|
||||
"requires": {
|
||||
"has": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"is-module": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
|
||||
"integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE="
|
||||
},
|
||||
"is-reference": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.1.4.tgz",
|
||||
"integrity": "sha512-uJA/CDPO3Tao3GTrxYn6AwkM4nUPJiGGYu5+cB8qbC7WGFlrKZbiRo7SFKxUAEpFUfiHofWCXBUNhvYJMh+6zw==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
||||
"integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
|
||||
"requires": {
|
||||
"@types/estree": "0.0.39"
|
||||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"jest-worker": {
|
||||
"version": "24.9.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-24.9.0.tgz",
|
||||
"integrity": "sha512-51PE4haMSXcHohnSMdM42anbvZANYTqMrr52tVKPqqsPJMzoP6FYYDVqahX/HrAoKEKz3uUPzSvKs9A3qR4iVw==",
|
||||
"version": "26.6.2",
|
||||
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz",
|
||||
"integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==",
|
||||
"requires": {
|
||||
"@types/node": "*",
|
||||
"merge-stream": "^2.0.0",
|
||||
"supports-color": "^6.1.0"
|
||||
"supports-color": "^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
|
||||
"integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==",
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"requires": {
|
||||
"has-flag": "^3.0.0"
|
||||
"has-flag": "^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,9 +282,9 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
|
||||
},
|
||||
"magic-string": {
|
||||
"version": "0.25.4",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.4.tgz",
|
||||
"integrity": "sha512-oycWO9nEVAP2RVPbIoDoA4Y7LFIJ3xRYov93gAyJhZkET1tNuB0u7uWkZS2LpBWTJUWnmau/To8ECWRC+jKNfw==",
|
||||
"version": "0.25.7",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz",
|
||||
"integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==",
|
||||
"requires": {
|
||||
"sourcemap-codec": "^1.4.4"
|
||||
}
|
||||
@@ -193,54 +294,85 @@
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
|
||||
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
|
||||
"requires": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
|
||||
},
|
||||
"path-parse": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
|
||||
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw=="
|
||||
},
|
||||
"picomatch": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
|
||||
"integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg=="
|
||||
},
|
||||
"randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
|
||||
"requires": {
|
||||
"safe-buffer": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"require-relative": {
|
||||
"version": "0.8.7",
|
||||
"resolved": "https://registry.npmjs.org/require-relative/-/require-relative-0.8.7.tgz",
|
||||
"integrity": "sha1-eZlTn8ngR6N5KPoZb44VY9q9Nt4="
|
||||
},
|
||||
"resolve": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.14.1.tgz",
|
||||
"integrity": "sha512-fn5Wobh4cxbLzuHaE+nphztHy43/b++4M6SsGFC2gB8uYwf0C8LcarfCz1un7UTW8OFQg9iNjZ4xpcFVGebDPg==",
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz",
|
||||
"integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==",
|
||||
"requires": {
|
||||
"is-core-module": "^2.1.0",
|
||||
"path-parse": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"rollup": {
|
||||
"version": "1.27.14",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-1.27.14.tgz",
|
||||
"integrity": "sha512-DuDjEyn8Y79ALYXMt+nH/EI58L5pEw5HU9K38xXdRnxQhvzUTI/nxAawhkAHUQeudANQ//8iyrhVRHJBuR6DSQ==",
|
||||
"version": "2.35.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.35.1.tgz",
|
||||
"integrity": "sha512-q5KxEyWpprAIcainhVy6HfRttD9kutQpHbeqDTWnqAFNJotiojetK6uqmcydNMymBEtC4I8bCYR+J3mTMqeaUA==",
|
||||
"requires": {
|
||||
"@types/estree": "*",
|
||||
"@types/node": "*",
|
||||
"acorn": "^7.1.0"
|
||||
"fsevents": "~2.1.2"
|
||||
}
|
||||
},
|
||||
"rollup-plugin-svelte": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup-plugin-svelte/-/rollup-plugin-svelte-5.1.1.tgz",
|
||||
"integrity": "sha512-wP3CnKHjR4fZUgNm5Iey7eItnxwnH/nAw568WJ8dpMSchBxxZ/DmKSx8e6h8k/B6SwG1wfGvWehadFJHcuFFSw==",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup-plugin-svelte/-/rollup-plugin-svelte-7.0.0.tgz",
|
||||
"integrity": "sha512-cw4yv/5v1NQV3nPbpOJtikgkB+9mfSJaqKUdq7x5fVQJnwLtcdc2JOszBs5pBY+SemTs5pmJbdEMseEavbUtjQ==",
|
||||
"requires": {
|
||||
"require-relative": "^0.8.7",
|
||||
"rollup-pluginutils": "^2.3.3",
|
||||
"sourcemap-codec": "^1.4.4"
|
||||
"rollup-pluginutils": "^2.8.2"
|
||||
}
|
||||
},
|
||||
"rollup-plugin-terser": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-5.1.3.tgz",
|
||||
"integrity": "sha512-FuFuXE5QUJ7snyxHLPp/0LFXJhdomKlIx/aK7Tg88Yubsx/UU/lmInoJafXJ4jwVVNcORJ1wRUC5T9cy5yk0wA==",
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz",
|
||||
"integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==",
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.0.0",
|
||||
"jest-worker": "^24.6.0",
|
||||
"rollup-pluginutils": "^2.8.1",
|
||||
"serialize-javascript": "^2.1.2",
|
||||
"terser": "^4.1.0"
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"jest-worker": "^26.2.1",
|
||||
"serialize-javascript": "^4.0.0",
|
||||
"terser": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"rollup-pluginutils": {
|
||||
@@ -249,31 +381,53 @@
|
||||
"integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==",
|
||||
"requires": {
|
||||
"estree-walker": "^0.6.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"estree-walker": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz",
|
||||
"integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
|
||||
},
|
||||
"serialize-javascript": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-2.1.2.tgz",
|
||||
"integrity": "sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ=="
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
|
||||
"integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
|
||||
"requires": {
|
||||
"randombytes": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
|
||||
"integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ=="
|
||||
},
|
||||
"source-map-support": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.16.tgz",
|
||||
"integrity": "sha512-efyLRJDr68D9hBBNIPWFjhpFzURh+KJykQwvMyW5UiZzYwoF6l4YMMDIJJEyFWxWCqfyxLzz6tSfUFR+kXXsVQ==",
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz",
|
||||
"integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==",
|
||||
"requires": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"sourcemap-codec": {
|
||||
"version": "1.4.6",
|
||||
"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.6.tgz",
|
||||
"integrity": "sha512-1ZooVLYFxC448piVLBbtOxFcXwnymH9oUF8nRd3CuYDVvkRBxRl6pB4Mtas5a4drtL+E8LDgFkQNcgIw6tc8Hg=="
|
||||
"version": "1.4.8",
|
||||
"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
|
||||
"integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="
|
||||
},
|
||||
"spectre.css": {
|
||||
"version": "0.5.8",
|
||||
@@ -289,19 +443,24 @@
|
||||
}
|
||||
},
|
||||
"svelte": {
|
||||
"version": "3.16.7",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.16.7.tgz",
|
||||
"integrity": "sha512-egrva1UklB1n7KAv179IhDpQzMGAvubJUlOQ9PitmmZmAfrCUEgrQnx2vPxn2s+mGV3aYegXvJ/yQ35N2SfnYQ=="
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.31.0.tgz",
|
||||
"integrity": "sha512-r+n8UJkDqoQm1b+3tA3Lh6mHXKpcfOSOuEuIo5gE2W9wQYi64RYX/qE6CZBDDsP/H4M+N426JwY7XGH4xASvGQ=="
|
||||
},
|
||||
"terser": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-4.4.3.tgz",
|
||||
"integrity": "sha512-0ikKraVtRDKGzHrzkCv5rUNDzqlhmhowOBqC0XqUHFpW+vJ45+20/IFBcebwKfiS2Z9fJin6Eo+F1zLZsxi8RA==",
|
||||
"version": "5.5.1",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.5.1.tgz",
|
||||
"integrity": "sha512-6VGWZNVP2KTUcltUQJ25TtNjx/XgdDsBDKGt8nN0MpydU36LmbPPcMBd2kmtZNNGVVDLg44k7GKeHHj+4zPIBQ==",
|
||||
"requires": {
|
||||
"commander": "^2.20.0",
|
||||
"source-map": "~0.6.1",
|
||||
"source-map-support": "~0.5.12"
|
||||
"source-map": "~0.7.2",
|
||||
"source-map-support": "~0.5.19"
|
||||
}
|
||||
},
|
||||
"wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.2",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -20,11 +20,11 @@
|
||||
"homepage": "https://github.com/sissbruecker/linkding#readme",
|
||||
"dependencies": {
|
||||
"spectre.css": "^0.5.8",
|
||||
"@rollup/plugin-commonjs": "^11.0.0",
|
||||
"@rollup/plugin-node-resolve": "^6.0.0",
|
||||
"rollup": "^1.20.0",
|
||||
"rollup-plugin-svelte": "^5.0.3",
|
||||
"rollup-plugin-terser": "^5.1.2",
|
||||
"svelte": "^3.0.0"
|
||||
"@rollup/plugin-commonjs": "^17.0.0",
|
||||
"@rollup/plugin-node-resolve": "^11.0.1",
|
||||
"rollup": "^2.35.1",
|
||||
"rollup-plugin-svelte": "^7.0.0",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"svelte": "^3.31.0"
|
||||
}
|
||||
}
|
||||
|
@@ -11,6 +11,7 @@ django-sass-processor==0.7.3
|
||||
django-widget-tweaks==1.4.5
|
||||
djangorestframework==3.11.1
|
||||
idna==2.8
|
||||
pyparsing==2.4.7
|
||||
pytz==2019.1
|
||||
rcssmin==1.0.6
|
||||
requests==2.22.0
|
||||
|
@@ -13,6 +13,7 @@ django-widget-tweaks==1.4.5
|
||||
djangorestframework==3.11.1
|
||||
idna==2.8
|
||||
libsass==0.19.2
|
||||
pyparsing==2.4.7
|
||||
pytz==2019.1
|
||||
rcssmin==1.0.6
|
||||
requests==2.22.0
|
||||
|
@@ -15,8 +15,7 @@ export default {
|
||||
},
|
||||
plugins: [
|
||||
svelte({
|
||||
// enable run-time checks when not in production
|
||||
dev: !production,
|
||||
emitCss: false
|
||||
}),
|
||||
|
||||
// If you have external dependencies installed from
|
||||
|
@@ -151,9 +151,15 @@ REST_FRAMEWORK = {
|
||||
'rest_framework.authentication.TokenAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
],
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated'
|
||||
],
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
|
||||
'PAGE_SIZE': 100
|
||||
}
|
||||
|
||||
# Registration switch
|
||||
ALLOW_REGISTRATION = False
|
||||
|
||||
# URL validation flag
|
||||
LD_DISABLE_URL_VALIDATION = os.getenv('LD_DISABLE_URL_VALIDATION', False) in (True, 'True', '1')
|
||||
|
@@ -12,21 +12,25 @@ DEBUG = True
|
||||
SASS_PROCESSOR_ENABLED = True
|
||||
|
||||
# Enable debug logging
|
||||
# Logging with SQL statements
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'filters': {
|
||||
'require_debug_true': {
|
||||
'()': 'django.utils.log.RequireDebugTrue',
|
||||
}
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'simple': {
|
||||
'format': '{levelname} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'level': 'DEBUG',
|
||||
'filters': ['require_debug_true'],
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'simple'
|
||||
}
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': 'WARNING',
|
||||
},
|
||||
'loggers': {
|
||||
'django.db.backends': {
|
||||
'level': 'ERROR', # Set to DEBUG to log all SQL calls
|
||||
|
@@ -11,3 +11,9 @@ vacuum=True
|
||||
stats = 127.0.0.1:9191
|
||||
uid = www-data
|
||||
gid = www-data
|
||||
|
||||
if-env = LD_REQUEST_TIMEOUT
|
||||
http-timeout = %(_)
|
||||
socket-timeout = %(_)
|
||||
harakiri = %(_)
|
||||
endif =
|
@@ -1 +1 @@
|
||||
1.0.0
|
||||
1.3.2
|
Reference in New Issue
Block a user