12 |
Importing
13 |
You can import your bookmarks from Chrome, Firefox, Safari, Opera, Internet Explorer, as long as they're in the Netscape bookmark format. Your directories will be converted to tags automatically. If you have a bookmark inside the folders "Photography -> Music" it'll get the tags "Photography" and "Music". If you have a bookmark inside the folders "Music -> Photography", it will get the tags "Photography" and "Music" as well.
14 |
Adding bookmarks
15 |
URL
16 |
That's the link to your bookmarked site. If you copy and paste it, the title will be fetched from the page automatically and added to the Name field as long as it is blank.
17 |
Name
18 | The name of your bookmark.
19 |
Tags
20 |
Comma or space separated tags for your bookmark. "music, songwriting, people" tags a bookmark as "music", "songwriting" and "people". So does "music songwriting people". So does "music, songwriting people"!
21 |
Descriptions
22 |
When you add a bookmark, you can write a description for it using Markdown syntax.
23 |
Viewing bookmarks
24 |
You can sort bookmark listings by name or by date (most recent first).
25 | The description for a bookmark is hidden by default and can be seen by clicking on [+]. Clicking expand all will expand all descriptions, and collapse all will close all of them.
26 |
Privacy
27 |
Public profiles
28 |
If you set your profile to public, everyone can see your bookmarks unless they're tagged as "private".
29 |
Unlisted tags
30 |
If you prefix a tag with a dot, like ".notsosecret", bookmarks tagged with it won't show up if another user's browsing your tags or bookmarks, but you can still share links to that tag.
31 |
Private profiles
32 |
If you set your profile to private, nobody can see your bookmarks unless they're tagged as "public".
33 |
Contact / Contribute
34 |
If you need anything or have a suggestion just send an email to bmarks@felipecortez.net. bmarks is open-source software built with Python and Django. You can find all the code, suggest changes and contribute at GitHub.
35 |
36 | {% endblock %}
37 |
--------------------------------------------------------------------------------
/app/marksapp/tests/test_misc.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from django.contrib.auth.models import User
3 | from marksapp.models import Tag, Bookmark
4 | from marksapp.misc import tag_regex, multitag_regex, website_from_url
5 | import marksapp.views as views
6 | import re
7 |
8 |
9 | class WebsiteFromURL(TestCase):
10 | @classmethod
11 | def setUpTestData(cls):
12 | Tag.objects.create(name="music")
13 | Tag.objects.create(name="compsci")
14 |
15 | def test_extract(self):
16 | strs_to_test = {
17 | "https://bmarks.net": "bmarks.net",
18 | "http://bmarks.net": "bmarks.net",
19 | "ftp://bmarks.net": "bmarks.net",
20 | "bmarks.net": "bmarks.net",
21 | "www.bmarks.net": "bmarks.net",
22 | "www.bmarks.net/": "bmarks.net",
23 | "www.bmarks.net/etc": "bmarks.net",
24 | # "subdomain.bmarks.net/etc" : "bmarks.net",
25 | # "iamnotadomain" : None,
26 | }
27 |
28 | for url, expected in strs_to_test.items():
29 | self.assertEquals(expected, website_from_url(url))
30 |
31 |
32 | class TagSplitTests(TestCase):
33 | @classmethod
34 | def setUpTestData(cls):
35 | Tag.objects.create(name="music")
36 | Tag.objects.create(name="compsci")
37 |
38 | def test_creation(self):
39 | music_tag = Tag.objects.get(id=1)
40 | self.assertEquals(music_tag.name, "music")
41 |
42 | def test_split(self):
43 | strs_to_test = [
44 | "music, compsci, art",
45 | " music, compsci art",
46 | "music, compsci,art ",
47 | " music,compsci, art",
48 | " music, compsci,,,art",
49 | "music, compsci, art",
50 | "music compsci art",
51 | ]
52 |
53 | for string in strs_to_test:
54 | self.assertEquals(
55 | views.tags_strip_split(string), ["music", "compsci", "art"]
56 | )
57 |
58 |
59 | class RegexTests(TestCase):
60 | @classmethod
61 | def setUpTestData(cls):
62 | pass
63 |
64 | def test_simple(self):
65 | tag_simple = ".unlisted"
66 | assert re.match(tag_regex, tag_simple) is not None
67 |
68 | def test_multi(self):
69 | multi_tags = [
70 | "compsci",
71 | ".unlisted",
72 | ".unlisted+compsci",
73 | ".unlisted+.notreallylisted",
74 | "very+normal+indeed",
75 | ]
76 |
77 | for string in multi_tags:
78 | assert re.match(multitag_regex, string) is not None
79 |
--------------------------------------------------------------------------------
/app/marksapp/tests/test_pagination.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from django.contrib.auth.models import User
3 | from marksapp.models import Tag, Bookmark
4 | import marksapp.views as views
5 |
6 |
7 | def marks_names(marks):
8 | return [m.name for m in marks]
9 |
10 |
11 | class PaginateTests(TestCase):
12 | @classmethod
13 | def setUpTestData(cls):
14 | User.objects.create_user(username="testuser")
15 |
16 | for i in range(1, 12):
17 | Bookmark.objects.create(name=f"b{i}", date_added="2018-11-02T00:00+0000")
18 |
19 | def test_first_page(self):
20 | result = views.paginate(Bookmark.objects, limit=3)
21 | self.assertEqual(marks_names(result["marks"]), ["b11", "b10", "b9"])
22 | self.assertEqual(result["after_link"].name, "b9")
23 | self.assertIs(result["before_link"], None)
24 |
25 | def test_transition(self):
26 | first_page = views.paginate(Bookmark.objects, limit=3)
27 | after_first = first_page["after_link"]
28 | second_page = views.paginate(Bookmark.objects, after=after_first, limit=3)
29 |
30 | self.assertEqual(marks_names(second_page["marks"]), ["b8", "b7", "b6"])
31 | self.assertEqual(second_page["after_link"].name, "b6")
32 | self.assertEqual(second_page["before_link"].name, "b8")
33 |
34 | def test_after(self):
35 | b7 = Bookmark.objects.get(name="b7")
36 | result = views.paginate(Bookmark.objects, after=b7, limit=3)
37 |
38 | self.assertEqual(marks_names(result["marks"]), ["b6", "b5", "b4"])
39 | self.assertEqual(result["after_link"].name, "b4")
40 | self.assertEqual(result["before_link"].name, "b6")
41 |
42 | def test_before(self):
43 | b6 = Bookmark.objects.get(name="b6")
44 | result = views.paginate(Bookmark.objects, before=b6, limit=3)
45 |
46 | self.assertEqual(marks_names(result["marks"]), ["b9", "b8", "b7"])
47 | self.assertEqual(result["after_link"].name, "b7")
48 | self.assertEqual(result["before_link"].name, "b9")
49 |
50 | def test_incomplete_last_page(self):
51 | b3 = Bookmark.objects.get(name="b3")
52 | result = views.paginate(Bookmark.objects, after=b3, limit=3)
53 |
54 | self.assertEqual(marks_names(result["marks"]), ["b2", "b1"])
55 | self.assertIs(result["after_link"], None)
56 | self.assertEqual(result["before_link"].name, "b2")
57 |
58 | def test_pagination_by_name(self):
59 | result = views.paginate(Bookmark.objects, limit=5, sort_column="name")
60 |
61 | self.assertEqual(marks_names(result["marks"]), ["b1", "b10", "b11", "b2", "b3"])
62 | self.assertEqual(result["after_link"].name, "b3")
63 | self.assertIs(result["before_link"], None)
64 |
65 | def test_pagination_by_name_inverse(self):
66 | result = views.paginate(Bookmark.objects, limit=5, sort_column="-name")
67 |
68 | self.assertEqual(marks_names(result["marks"]), ["b9", "b8", "b7", "b6", "b5"])
69 | self.assertEqual(result["after_link"].name, "b5")
70 | self.assertIs(result["before_link"], None)
71 |
--------------------------------------------------------------------------------
/app/marksapp/management/commands/setupfromfile.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand, CommandError
2 | from django.utils.text import slugify
3 | from marksapp.models import Bookmark, Tag
4 | from html.parser import HTMLParser
5 | import datetime
6 |
7 |
8 | class NetscapeParser(HTMLParser):
9 | add_mark = False
10 | add_cat = False
11 | add_date = 0
12 | icon = ""
13 | href = ""
14 | tags = []
15 | categories = []
16 | bookmarks = []
17 |
18 | def handle_starttag(self, tag, attrs):
19 | if tag == "h3":
20 | self.add_cat = True
21 | if tag == "a":
22 | self.add_mark = True
23 | for attr in attrs:
24 | if attr[0] == "href":
25 | self.href = attr[1]
26 | elif attr[0] == "add_date":
27 | self.add_date = datetime.datetime.utcfromtimestamp(
28 | int(attr[1])
29 | ).replace(tzinfo=datetime.timezone.utc)
30 | elif attr[0] == "icon":
31 | self.icon = attr[1]
32 | elif attr[0] == "tags":
33 | self.tags = attr[1].split(",")
34 |
35 | def handle_endtag(self, tag):
36 | if tag == "dl":
37 | if self.categories:
38 | self.categories.pop()
39 |
40 | def handle_data(self, data):
41 | if self.add_cat == True:
42 | self.categories.append(data.lower())
43 | self.add_cat = False
44 | elif self.add_mark == True:
45 | mark = {}
46 | mark["name"] = data
47 | mark["url"] = self.href
48 | mark["categories"] = self.categories[:]
49 | mark["tags"] = self.tags[:]
50 | mark["add_date"] = self.add_date
51 | self.bookmarks.append(mark)
52 | self.tags = []
53 | self.add_mark = False
54 |
55 |
56 | def bookmarks_from_file(filename):
57 | with open(filename, "r") as f:
58 | bookmarks = f.read()
59 |
60 | parser = NetscapeParser()
61 | parser.feed(bookmarks)
62 | return parser.bookmarks
63 |
64 |
65 | class Command(BaseCommand):
66 | help = "Populate DB from a Netscape bookmark file"
67 |
68 | def add_arguments(self, parser):
69 | parser.add_argument("filename")
70 |
71 | def handle(self, *args, **options):
72 | Bookmark.objects.all().delete()
73 | Tag.objects.all().delete()
74 |
75 | for mark in bookmarks_from_file(options["filename"]):
76 | b = Bookmark.objects.update_or_create(url=mark["url"])[0]
77 | b.name = mark["name"]
78 | b.date_added = mark["add_date"]
79 |
80 | for tag in mark["categories"] + mark["tags"]:
81 | t = Tag.objects.get_or_create(name=slugify(tag))[0]
82 | b.tags.add(t)
83 |
84 | b.save()
85 |
86 | print(Bookmark.objects.all())
87 |
88 | # b1 = Bookmark(name="Opa",
89 | # url="opa.com")
90 | # b2 = Bookmark(name="Bicho",
91 | # url="bicho.com")
92 | # b2.save()
93 |
94 | # t1 = Tag(name="inutil")
95 | # t2 = Tag(name="util")
96 | # t3 = Tag(name="teste")
97 | # b1.save()
98 | # t1.save()
99 | # t2.save()
100 | # t3.save()
101 | # b1.tags.add(t1)
102 | # b1.save()
103 | # b2.tags.add(t2)
104 | # b2.tags.add(t3)
105 | # b2.save()
106 |
107 | # print(b1)
108 | # print(b2)
109 |
--------------------------------------------------------------------------------
/app/marksapp/templates/marks.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% load filters %}
3 |
4 | {% block page_info %}
5 | No results found.
121 | {% endif %}
122 |
123 | {% endblock %}
124 |
--------------------------------------------------------------------------------
/app/marks/settings.py:
--------------------------------------------------------------------------------
1 | from .config import *
2 | import os
3 | import logging
4 |
5 | SECRET_KEY = "defaultkey"
6 |
7 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
8 |
9 | if os.environ.get("DJANGO_DEVELOPMENT") is not None:
10 | DEBUG = True
11 | else:
12 | DEBUG = False
13 |
14 | ALLOWED_HOSTS = ["127.0.0.1", ".bmarks.net"]
15 |
16 | INTERNAL_IPS = ["127.0.0.1",]
17 |
18 | AUTHENTICATION_BACKENDS = ["marksapp.backends.CaseInsensitiveModelBackend"]
19 |
20 | INSTALLED_APPS = [
21 | "marksapp.apps.MarksappConfig",
22 | "django.contrib.admin",
23 | "django.contrib.auth",
24 | "django.contrib.contenttypes",
25 | "django.contrib.sessions",
26 | "django.contrib.messages",
27 | "django.contrib.staticfiles",
28 | "django.contrib.postgres",
29 | "debug_toolbar",
30 | ]
31 |
32 | MIDDLEWARE = [
33 | "django.middleware.security.SecurityMiddleware",
34 | "django.contrib.sessions.middleware.SessionMiddleware",
35 | "django.middleware.common.CommonMiddleware",
36 | "django.middleware.csrf.CsrfViewMiddleware",
37 | "django.contrib.auth.middleware.AuthenticationMiddleware",
38 | "django.contrib.messages.middleware.MessageMiddleware",
39 | "django.middleware.clickjacking.XFrameOptionsMiddleware",
40 | "debug_toolbar.middleware.DebugToolbarMiddleware",
41 | ]
42 |
43 | ROOT_URLCONF = "marks.urls"
44 |
45 | TEMPLATES = [
46 | {
47 | "BACKEND": "django.template.backends.django.DjangoTemplates",
48 | "DIRS": [],
49 | "APP_DIRS": True,
50 | "OPTIONS": {
51 | "context_processors": [
52 | "django.template.context_processors.debug",
53 | "django.template.context_processors.request",
54 | "django.contrib.auth.context_processors.auth",
55 | "django.contrib.messages.context_processors.messages",
56 | ]
57 | },
58 | }
59 | ]
60 |
61 | WSGI_APPLICATION = "marks.wsgi.application"
62 |
63 |
64 | if "TRAVIS" in os.environ:
65 | print("Using Travis CI for testing")
66 |
67 | DATABASES = {
68 | "default": {
69 | "ENGINE": "django.db.backends.postgresql",
70 | "NAME": "test_db",
71 | "USER": "postgres",
72 | "PASSWORD": "",
73 | "HOST": "localhost",
74 | "PORT": "",
75 | }
76 | }
77 | else:
78 | DATABASES = {
79 | "default": {
80 | "ENGINE": "django.db.backends.postgresql",
81 | "NAME": DB_NAME,
82 | "USER": DB_USER,
83 | "PASSWORD": DB_PW,
84 | "HOST": "db",
85 | "PORT": DB_PORT,
86 | }
87 | }
88 |
89 | AUTH_PASSWORD_VALIDATORS = [
90 | {
91 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
92 | },
93 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
94 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
95 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
96 | ]
97 |
98 | LANGUAGE_CODE = "en-us"
99 |
100 | TIME_ZONE = "America/Recife"
101 |
102 | USE_I18N = True
103 |
104 | USE_L10N = True
105 |
106 | USE_TZ = True
107 |
108 | LOGIN_URL = "/login/"
109 |
110 | LOGIN_REDIRECT_URL = "/"
111 |
112 | LOGOUT_REDIRECT_URL = "/"
113 |
114 | STATIC_ROOT = (
115 | "/srv/www/marks/static/" if not DEBUG else os.path.join(BASE_DIR, "static")
116 | )
117 |
118 | STATIC_URL = "/static/"
119 |
120 | X_FRAME_OPTIONS = "DENY"
121 |
122 | SECURE_CONTENT_TYPE_NOSNIFF = not DEBUG
123 |
124 | SECURE_BROWSER_XSS_FILTER = not DEBUG
125 |
126 | SECURE_SSL_REDIRECT = False
127 |
128 | # SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTOCOL", "https")
129 |
130 | SESSION_COOKIE_SECURE = not DEBUG
131 |
132 | CSRF_COOKIE_SECURE = not DEBUG
133 |
134 | CSRF_COOKIE_HTTPONLY = not DEBUG
135 |
136 | LOGGING = {
137 | "version": 1,
138 | "disable_existing_loggers": False,
139 | "handlers": {"console": {"class": "logging.StreamHandler"}},
140 | "loggers": {
141 | "django": {
142 | "handlers": ["console"],
143 | "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"),
144 | }
145 | },
146 | }
147 |
148 | DEBUG_TOOLBAR_CONFIG = {
149 | 'SHOW_TOOLBAR_CALLBACK': lambda request: DEBUG,
150 | }
151 |
--------------------------------------------------------------------------------
/app/marksapp/forms.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 | from django.forms import ModelForm, CharField, ChoiceField
3 | from django.forms.widgets import TextInput
4 | from django.contrib.auth.models import User
5 | from marksapp.models import Bookmark, Tag, Profile
6 | from marksapp.misc import tag_regex
7 | import marksapp.views
8 | import re
9 |
10 | EMAIL_PLACEHOLDER_STR = "optional! just in case you forget your password"
11 |
12 |
13 | # https://github.com/wagtail/wagtail/issues/130#issuecomment-37180123
14 | # fucking colons...
15 | # hey maybe https://experiencehq.net/blog/better-django-modelform-html for placeholders
16 | class BaseForm(forms.Form):
17 | def __init__(self, *args, **kwargs):
18 | kwargs.setdefault(
19 | "label_suffix", ""
20 | ) # globally override the Django >=1.6 default of ':'
21 | super(BaseForm, self).__init__(*args, **kwargs)
22 |
23 | for field_name in self.fields:
24 | field = self.fields.get(field_name)
25 | if field:
26 | field.widget.attrs.update(
27 | {
28 | #'placeholder': field.label
29 | }
30 | )
31 |
32 |
33 | class BaseModelForm(forms.ModelForm):
34 | def __init__(self, *args, **kwargs):
35 | kwargs.setdefault(
36 | "label_suffix", ""
37 | ) # globally override the Django >=1.6 default of ':'
38 | super(BaseModelForm, self).__init__(*args, **kwargs)
39 |
40 | for field_name in self.fields:
41 | field = self.fields.get(field_name)
42 | if field:
43 | field.widget.attrs.update(
44 | {
45 | #'placeholder': field.label
46 | }
47 | )
48 |
49 |
50 | class CommaTags(TextInput):
51 | template_name = "comma_tags.html"
52 |
53 | def get_context(self, name, value, attrs):
54 | if value:
55 | objects = []
56 | value = ", ".join([tag.name for tag in value])
57 |
58 | context = super(TextInput, self).get_context(name, value, attrs)
59 | return context
60 |
61 |
62 | class BookmarkForm(BaseModelForm):
63 | tags = CharField(widget=CommaTags)
64 |
65 | class Meta:
66 | model = Bookmark
67 | fields = ["url", "name", "tags", "description"]
68 | widgets = {
69 | "description": forms.Textarea(attrs={"rows": 3, "placeholder": "optional"})
70 | }
71 |
72 | def save(self, commit=True, *args, **kwargs):
73 | m = super(BookmarkForm, self).save(commit=False, *args, **kwargs)
74 | form_tags = marksapp.views.tags_strip_split(self.cleaned_data["tags"])
75 | m.save()
76 | self.instance.tags.clear()
77 |
78 | for tag in form_tags:
79 | if re.match(tag_regex, tag):
80 | t = Tag.objects.get_or_create(name=tag)[0]
81 | m.tags.add(t)
82 | else:
83 | print("WRONG")
84 | print(self.instance.tags)
85 | return m
86 |
87 |
88 | class TagForm(BaseModelForm):
89 | class Meta:
90 | model = Tag
91 | fields = ["name"]
92 |
93 | def is_valid(self):
94 | valid = super(TagForm, self).is_valid()
95 |
96 | if not valid:
97 | for f_name in self.errors:
98 | print(f_name)
99 | return valid
100 |
101 | return True
102 |
103 |
104 | class RegistrationForm(BaseForm):
105 | username = CharField(label="Username", required=True)
106 | password = CharField(label="Password", required=True, widget=forms.PasswordInput)
107 | email = CharField(
108 | label="E-mail",
109 | required=False,
110 | widget=forms.TextInput(attrs={"placeholder": EMAIL_PLACEHOLDER_STR}),
111 | )
112 | visibility = ChoiceField(
113 | choices=Profile.visibility_choices,
114 | label="Default visibility",
115 | initial="PB",
116 | widget=forms.RadioSelect(),
117 | )
118 |
119 |
120 | class ProfileForm(BaseModelForm):
121 | class Meta:
122 | model = Profile
123 | fields = ["visibility"]
124 | widgets = {"visibility": forms.RadioSelect()}
125 |
126 |
127 | class UserForm(BaseModelForm):
128 | class Meta:
129 | model = User
130 | fields = ["email"]
131 | widgets = {
132 | "email": forms.TextInput(attrs={"placeholder": EMAIL_PLACEHOLDER_STR})
133 | }
134 |
135 |
136 | class NetscapeForm(forms.Form):
137 | file = forms.FileField()
138 |
139 |
140 | class ImportJsonForm(forms.Form):
141 | file = forms.FileField()
142 |
--------------------------------------------------------------------------------
/app/marksapp/templates/base.html:
--------------------------------------------------------------------------------
1 | {% spaceless %}
2 |
3 |
4 |
5 |