mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-14 22:19:32 +02:00
Compare commits
9 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
cb7abbfacb | ||
![]() |
b844293342 | ||
![]() |
0f231bcd9f | ||
![]() |
9df270557f | ||
![]() |
f98c89e99d | ||
![]() |
6addee1377 | ||
![]() |
16ba7f390d | ||
![]() |
64914fb0d5 | ||
![]() |
ac0f0a7831 |
3
.env.sample
Normal file
3
.env.sample
Normal file
@@ -0,0 +1,3 @@
|
||||
LD_CONTAINER_NAME=linkding
|
||||
LD_HOST_PORT=9090
|
||||
LD_HOST_DATA_DIR=./data
|
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
|
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,8 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## v1.0.0 (31/12/2020)
|
||||
## 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)
|
||||
- [**bug**] Increase limit on bookmark URL length [#25](https://github.com/sissbruecker/linkding/issues/25)
|
||||
|
19
README.md
19
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
|
||||
```
|
||||
|
||||
@@ -40,12 +40,27 @@ If you are using a Linux system you can use the following [shell script](https:/
|
||||
|
||||
The script can be configured using 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
|
||||
|
BIN
assets/logo.afdesign
Normal file
BIN
assets/logo.afdesign
Normal file
Binary file not shown.
@@ -4,6 +4,8 @@ from django import forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
|
||||
from bookmarks.utils import unique
|
||||
|
||||
|
||||
class Tag(models.Model):
|
||||
name = models.CharField(max_length=64)
|
||||
@@ -18,7 +20,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
|
||||
|
@@ -2,6 +2,7 @@ from django.contrib.auth.models import User
|
||||
from django.db.models import Q, Count, Aggregate, CharField, Value, BooleanField
|
||||
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.utils import unique
|
||||
|
||||
|
||||
class Concat(Aggregate):
|
||||
@@ -41,7 +42,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
|
||||
@@ -74,7 +75,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 +96,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,
|
||||
|
@@ -1,18 +1,21 @@
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
|
BIN
bookmarks/static/favicon.png
Normal file
BIN
bookmarks/static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
@@ -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">
|
||||
|
@@ -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
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)
|
||||
|
60
bookmarks/tests/test_tags_service.py
Normal file
60
bookmarks/tests/test_tags_service.py
Normal file
@@ -0,0 +1,60 @@
|
||||
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 TagTestCase(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_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])
|
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())
|
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
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@@ -1 +1 @@
|
||||
1.1.0
|
||||
1.2.0
|
Reference in New Issue
Block a user