1
0
mirror of https://github.com/aquatix/digimarks.git synced 2025-12-06 22:05:09 +01:00

93 Commits

Author SHA1 Message Date
36370cfad4 Use Javascript camelCase in variables 2025-06-16 22:38:54 +02:00
63a86a7090 Edit/Add bookmark modal 2025-06-16 22:32:43 +02:00
18b40ed485 Tables, template reflowing, docstrings, better index page 2025-06-15 22:07:02 +02:00
fc0c668ba1 Sections 2025-06-13 16:55:56 +02:00
fe360a1c7a Inputs like having a dropshadow too 2025-06-10 22:21:14 +02:00
231620ca3b Use palette colours 2025-06-10 22:19:23 +02:00
925aeba18e Lots of input styling and some argonaut colours 2025-06-10 22:16:11 +02:00
3a566f60ca Support HEAD on root route, for monitoring etc 2025-06-10 16:04:33 +02:00
5adbb8dce0 Default sizes for H1 per specification 2025-06-09 22:08:52 +02:00
284768d9d9 Lighter muted 2025-06-06 17:18:33 +02:00
1229680abe Namespace certain types of variables 2025-06-06 15:36:53 +02:00
4dc46ba3c7 Muted text color and some structuring 2025-06-06 15:28:06 +02:00
3e917efe37 Reworked image cards, added footer layout to digui 2025-05-24 22:38:14 +02:00
343ac01086 Fix for property after refactoring the model 2025-05-13 22:18:41 +02:00
d8c8d87568 Update bookmark and autofill certain fields 2025-05-13 19:01:13 +02:00
22e73bc991 Explicit button colours for these themes 2025-05-13 18:37:05 +02:00
f750a547d3 Sort bookmarks correctly on load 2025-05-12 22:58:32 +02:00
d96bde9bb1 Tags/chips 2025-05-12 22:50:56 +02:00
70979b3350 Support for filtering on tags 2025-05-12 16:13:28 +02:00
1836eedfe8 Better loading indicator 2025-05-11 09:19:53 +02:00
5efc12e698 blip-preventer 2025-05-11 09:19:51 +02:00
eb8e764a61 Card layouting, introduced Fontawesome icons 2025-05-09 23:13:39 +02:00
d9b1d99e32 20240331: just use ruff, and document the other deps 2025-05-09 20:44:50 +02:00
e7021cdf3d silo improvements, fix for nebula 2025-05-09 17:03:08 +02:00
45d8743b65 Moved themes to digui 2025-05-09 16:35:39 +02:00
4d5aae7881 Removed light/dark modes, introduced list of themes 2025-05-09 15:34:29 +02:00
3f5d43b0fa Theming trials 2025-05-09 14:58:42 +02:00
d9e8ca76fe Initial theme toggler 2025-05-08 19:13:17 +02:00
faae900b06 Configure for server 2025-05-07 16:47:27 +02:00
0f7e280bb3 Show bookmarks in a grid 2025-05-06 23:15:09 +02:00
d28228fc03 Better (semantic) HTML structure, load indicator in nav 2025-05-06 16:59:58 +02:00
5d71250408 Toggle between showing the bookmarks and a list of the tags 2025-05-06 16:21:56 +02:00
d073bc079a Started fleshing out digui 2025-05-06 15:57:27 +02:00
72b131c77f Better sort buttons 2025-05-06 15:40:58 +02:00
5943265687 Ignore PyCharm config 2025-05-06 14:49:18 +02:00
3642753266 Only fetch new bookmarks and tags when cache is not up-to-date 2025-05-06 14:44:25 +02:00
127284716e Also order z-a 2025-05-06 08:02:53 +02:00
40a0f773c4 Navigation stub, sortable bookmarks 2025-05-05 21:55:28 +02:00
b364f865b1 Show and filter/search bookmarks 2025-05-05 20:08:34 +02:00
324c77f985 Refactoring to simpler html with alpine.js 2025-05-04 22:39:37 +02:00
9b11ae65c3 Moved templates and static into the project dir 2025-05-04 21:40:31 +02:00
85639b128f Public tags, cleanups, config for templates 2025-05-04 21:38:58 +02:00
3369ee3b89 Tabula rasa rewrite with SQLModel 2025-05-04 19:05:54 +02:00
3a17b5aa63 More refactoring for sqlalchemy 2025-05-04 14:25:52 +02:00
bf36d726ac Merge branch 'fastapi' of github.com:aquatix/digimarks into fastapi 2025-05-04 13:18:12 +02:00
8e8d3bfcdc Better docstring 2025-05-04 13:18:06 +02:00
d89e8be72e Formatting fixes 2025-04-29 21:18:19 +02:00
e4d303a72b More refactoring 2025-02-02 22:06:23 +01:00
cfcca3e161 Use preferred name of the library 2024-08-08 17:31:29 +02:00
533e21141f More refactoring 2024-08-08 17:30:15 +02:00
c695972a94 Stubs for the rewrite 2024-08-08 15:00:09 +02:00
8968b47ddd Check on and fixed imports with ruff 2024-03-31 21:31:06 +02:00
7cbde4cc37 Use async httpx instead of requests to do external calls 2024-03-31 21:30:28 +02:00
29c3ccca59 Use async httpx instead of requests to do external calls 2024-03-31 21:23:36 +02:00
71b0543771 Introducing pytest 2024-03-31 21:23:12 +02:00
374db18181 Better naming, docstrings and more 2024-02-11 21:25:18 +01:00
0351760d3f Ruff config and such 2024-02-11 21:24:46 +01:00
a3cdccdb8a More refactoring 2023-12-12 22:46:19 +01:00
65af6b5762 Unnecessary exclude 2023-12-12 22:44:44 +01:00
81825379cb Docstring fixes according to ruff 2023-11-02 13:26:58 +01:00
f6f129d67c Refactoring to fastapi, reformatting with ruff 2023-10-30 21:51:55 +01:00
30bd835e41 Refactoring to FastAPI 2023-08-01 22:42:24 +02:00
7e397f9d2b Refactoring to FastAPI and SQLAlchemy 2023-07-30 21:19:51 +02:00
96e7ef16d4 New project structure, in line with modern Python projects 2023-07-29 16:05:05 +02:00
0b49186559 First work on refactoring to fastapi 2022-08-04 13:27:38 +02:00
3092f83c8b Serve the search JS with the correct mimetype 2022-07-23 11:10:55 +02:00
a27787e956 Dependency updates 2022-07-23 11:10:50 +02:00
21800911db Security: lxml; more updates 2022-07-13 18:45:34 +02:00
20a3d9f838 Latest everything 2022-04-23 09:09:50 +02:00
0ab3bd2263 Security: lxml; more updates 2021-12-13 21:28:43 +01:00
32b074b859 Security: urllib3; more updates 2021-07-06 15:10:18 +02:00
5789bbe006 Latest everything 2021-03-25 10:16:17 +01:00
2ef7358ac7 Jinja2 security fix; new-style pip-tools requirements 2021-02-02 08:51:18 +01:00
d1e590390c lxml security update and more 2020-12-02 09:19:05 +01:00
7a1bc11004 Replaced deprecated AtomFeed by feedgen; some dep updates 2020-07-28 14:52:44 +02:00
315c664fcc Document the RapidAPI (MashApe) key for favicons 2020-05-06 14:04:33 +02:00
db5944cec4 Werkzeug 1.0 has deprecated the AtomFeed 2020-05-06 14:02:09 +02:00
Michiel Scholten
becb734d17 Merge pull request #19 from mnishig/doc-MASHAPE_API_KEY
Add description for 'MASHAPE_API_KEY'
2020-05-06 13:56:57 +02:00
64ee0856c5 (Security) bumps 2020-05-05 19:59:27 +02:00
Masahide Nishihama
6c2be3070e add description 'MASHAPE_API_KEY' 2020-04-28 10:30:52 +09:00
426c2eda68 RapidAPI all the things 2020-04-16 13:34:10 +02:00
b0e53d4a85 Back to a version that actually works 2020-03-20 20:47:58 +01:00
1f69d9e53f Lots of updates 2020-03-20 20:44:28 +01:00
Michiel Scholten
fc27d9f186 Merge pull request #17 from aquatix/snyk-fix-e8cd4803e12d5fe482405c22b3c5b385
[Snyk] Security upgrade urllib3 from 1.25.3 to 1.25.8
2020-03-07 09:22:34 +01:00
snyk-bot
6341b384bf fix: requirements.txt to reduce vulnerabilities
The following vulnerabilities are fixed by pinning transitive dependencies:
- https://snyk.io/vuln/SNYK-PYTHON-URLLIB3-559452
2020-03-07 07:48:53 +00:00
f698ebfe18 Security updates for Flask&Werkzeug, more version bumps 2019-08-12 19:27:49 +02:00
9f736ffe82 Move to RapidAPI url for favicons API 2019-07-27 10:45:04 +02:00
e1a45a21b5 urllib3 security update, more version bumps 2019-05-04 10:41:49 +02:00
9492d26511 Security update for urllib3, more bumps 2019-04-19 21:08:49 +02:00
f7762ebc7b Updates, amongst which a security update for Jinja2 2019-04-10 12:07:14 +02:00
1c4bc61494 Minor version bumps 2019-02-28 12:48:44 +01:00
2a87e0aa1f Dependency version bumps 2019-02-11 13:23:03 +01:00
0a24c7d170 (Security) updates 2018-10-18 20:56:51 +02:00
38 changed files with 2677 additions and 1216 deletions

4
.codacy.yaml Normal file
View File

@@ -0,0 +1,4 @@
---
exclude_paths:
- "example_config/**"
- "docs/source/**"

3
.gitignore vendored
View File

@@ -88,6 +88,9 @@ ENV/
# Rope project settings
.ropeproject
# JetBrains PyCharm/Idea
.idea
# vim
*.swp

View File

@@ -22,6 +22,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Settings through Pydantic Settings
### Changed
- Moved from Flask to FastAPI
- Moved from Peewee ORM to SQLAlchemy
### Removed
- Jinja2 templates
### Fixed
## [1.2.0] - Flask is Fine (2023-07-30)
### Added
- 'lightblue' theme
- 'black amoled' theme

View File

@@ -39,11 +39,25 @@ Usage / example configuration
Copy ``settings.py`` from example_config to the parent directory and
configure to your needs (*at the least* change the value of `SYSTEMKEY`).
Do not forget to fill in the `MASHAPE_API_KEY` value, which you [can request on the RapidAPI website](https://rapidapi.com/realfavicongenerator/api/realfavicongenerator).
Run digimarks as a service under nginx or apache and call the appropriate
url's when wanted.
Url's are of the form https://marks.example.com/<userkey>/<action>
digimarks can also be run from the command line: ``uvicorn digimarks:app --reload``
Be sure to export/set the ``SECRETKEY`` environment variable before running, it's needed for some management URI's.
Run ``gunicorn -k uvicorn.workers.UvicornWorker`` for production. For an example of how to set up a server `see this article <https://www.slingacademy.com/article/deploying-fastapi-on-ubuntu-with-nginx-and-lets-encrypt/>`_ with configuration for nginx, uvicorn, systemd, security and such.
The RQ background worker can be run from the command line: ``rq worker --with-scheduler``
Url's are of the form https://hook.example.com/app/<appkey>/<triggerkey>
API documentation is auto-generated, and can be browsed at https://hook.example.com/docs
Bookmarklet
~~~~~~~~~~~
@@ -72,8 +86,9 @@ If you for whatever reason would lose this user key, just either look on the con
Server configuration
~~~~~~~~~~~~~~~~~~~~
* `vhost for Apache2.4`_
* `uwsgi.ini`_
* `systemd for digimarks API`_ which uses the `gunicorn config`_
* `nginx for digimarks API`_
* `more config`_
What's new?
@@ -89,7 +104,6 @@ Attributions
.. _digimarks: https://github.com/aquatix/digimarks
.. _webhook: https://en.wikipedia.org/wiki/Webhook
.. |PyPI version| image:: https://img.shields.io/pypi/v/digimarks.svg
:target: https://pypi.python.org/pypi/digimarks/
.. |PyPI license| image:: https://img.shields.io/github/license/aquatix/digimarks.svg
@@ -105,3 +119,6 @@ Attributions
.. _uwsgi.ini: https://github.com/aquatix/digimarks/blob/master/example_config/uwsgi.ini
.. _Changelog: https://github.com/aquatix/digimarks/blob/master/CHANGELOG.md
.. _Freepik: http://www.flaticon.com/free-icon/letter-m_2041
.. _systemd for digimarks API: https://github.com/aquatix/digimarks/blob/master/example_config/systemd/digimarks.service
.. _gunicorn config: https://github.com/aquatix/digimarks/blob/master/example_config/gunicorn_digimarks_conf.py
.. _more config: https://github.com/aquatix/digimarks/tree/master/example_config

View File

@@ -1,663 +0,0 @@
from __future__ import print_function
import datetime
import gzip
import hashlib
import os
import shutil
import sys
import bs4
import requests
from flask import (Flask, abort, jsonify, redirect, render_template, request,
url_for)
from peewee import * # noqa
from werkzeug.contrib.atom import AtomFeed
from digimarks import models, themes, views
try:
# Python 3
from urllib.parse import urljoin, urlparse, urlunparse
except ImportError:
# Python 2
from urlparse import urljoin, urlparse, urlunparse
DIGIMARKS_USER_AGENT = 'digimarks/1.2.0-dev'
try:
import settings
except ImportError:
print('Copy settings_example.py to settings.py and set the configuration to your own preferences')
sys.exit(1)
# app configuration
APP_ROOT = os.path.dirname(os.path.realpath(__file__))
MEDIA_ROOT = os.path.join(APP_ROOT, 'static')
MEDIA_URL = '/static/'
# create our flask app and a database wrapper
app = Flask(__name__)
app.config.from_object(__name__)
# Strip unnecessary whitespace due to jinja2 codeblocks
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True
# set custom url for the app, for example '/bookmarks'
try:
app.config['APPLICATION_ROOT'] = settings.APPLICATION_ROOT
except AttributeError:
pass
# Cache the tags
all_tags = {}
usersettings = {}
def get_cached_tags(userkey):
""" Fail-safe way to get the cached tags for `userkey` """
try:
return all_tags[userkey]
except KeyError:
return []
def get_theme(userkey):
try:
usertheme = usersettings[userkey]['theme']
return themes[usertheme]
except KeyError:
return themes[DEFAULT_THEME] # default
def make_external(url):
return urljoin(request.url_root, url)
def _find_bookmarks(userkey, filter_text):
return Bookmark.select().where(
Bookmark.userkey == userkey,
(
Bookmark.title.contains(filter_text) |
Bookmark.url.contains(filter_text) |
Bookmark.note.contains(filter_text)
),
Bookmark.status == Bookmark.VISIBLE
).order_by(Bookmark.created_date.desc())
@app.errorhandler(404)
def page_not_found(e):
theme = themes[DEFAULT_THEME]
return render_template('404.html', error=e, theme=theme), 404
@app.route('/')
def index():
""" Homepage, point visitors to project page """
theme = themes[DEFAULT_THEME]
return render_template('index.html', theme=theme)
def get_bookmarks(userkey, filtermethod=None, sortmethod=None):
""" User homepage, list their bookmarks, optionally filtered and/or sorted """
#return object_list('bookmarks.html', Bookmark.select())
#user = User.select(key=userkey)
#if user:
# bookmarks = Bookmark.select(User=user)
# return render_template('bookmarks.html', bookmarks)
#else:
# abort(404)
message = request.args.get('message')
bookmarktags = get_cached_tags(userkey)
filter_text = ''
if request.form:
filter_text = request.form['filter_text']
filter_starred = False
if filtermethod and filtermethod.lower() == 'starred':
filter_starred = True
filter_broken = False
if filtermethod and filtermethod.lower() == 'broken':
filter_broken = True
filter_note = False
if filtermethod and filtermethod.lower() == 'note':
filter_note = True
if filter_text:
bookmarks = _find_bookmarks(userkey, filter_text)
elif filter_starred:
bookmarks = Bookmark.select().where(Bookmark.userkey == userkey,
Bookmark.starred).order_by(Bookmark.created_date.desc())
elif filter_broken:
bookmarks = Bookmark.select().where(Bookmark.userkey == userkey,
Bookmark.http_status != 200).order_by(Bookmark.created_date.desc())
elif filter_note:
bookmarks = Bookmark.select().where(Bookmark.userkey == userkey,
Bookmark.note != '').order_by(Bookmark.created_date.desc())
else:
bookmarks = Bookmark.select().where(
Bookmark.userkey == userkey,
Bookmark.status == Bookmark.VISIBLE
).order_by(Bookmark.created_date.desc())
return bookmarks, bookmarktags, filter_text, message
@app.route('/<userkey>', methods=['GET', 'POST'])
@app.route('/<userkey>/filter/<filtermethod>', methods=['GET', 'POST'])
@app.route('/<userkey>/sort/<sortmethod>', methods=['GET', 'POST'])
@app.route('/<userkey>/<show_as>', methods=['GET', 'POST'])
@app.route('/<userkey>/<show_as>/filter/<filtermethod>', methods=['GET', 'POST'])
@app.route('/<userkey>/<show_as>/sort/<sortmethod>', methods=['GET', 'POST'])
def bookmarks_page(userkey, filtermethod=None, sortmethod=None, show_as='cards'):
bookmarks, bookmarktags, filter_text, message = get_bookmarks(userkey, filtermethod, sortmethod)
theme = get_theme(userkey)
return render_template(
'bookmarks.html',
bookmarks=bookmarks,
userkey=userkey,
tags=bookmarktags,
filter_text=filter_text,
message=message,
theme=theme,
editable=True, # bookmarks can be edited
showtags=True, # tags should be shown with the bookmarks
filtermethod=filtermethod,
sortmethod=sortmethod,
show_as=show_as, # show list of bookmarks instead of cards
)
@app.route('/<userkey>/js')
def bookmarks_js(userkey):
""" Return list of bookmarks with their favicons, to be used for autocompletion """
bookmarks = Bookmark.select().where(
Bookmark.userkey == userkey,
Bookmark.status == Bookmark.VISIBLE
).order_by(Bookmark.created_date.desc())
return render_template(
'bookmarks.js',
bookmarks=bookmarks
)
@app.route('/r/<userkey>/<urlhash>')
def bookmark_redirect(userkey, urlhash):
""" Securely redirect a bookmark to its url, stripping referrer (if browser plays nice) """
# @TODO: add counter to this bookmark
try:
bookmark = Bookmark.get(
Bookmark.url_hash == urlhash,
Bookmark.userkey == userkey,
Bookmark.status == Bookmark.VISIBLE
)
except Bookmark.DoesNotExist:
abort(404)
return render_template('redirect.html', url=bookmark.url)
@app.route('/api/v1/<userkey>', methods=['GET', 'POST'])
@app.route('/api/v1/<userkey>/filter/<filtermethod>', methods=['GET', 'POST'])
@app.route('/api/v1/<userkey>/sort/<sortmethod>', methods=['GET', 'POST'])
def bookmarks_json(userkey, filtermethod=None, sortmethod=None):
bookmarks, bookmarktags, filter_text, message = get_bookmarks(userkey, filtermethod, sortmethod)
bookmarkslist = [i.serialize for i in bookmarks]
the_data = {
'bookmarks': bookmarkslist,
'tags': bookmarktags,
'filter_text': filter_text,
'message': message,
'userkey': userkey,
}
return jsonify(the_data)
@app.route('/api/v1/<userkey>/<urlhash>')
def bookmark_json(userkey, urlhash):
""" Serialise bookmark to json """
try:
bookmark = Bookmark.get(
Bookmark.url_hash == urlhash,
Bookmark.userkey == userkey,
Bookmark.status == Bookmark.VISIBLE
)
return jsonify(bookmark.to_dict())
except Bookmark.DoesNotExist:
return jsonify({'message': 'Bookmark not found', 'status': 'error 404'})
@app.route('/api/v1/<userkey>/search/<filter_text>')
def search_bookmark_titles_json(userkey, filter_text):
""" Serialise bookmark to json """
bookmarks = _find_bookmarks(userkey, filter_text)
result = []
for bookmark in bookmarks:
result.append(bookmark.to_dict())
return jsonify(result)
@app.route('/<userkey>/<urlhash>')
@app.route('/<userkey>/<urlhash>/edit')
def editbookmark(userkey, urlhash):
""" Bookmark edit form """
# bookmark = getbyurlhash()
try:
bookmark = Bookmark.get(Bookmark.url_hash == urlhash, Bookmark.userkey == userkey)
except Bookmark.DoesNotExist:
abort(404)
message = request.args.get('message')
tags = get_cached_tags(userkey)
if not bookmark.note:
# Workaround for when an existing bookmark has a null note
bookmark.note = ''
theme = get_theme(userkey)
return render_template(
'edit.html',
action='Edit bookmark',
userkey=userkey,
bookmark=bookmark,
message=message,
formaction='edit',
tags=tags,
theme=theme
)
@app.route('/<userkey>/add')
def addbookmark(userkey):
""" Bookmark add form """
url = request.args.get('url')
if not url:
url = ''
if request.args.get('referrer'):
url = request.referrer
bookmark = Bookmark(title='', url=url, tags='')
message = request.args.get('message')
tags = get_cached_tags(userkey)
theme = get_theme(userkey)
return render_template(
'edit.html',
action='Add bookmark',
userkey=userkey,
bookmark=bookmark,
tags=tags,
message=message,
theme=theme
)
def updatebookmark(userkey, urlhash=None):
""" Add (no urlhash) or edit (urlhash is set) a bookmark """
title = request.form.get('title')
url = request.form.get('url')
tags = request.form.get('tags')
note = request.form.get('note')
starred = False
if request.form.get('starred'):
starred = True
strip_params = False
if request.form.get('strip'):
strip_params = True
if url and not urlhash:
# New bookmark
bookmark, created = Bookmark.get_or_create(url=url, userkey=userkey)
if not created:
message = 'Existing bookmark, did not overwrite with new values'
return redirect(url_for('editbookmark', userkey=userkey, urlhash=bookmark.url_hash, message=message))
elif url:
# Existing bookmark, get from DB
bookmark = Bookmark.get(Bookmark.userkey == userkey, Bookmark.url_hash == urlhash)
# Editing this bookmark, set modified_date to now
bookmark.modified_date = datetime.datetime.now()
else:
# No url was supplied, abort. @TODO: raise exception?
return None
bookmark.title = title
if strip_params:
url = Bookmark.strip_url_params(url)
bookmark.url = url
bookmark.starred = starred
bookmark.set_tags(tags)
bookmark.note = note
bookmark.set_hash()
#bookmark.fetch_image()
if not title:
# Title was empty, automatically fetch it from the url, will also update the status code
bookmark.set_title_from_source()
else:
bookmark.set_status_code()
if bookmark.http_status == 200 or bookmark.http_status == 202:
try:
bookmark.set_favicon()
except IOError:
# Icon file could not be saved possibly, don't bail completely
pass
bookmark.save()
return bookmark
@app.route('/<userkey>/adding', methods=['GET', 'POST'])
#@app.route('/<userkey>/adding')
def addingbookmark(userkey):
""" Add the bookmark from form submit by /add """
tags = get_cached_tags(userkey)
if request.method == 'POST':
bookmark = updatebookmark(userkey)
if not bookmark:
return redirect(url_for('addbookmark', userkey=userkey, message='No url provided', tags=tags))
if type(bookmark).__name__ == 'Response':
return bookmark
all_tags[userkey] = get_tags_for_user(userkey)
return redirect(url_for('editbookmark', userkey=userkey, urlhash=bookmark.url_hash))
return redirect(url_for('addbookmark', userkey=userkey, tags=tags))
@app.route('/<userkey>/<urlhash>/editing', methods=['GET', 'POST'])
def editingbookmark(userkey, urlhash):
""" Edit the bookmark from form submit """
if request.method == 'POST':
bookmark = updatebookmark(userkey, urlhash=urlhash)
all_tags[userkey] = get_tags_for_user(userkey)
return redirect(url_for('editbookmark', userkey=userkey, urlhash=bookmark.url_hash))
return redirect(url_for('editbookmark', userkey=userkey, urlhash=urlhash))
@app.route('/<userkey>/<urlhash>/delete', methods=['GET', 'POST'])
def deletingbookmark(userkey, urlhash):
""" Delete the bookmark from form submit by <urlhash>/delete """
query = Bookmark.update(status=Bookmark.DELETED).where(Bookmark.userkey == userkey, Bookmark.url_hash == urlhash)
query.execute()
query = Bookmark.update(deleted_date=datetime.datetime.now()).where(
Bookmark.userkey == userkey,
Bookmark.url_hash == urlhash
)
query.execute()
message = 'Bookmark deleted. <a href="{}">Undo deletion</a>'.format(url_for(
'undeletebookmark',
userkey=userkey,
urlhash=urlhash
))
all_tags[userkey] = get_tags_for_user(userkey)
return redirect(url_for('bookmarks_page', userkey=userkey, message=message))
@app.route('/<userkey>/<urlhash>/undelete')
def undeletebookmark(userkey, urlhash):
""" Undo deletion of the bookmark identified by urlhash """
query = Bookmark.update(status=Bookmark.VISIBLE).where(Bookmark.userkey == userkey, Bookmark.url_hash == urlhash)
query.execute()
message = 'Bookmark restored'
all_tags[userkey] = get_tags_for_user(userkey)
return redirect(url_for('bookmarks_page', userkey=userkey, message=message))
@app.route('/<userkey>/tags')
def tags_page(userkey):
""" Overview of all tags used by user """
tags = get_cached_tags(userkey)
#publictags = PublicTag.select().where(Bookmark.userkey == userkey)
alltags = []
for tag in tags:
try:
publictag = PublicTag.get(PublicTag.userkey == userkey, PublicTag.tag == tag)
except PublicTag.DoesNotExist:
publictag = None
total = Bookmark.select().where(
Bookmark.userkey == userkey,
Bookmark.tags.contains(tag),
Bookmark.status == Bookmark.VISIBLE
).count()
alltags.append({'tag': tag, 'publictag': publictag, 'total': total})
totaltags = len(alltags)
totalbookmarks = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.status == Bookmark.VISIBLE).count()
totalpublic = PublicTag.select().where(PublicTag.userkey == userkey).count()
totalstarred = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.starred).count()
totaldeleted = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.status == Bookmark.DELETED).count()
totalnotes = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.note != '').count()
totalhttperrorstatus = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.http_status != 200).count()
theme = get_theme(userkey)
return render_template(
'tags.html',
tags=alltags,
totaltags=totaltags,
totalpublic=totalpublic,
totalbookmarks=totalbookmarks,
totaldeleted=totaldeleted,
totalstarred=totalstarred,
totalhttperrorstatus=totalhttperrorstatus,
totalnotes=totalnotes,
userkey=userkey,
theme=theme
)
@app.route('/<userkey>/tag/<tag>')
def tag_page(userkey, tag):
""" Overview of all bookmarks with a certain tag """
bookmarks = Bookmark.select().where(
Bookmark.userkey == userkey,
Bookmark.tags.contains(tag),
Bookmark.status == Bookmark.VISIBLE
).order_by(Bookmark.created_date.desc())
tags = get_cached_tags(userkey)
pageheader = 'tag: ' + tag
message = request.args.get('message')
try:
publictag = PublicTag.get(PublicTag.userkey == userkey, PublicTag.tag == tag)
except PublicTag.DoesNotExist:
publictag = None
theme = get_theme(userkey)
return render_template(
'bookmarks.html',
bookmarks=bookmarks,
userkey=userkey,
tags=tags,
tag=tag,
publictag=publictag,
action=pageheader,
message=message,
theme=theme,
editable=True,
showtags=True,
)
def get_publictag(tagkey):
""" Return tag and bookmarks in this public tag collection """
this_tag = PublicTag.get(PublicTag.tagkey == tagkey)
bookmarks = Bookmark.select().where(
Bookmark.userkey == this_tag.userkey,
Bookmark.tags.contains(this_tag.tag),
Bookmark.status == Bookmark.VISIBLE
).order_by(Bookmark.created_date.desc())
return this_tag, bookmarks
@app.route('/pub/<tagkey>')
def publictag_page(tagkey):
""" Read-only overview of the bookmarks in the userkey/tag of this PublicTag """
#this_tag = get_object_or_404(PublicTag.select().where(PublicTag.tagkey == tagkey))
try:
this_tag, bookmarks = get_publictag(tagkey)
theme = themes[DEFAULT_THEME]
return render_template(
'publicbookmarks.html',
bookmarks=bookmarks,
tag=this_tag.tag,
action=this_tag.tag,
tagkey=tagkey,
theme=theme
)
except PublicTag.DoesNotExist:
abort(404)
@app.route('/api/v1/pub/<tagkey>')
def publictag_json(tagkey):
""" json representation of the Read-only overview of the bookmarks in the userkey/tag of this PublicTag """
try:
this_tag, bookmarks = get_publictag(tagkey)
result = {
#'tag': this_tag,
'tagkey': tagkey,
'count': len(bookmarks),
'items': [],
}
for bookmark in bookmarks:
result['items'].append(bookmark.to_dict())
return jsonify(result)
except PublicTag.DoesNotExist:
abort(404)
@app.route('/pub/<tagkey>/feed')
def publictag_feed(tagkey):
""" rss/atom representation of the Read-only overview of the bookmarks in the userkey/tag of this PublicTag """
try:
this_tag = PublicTag.get(PublicTag.tagkey == tagkey)
bookmarks = Bookmark.select().where(
Bookmark.userkey == this_tag.userkey,
Bookmark.tags.contains(this_tag.tag),
Bookmark.status == Bookmark.VISIBLE
)
feed = AtomFeed(this_tag.tag, feed_url=request.url, url=make_external(url_for('publictag_page', tagkey=tagkey)))
for bookmark in bookmarks:
updated_date = bookmark.modified_date
if not bookmark.modified_date:
updated_date = bookmark.created_date
bookmarktitle = '{} (no title)'.format(bookmark.url)
if bookmark.title:
bookmarktitle = bookmark.title
feed.add(
bookmarktitle,
content_type='html',
author='digimarks',
url=bookmark.url,
updated=updated_date,
published=bookmark.created_date
)
return feed.get_response()
except PublicTag.DoesNotExist:
abort(404)
@app.route('/<userkey>/<tag>/makepublic', methods=['GET', 'POST'])
def addpublictag(userkey, tag):
#user = get_object_or_404(User.get(User.key == userkey))
try:
User.get(User.key == userkey)
except User.DoesNotExist:
abort(404)
try:
publictag = PublicTag.get(PublicTag.userkey == userkey, PublicTag.tag == tag)
except PublicTag.DoesNotExist:
publictag = None
if not publictag:
newpublictag = PublicTag()
newpublictag.generate_key()
newpublictag.userkey = userkey
newpublictag.tag = tag
newpublictag.save()
message = 'Public link to this tag created'
return redirect(url_for('tag_page', userkey=userkey, tag=tag, message=message))
message = 'Public link already existed'
return redirect(url_for('tag_page', userkey=userkey, tag=tag, message=message))
@app.route('/<userkey>/<tag>/removepublic/<tagkey>', methods=['GET', 'POST'])
def removepublictag(userkey, tag, tagkey):
q = PublicTag.delete().where(PublicTag.userkey == userkey, PublicTag.tag == tag, PublicTag.tagkey == tagkey)
q.execute()
message = 'Public link deleted'
return redirect(url_for('tag_page', userkey=userkey, tag=tag, message=message))
@app.route('/<systemkey>/adduser')
def adduser(systemkey):
""" Add user endpoint, convenience """
if systemkey == settings.SYSTEMKEY:
newuser = User()
newuser.generate_key()
newuser.username = 'Nomen Nescio'
newuser.save()
all_tags[newuser.key] = []
return redirect('/{}'.format(newuser.key.decode("utf-8")), code=302)
else:
abort(404)
@app.route('/<systemkey>/refreshfavicons')
def refreshfavicons(systemkey):
""" Add user endpoint, convenience """
if systemkey == settings.SYSTEMKEY:
bookmarks = Bookmark.select()
for bookmark in bookmarks:
if bookmark.favicon:
try:
filename = os.path.join(MEDIA_ROOT, 'favicons/' + bookmark.favicon)
os.remove(filename)
except OSError as e:
print(e)
bookmark.set_favicon()
return redirect('/')
else:
abort(404)
@app.route('/<systemkey>/findmissingfavicons')
def findmissingfavicons(systemkey):
""" Add user endpoint, convenience """
if systemkey == settings.SYSTEMKEY:
bookmarks = Bookmark.select()
for bookmark in bookmarks:
try:
if not bookmark.favicon or not os.path.isfile(os.path.join(MEDIA_ROOT, 'favicons/' + bookmark.favicon)):
# This favicon is missing
# Clear favicon, so fallback can be used instead of showing a broken image
bookmark.favicon = None
bookmark.save()
# Try to fetch and save new favicon
bookmark.set_favicon()
bookmark.save()
except OSError as e:
print(e)
return redirect('/')
else:
abort(404)
# Initialisation == create the bookmark, user and public tag tables if they do not exist
Bookmark.create_table(True)
User.create_table(True)
PublicTag.create_table(True)
users = User.select()
print('Current user keys:')
for user in users:
all_tags[user.key] = get_tags_for_user(user.key)
usersettings[user.key] = {'theme': user.theme}
print(user.key)
# Run when called standalone
if __name__ == '__main__':
# run the application
app.run(host='0.0.0.0', port=9999, debug=True)

View File

@@ -1,321 +0,0 @@
"""digimarks data models and accompanying convenience functions"""
import binascii
import os
import datetime
from peewee import * # noqa
from . import themes
try:
# Python 3
from urllib.parse import urljoin, urlparse, urlunparse
except ImportError:
# Python 2
from urlparse import urljoin, urlparse, urlunparse
DATABASE_PATH = os.path.dirname(os.path.realpath(__file__))
if 'DIGIMARKS_DB_PATH' in os.environ:
DATABASE_PATH = os.environ['DIGIMARKS_DB_PATH']
database = SqliteDatabase(os.path.join(DATABASE_PATH, 'bookmarks.db'))
def ifilterfalse(predicate, iterable):
# ifilterfalse(lambda x: x%2, range(10)) --> 0 2 4 6 8
if predicate is None:
predicate = bool
for x in iterable:
if not predicate(x):
yield x
def unique_everseen(iterable, key=None):
"List unique elements, preserving order. Remember all elements ever seen."
# unique_everseen('AAAABBBCCDAABBB') --> A B C D
# unique_everseen('ABBCcAD', str.lower) --> A B C D
seen = set()
seen_add = seen.add
if key is None:
for element in ifilterfalse(seen.__contains__, iterable):
seen_add(element)
yield element
else:
for element in iterable:
k = key(element)
if k not in seen:
seen_add(k)
yield element
def clean_tags(tags_list):
tags_res = [x.strip() for x in tags_list]
tags_res = list(unique_everseen(tags_res))
tags_res.sort()
if tags_res and tags_res[0] == '':
del tags_res[0]
return tags_res
magic_dict = {
b"\x1f\x8b\x08": "gz",
b"\x42\x5a\x68": "bz2",
b"\x50\x4b\x03\x04": "zip"
}
max_len = max(len(x) for x in magic_dict)
def file_type(filename):
with open(filename, "rb") as f:
file_start = f.read(max_len)
for magic, filetype in magic_dict.items():
if file_start.startswith(magic):
return filetype
return "no match"
class BaseModel(Model):
class Meta:
database = database
class User(BaseModel):
""" User account """
username = CharField()
key = CharField()
theme = CharField(default=themes.DEFAULT_THEME)
created_date = DateTimeField(default=datetime.datetime.now)
def generate_key(self):
""" Generate userkey """
self.key = binascii.hexlify(os.urandom(24))
return self.key
class Bookmark(BaseModel):
""" Bookmark instance, connected to User """
# Foreign key to User
userkey = CharField()
title = CharField(default='')
url = CharField()
note = TextField(default='')
#image = CharField(default='')
url_hash = CharField(default='')
tags = CharField(default='')
starred = BooleanField(default=False)
# Website (domain) favicon
favicon = CharField(null=True)
# Status code: 200 is OK, 404 is not found, for example (showing an error)
HTTP_CONNECTIONERROR = 0
HTTP_OK = 200
HTTP_ACCEPTED = 202
HTTP_MOVEDTEMPORARILY = 304
HTTP_NOTFOUND = 404
http_status = IntegerField(default=200)
redirect_uri = None
created_date = DateTimeField(default=datetime.datetime.now)
modified_date = DateTimeField(null=True)
deleted_date = DateTimeField(null=True)
# Bookmark status; deleting doesn't remove from DB
VISIBLE = 0
DELETED = 1
status = IntegerField(default=VISIBLE)
class Meta:
ordering = (('created_date', 'desc'),)
def set_hash(self):
""" Generate hash """
self.url_hash = hashlib.md5(self.url.encode('utf-8')).hexdigest()
def set_title_from_source(self):
""" Request the title by requesting the source url """
try:
result = requests.get(self.url, headers={'User-Agent': DIGIMARKS_USER_AGENT})
self.http_status = result.status_code
except:
# For example 'MissingSchema: Invalid URL 'abc': No schema supplied. Perhaps you meant http://abc?'
self.http_status = 404
if self.http_status == 200 or self.http_status == 202:
html = bs4.BeautifulSoup(result.text, 'html.parser')
try:
self.title = html.title.text.strip()
except AttributeError:
self.title = ''
return self.title
def set_status_code(self):
""" Check the HTTP status of the url, as it might not exist for example """
try:
result = requests.head(self.url, headers={'User-Agent': DIGIMARKS_USER_AGENT})
self.http_status = result.status_code
except requests.ConnectionError:
self.http_status = self.HTTP_CONNECTIONERROR
return self.http_status
def _set_favicon_with_iconsbetterideaorg(self, domain):
""" Fetch favicon for the domain """
fileextension = '.png'
meta = requests.head(
'http://icons.better-idea.org/icon?size=60&url=' + domain,
allow_redirects=True,
headers={'User-Agent': DIGIMARKS_USER_AGENT}
)
if meta.url[-3:].lower() == 'ico':
fileextension = '.ico'
response = requests.get(
'http://icons.better-idea.org/icon?size=60&url=' + domain,
stream=True,
headers={'User-Agent': DIGIMARKS_USER_AGENT}
)
filename = os.path.join(MEDIA_ROOT, 'favicons/' + domain + fileextension)
with open(filename, 'wb') as out_file:
shutil.copyfileobj(response.raw, out_file)
del response
filetype = file_type(filename)
if filetype == 'gz':
# decompress
orig = gzip.GzipFile(filename, 'rb')
origcontent = orig.read()
orig.close()
os.remove(filename)
with open(filename, 'wb') as new:
new.write(origcontent)
self.favicon = domain + fileextension
def _set_favicon_with_realfavicongenerator(self, domain):
""" Fetch favicon for the domain """
response = requests.get(
'https://realfavicongenerator.p.mashape.com/favicon/icon?platform=android_chrome&site=' + domain,
stream=True,
headers={'User-Agent': DIGIMARKS_USER_AGENT, 'X-Mashape-Key': settings.MASHAPE_API_KEY}
)
if response.status_code == 404:
# Fall back to desktop favicon
response = requests.get(
'https://realfavicongenerator.p.mashape.com/favicon/icon?platform=desktop&site=' + domain,
stream=True,
headers={'User-Agent': DIGIMARKS_USER_AGENT, 'X-Mashape-Key': settings.MASHAPE_API_KEY}
)
# Debug for the moment
print(domain)
print(response.headers)
if 'Content-Length' in response.headers and response.headers['Content-Length'] == '0':
# No favicon found, likely
print('Skipping this favicon, needs fallback')
return
# Default to 'image/png'
fileextension = '.png'
if response.headers['content-type'] == 'image/jpeg':
fileextension = '.jpg'
if response.headers['content-type'] == 'image/x-icon':
fileextension = '.ico'
filename = os.path.join(MEDIA_ROOT, 'favicons/' + domain + fileextension)
with open(filename, 'wb') as out_file:
shutil.copyfileobj(response.raw, out_file)
del response
filetype = file_type(filename)
if filetype == 'gz':
# decompress
orig = gzip.GzipFile(filename, 'rb')
origcontent = orig.read()
orig.close()
os.remove(filename)
with open(filename, 'wb') as new:
new.write(origcontent)
self.favicon = domain + fileextension
def set_favicon(self):
""" Fetch favicon for the domain """
u = urlparse(self.url)
domain = u.netloc
if os.path.isfile(os.path.join(MEDIA_ROOT, 'favicons/' + domain + '.png')):
# If file exists, don't re-download it
self.favicon = domain + '.png'
return
if os.path.isfile(os.path.join(MEDIA_ROOT, 'favicons/' + domain + '.ico')):
# If file exists, don't re-download it
self.favicon = domain + '.ico'
return
#self._set_favicon_with_iconsbetterideaorg(domain)
self._set_favicon_with_realfavicongenerator(domain)
def set_tags(self, newtags):
""" Set tags from `tags`, strip and sort them """
tags_split = newtags.split(',')
tags_clean = clean_tags(tags_split)
self.tags = ','.join(tags_clean)
def get_redirect_uri(self):
if self.redirect_uri:
return self.redirect_uri
if self.http_status == 301 or self.http_status == 302:
result = requests.head(self.url, allow_redirects=True, headers={'User-Agent': DIGIMARKS_USER_AGENT})
self.http_status = result.status_code
self.redirect_uri = result.url
return result.url
return None
def get_uri_domain(self):
parsed = urlparse(self.url)
return parsed.hostname
@classmethod
def strip_url_params(cls, url):
parsed = urlparse(url)
return urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, '', parsed.fragment))
@property
def tags_list(self):
""" Get the tags as a list, iterable in template """
if self.tags:
return self.tags.split(',')
return []
def to_dict(self):
result = {
'title': self.title,
'url': self.url,
'created': self.created_date.strftime('%Y-%m-%d %H:%M:%S'),
'url_hash': self.url_hash,
'tags': self.tags.split(','),
'favicon': self.favicon,
'http_status': self.http_status,
'redirect_uri': self.redirect_uri,
}
return result
@property
def serialize(self):
return self.to_dict()
class PublicTag(BaseModel):
""" Publicly shared tag """
tagkey = CharField()
userkey = CharField()
tag = CharField()
created_date = DateTimeField(default=datetime.datetime.now)
def generate_key(self):
""" Generate hash-based key for publicly shared tag """
self.tagkey = binascii.hexlify(os.urandom(16))
def get_tags_for_user(userkey):
""" Extract all tags from the bookmarks """
bookmarks = Bookmark.select().filter(Bookmark.userkey == userkey, Bookmark.status == Bookmark.VISIBLE)
tags = []
for bookmark in bookmarks:
tags += bookmark.tags_list
return clean_tags(tags)

View File

@@ -1,126 +0,0 @@
"""digimarks theme definitions"""
DEFAULT_THEME = 'freshgreen'
themes = {
'green': {
'BROWSERCHROME': '#2e7d32', # green darken-2
'BODY': 'grey lighten-4',
'TEXT': 'black-text',
'TEXTHEX': '#000',
'NAV': 'green darken-3',
'PAGEHEADER': 'grey-text lighten-5',
'MESSAGE_BACKGROUND': 'orange lighten-2',
'MESSAGE_TEXT': 'white-text',
'ERRORMESSAGE_BACKGROUND': 'red darken-1',
'ERRORMESSAGE_TEXT': 'white-text',
'BUTTON': '#1b5e20', # green darken-4
'BUTTON_ACTIVE': '#43a047', # green darken-1
'LINK_TEXT': '#1b5e20', # green darken-4
'CARD_BACKGROUND': 'green darken-3',
'CARD_TEXT': 'white-text',
'CARD_LINK': '#FFF', # white-text
'CHIP_TEXT': '#1b5e20', # green darken-4
'FAB': 'red',
'STAR': 'yellow-text',
'PROBLEM': 'red-text',
'COMMENT': '',
},
'freshgreen': {
'BROWSERCHROME': '#43a047', # green darken-1
'BODY': 'grey lighten-5',
'TEXT': 'black-text',
'TEXTHEX': '#000',
'NAV': 'green darken-1',
'PAGEHEADER': 'grey-text lighten-5',
'MESSAGE_BACKGROUND': 'orange lighten-2',
'MESSAGE_TEXT': 'white-text',
'ERRORMESSAGE_BACKGROUND': 'red darken-1',
'ERRORMESSAGE_TEXT': 'white-text',
'BUTTON': '#1b5e20', # green darken-4
'BUTTON_ACTIVE': '#43a047', # green darken-1
'LINK_TEXT': '#1b5e20', # green darken-4
'CARD_BACKGROUND': 'green darken-1',
'CARD_TEXT': 'white-text',
'CARD_LINK': '#FFF', # white-text
'CHIP_TEXT': '#1b5e20', # green darken-4
'FAB': 'red',
'STAR': 'yellow-text',
'PROBLEM': 'red-text',
'COMMENT': '',
},
'lightblue': {
'BROWSERCHROME': '#0288d1', # light-blue darken-2
'BODY': 'white',
'TEXT': 'black-text',
'TEXTHEX': '#000',
'NAV': 'light-blue darken-2',
'PAGEHEADER': 'grey-text lighten-5',
'MESSAGE_BACKGROUND': 'orange lighten-2',
'MESSAGE_TEXT': 'white-text',
'ERRORMESSAGE_BACKGROUND': 'red darken-1',
'ERRORMESSAGE_TEXT': 'white-text',
'BUTTON': '#fb8c00', # orange darken-1
'BUTTON_ACTIVE': '#ffa726', # orange lighten-1
'LINK_TEXT': '#FFF', # white
'CARD_BACKGROUND': 'light-blue lighten-2',
'CARD_TEXT': 'black-text',
'CARD_LINK': '#263238', # blue-grey-text darken-4
'CHIP_TEXT': '#FFF', # white
'FAB': 'light-blue darken-4',
'STAR': 'yellow-text',
'PROBLEM': 'red-text',
'COMMENT': '',
},
'dark': {
'BROWSERCHROME': '#212121', # grey darken-4
'BODY': 'grey darken-4',
'TEXT': 'grey-text lighten-1',
'TEXTHEX': '#bdbdbd',
'NAV': 'grey darken-3',
'PAGEHEADER': 'grey-text lighten-1',
'MESSAGE_BACKGROUND': 'orange lighten-2',
'MESSAGE_TEXT': 'white-text',
'ERRORMESSAGE_BACKGROUND': 'red darken-1',
'ERRORMESSAGE_TEXT': 'white-text',
'BUTTON': '#fb8c00', # orange darken-1
'BUTTON_ACTIVE': '#ffa726', # orange lighten-1
'LINK_TEXT': '#fb8c00', # orange-text darken-1
'CARD_BACKGROUND': 'grey darken-3',
'CARD_TEXT': 'grey-text lighten-1',
'CARD_LINK': '#fb8c00', # orange-text darken-1
'CHIP_TEXT': '#fb8c00', # orange-text darken-1
'FAB': 'red',
'STAR': 'yellow-text',
'PROBLEM': 'red-text',
'COMMENT': '',
},
'amoled': {
'BROWSERCHROME': '#000', # grey darken-4
'BODY': 'black',
'TEXT': 'grey-text lighten-1',
'TEXTHEX': '#bdbdbd',
'NAV': 'grey darken-3',
'PAGEHEADER': 'grey-text lighten-1',
'MESSAGE_BACKGROUND': 'orange lighten-2',
'MESSAGE_TEXT': 'white-text',
'ERRORMESSAGE_BACKGROUND': 'red darken-1',
'ERRORMESSAGE_TEXT': 'white-text',
'BUTTON': '#fb8c00', # orange darken-1
'BUTTON_ACTIVE': '#ffa726', # orange lighten-1
'LINK_TEXT': '#fb8c00', # orange-text darken-1
'CARD_BACKGROUND': 'grey darken-3',
'CARD_TEXT': 'grey-text lighten-1',
'CARD_LINK': '#fb8c00', # orange-text darken-1
'CHIP_TEXT': '#fb8c00', # orange-text darken-1
'FAB': 'red',
'STAR': 'yellow-text',
'PROBLEM': 'red-text',
'COMMENT': '',
}
}

View File

@@ -1,2 +0,0 @@
"""digimarks views"""

View File

@@ -10,6 +10,10 @@ DEBUG = False
# echo -n "yourstring" | sha1sum
SYSTEMKEY = 'S3kr1t'
# RapidAPI key for favicons
# https://rapidapi.com/realfavicongenerator/api/realfavicongenerator
MASHAPE_API_KEY = 'your_MASHAPE_key'
LOG_LOCATION = 'digimarks.log'
#LOG_LOCATION = '/var/log/digimarks/digimarks.log'
# How many logs to keep in log rotation:

22
pylintrc Normal file
View File

@@ -0,0 +1,22 @@
[FORMAT]
max-line-length=120
[BASIC]
# Good variable names which should always be accepted, separated by a comma.
good-names=i,
j,
k,
e,
ex,
extra,
f,
fd,
fp,
logger,
Run,
q,
s,
x,
y,
_

74
pyproject.toml Normal file
View File

@@ -0,0 +1,74 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "digimarks"
version = "1.1.99"
authors = [
{name = "Michiel Scholten", email = "michiel@diginaut.net"},
]
description='Simple bookmarking service, using a SQLite database to store bookmarks, supporting tags, automatic title fetching and REST API calls.'
readme = "README.rst"
requires-python = ">=3.7"
keywords = ["bookmarks", "api"]
license = {text = "Apache"}
classifiers = [
"Framework :: FastAPI",
"Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache Software License",
]
dependencies = [
"importlib-metadata; python_version<'3.8'",
"fastapi[all]",
"pydantic>2.0",
"requests",
"bs4",
"feedgen"
]
# dynamic = ["version"]
[project.scripts]
my-script = "digimarks:app"
[project.urls]
"Homepage" = "https://github.com/aquatix/digimarks"
"Bug Tracker" = "https://github.com/aquatix/digimarks/issues"
[tool.black]
line-length = 120
[tool.ruff]
exclude = [
".git",
"__pycache__",
"docs/source/conf.py",
"build",
"dist",
"example_config/gunicorn_digimarks_conf.py",
"example_config/settings.py",
]
line-length = 120
[tool.ruff.format]
# Prefer single quotes over double quotes
quote-style = "single"
[tool.ruff.lint]
ignore = ["D203", "D213"]
select = [
"C9",
"D",
"E",
"F",
"I",
"W",
]
[tool.ruff.lint.flake8-quotes]
docstring-quotes = "double"
inline-quotes = "single"
multiline-quotes = "double"
[tool.ruff.lint.mccabe]
max-complexity = 10

View File

@@ -1,3 +1,11 @@
-r requirements.in
pylint
# Linting and fixing, including isort
ruff
# Test suite
pytest
# Publishing on PyPI
build
twine

View File

@@ -1,27 +1,223 @@
#
# This file is autogenerated by pip-compile
# To update, run:
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --output-file requirements-dev.txt requirements-dev.in
# pip-compile requirements-dev.in
#
astroid==2.0.4 # via pylint
beautifulsoup4==4.6.3 # via bs4
annotated-types==0.5.0
# via pydantic
anyio==3.7.1
# via
# httpcore
# starlette
# watchfiles
astroid==2.15.6
# via pylint
beautifulsoup4==4.12.2
# via bs4
black==23.7.0
# via -r requirements-dev.in
bleach==6.0.0
# via readme-renderer
bs4==0.0.1
certifi==2018.10.15 # via requests
chardet==3.0.4 # via requests
click==7.0 # via flask
flask==1.0.2
idna==2.7 # via requests
isort==4.3.4 # via pylint
itsdangerous==1.1.0 # via flask
jinja2==2.10 # via flask
lazy-object-proxy==1.3.1 # via astroid
markupsafe==1.1.0 # via jinja2
mccabe==0.6.1 # via pylint
peewee==3.7.1
pylint==2.1.1
requests==2.20.1
six==1.11.0 # via astroid
urllib3==1.24.1 # via requests
werkzeug==0.14.1 # via flask
wrapt==1.10.11 # via astroid
# via -r requirements.in
build==0.10.0
# via -r requirements-dev.in
certifi==2023.7.22
# via
# httpcore
# httpx
# requests
cffi==1.15.1
# via cryptography
charset-normalizer==3.2.0
# via requests
click==8.1.6
# via
# black
# uvicorn
cryptography==41.0.2
# via secretstorage
dill==0.3.7
# via pylint
dnspython==2.4.1
# via email-validator
docutils==0.20.1
# via readme-renderer
email-validator==2.0.0.post2
# via fastapi
exceptiongroup==1.1.2
# via anyio
fastapi[all]==0.100.1
# via -r requirements.in
feedgen==0.9.0
# via -r requirements.in
greenlet==2.0.2
# via sqlalchemy
h11==0.14.0
# via
# httpcore
# uvicorn
httpcore==0.17.3
# via httpx
httptools==0.6.0
# via uvicorn
httpx==0.24.1
# via fastapi
idna==3.4
# via
# anyio
# email-validator
# httpx
# requests
importlib-metadata==6.8.0
# via
# keyring
# twine
isort==5.12.0
# via pylint
itsdangerous==2.1.2
# via fastapi
jaraco-classes==3.3.0
# via keyring
jeepney==0.8.0
# via
# keyring
# secretstorage
jinja2==3.1.2
# via fastapi
keyring==24.2.0
# via twine
lazy-object-proxy==1.9.0
# via astroid
lxml==4.9.3
# via feedgen
markdown-it-py==3.0.0
# via rich
markupsafe==2.1.3
# via jinja2
mccabe==0.7.0
# via pylint
mdurl==0.1.2
# via markdown-it-py
more-itertools==10.0.0
# via jaraco-classes
mypy-extensions==1.0.0
# via black
orjson==3.9.2
# via fastapi
packaging==23.1
# via
# black
# build
pathspec==0.11.2
# via black
pkginfo==1.9.6
# via twine
platformdirs==3.10.0
# via
# black
# pylint
pycparser==2.21
# via cffi
pydantic==2.1.1
# via
# fastapi
# pydantic-extra-types
# pydantic-settings
pydantic-core==2.4.0
# via pydantic
pydantic-extra-types==2.0.0
# via fastapi
pydantic-settings==2.0.2
# via fastapi
pygments==2.15.1
# via
# readme-renderer
# rich
pylint==2.17.5
# via -r requirements-dev.in
pyproject-hooks==1.0.0
# via build
python-dateutil==2.8.2
# via feedgen
python-dotenv==1.0.0
# via
# pydantic-settings
# uvicorn
python-multipart==0.0.6
# via fastapi
pyyaml==6.0.1
# via
# fastapi
# uvicorn
readme-renderer==40.0
# via twine
requests==2.31.0
# via
# -r requirements.in
# requests-toolbelt
# twine
requests-toolbelt==1.0.0
# via twine
rfc3986==2.0.0
# via twine
rich==13.5.0
# via twine
ruff==0.0.280
# via -r requirements-dev.in
secretstorage==3.3.3
# via keyring
six==1.16.0
# via
# bleach
# python-dateutil
sniffio==1.3.0
# via
# anyio
# httpcore
# httpx
soupsieve==2.4.1
# via beautifulsoup4
sqlalchemy==2.0.19
# via -r requirements.in
starlette==0.27.0
# via fastapi
tomli==2.0.1
# via
# black
# build
# pylint
# pyproject-hooks
tomlkit==0.12.1
# via pylint
twine==4.0.2
# via -r requirements-dev.in
typing-extensions==4.7.1
# via
# astroid
# fastapi
# pydantic
# pydantic-core
# sqlalchemy
# uvicorn
ujson==5.8.0
# via fastapi
urllib3==2.0.4
# via
# requests
# twine
uvicorn[standard]==0.23.1
# via fastapi
uvloop==0.17.0
# via uvicorn
watchfiles==0.19.0
# via uvicorn
webencodings==0.5.1
# via bleach
websockets==11.0.3
# via uvicorn
wrapt==1.15.0
# via astroid
zipp==3.16.2
# via importlib-metadata

3
requirements-server.in Normal file
View File

@@ -0,0 +1,3 @@
-r requirements.in
gunicorn

View File

@@ -1,4 +1,12 @@
flask
peewee
bs4
requests
# Core application
fastapi[all]
sqlmodel
# Fetch title etc from links
beautifulsoup4
# Fetch favicons
extract_favicon
# Generate (atom) feeds for tags and such
feedgen

View File

@@ -1,20 +1,121 @@
#
# This file is autogenerated by pip-compile
# To update, run:
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --output-file requirements.txt requirements.in
# pip-compile requirements.in
#
beautifulsoup4==4.6.3 # via bs4
annotated-types==0.5.0
# via pydantic
anyio==3.7.1
# via
# httpcore
# starlette
# watchfiles
beautifulsoup4==4.12.2
# via bs4
bs4==0.0.1
certifi==2018.10.15 # via requests
chardet==3.0.4 # via requests
click==7.0 # via flask
flask==1.0.2
idna==2.7 # via requests
itsdangerous==1.1.0 # via flask
jinja2==2.10 # via flask
markupsafe==1.1.0 # via jinja2
peewee==3.7.1
requests==2.20.1
urllib3==1.24.1 # via requests
werkzeug==0.14.1 # via flask
# via -r requirements.in
certifi==2023.7.22
# via
# httpcore
# httpx
# requests
charset-normalizer==3.2.0
# via requests
click==8.1.6
# via uvicorn
dnspython==2.4.1
# via email-validator
email-validator==2.0.0.post2
# via fastapi
exceptiongroup==1.1.2
# via anyio
fastapi[all]==0.100.1
# via -r requirements.in
feedgen==0.9.0
# via -r requirements.in
greenlet==2.0.2
# via sqlalchemy
h11==0.14.0
# via
# httpcore
# uvicorn
httpcore==0.17.3
# via httpx
httptools==0.6.0
# via uvicorn
httpx==0.24.1
# via fastapi
idna==3.4
# via
# anyio
# email-validator
# httpx
# requests
itsdangerous==2.1.2
# via fastapi
jinja2==3.1.2
# via fastapi
lxml==4.9.3
# via feedgen
markupsafe==2.1.3
# via jinja2
orjson==3.9.2
# via fastapi
pydantic==2.1.1
# via
# fastapi
# pydantic-extra-types
# pydantic-settings
pydantic-core==2.4.0
# via pydantic
pydantic-extra-types==2.0.0
# via fastapi
pydantic-settings==2.0.2
# via fastapi
python-dateutil==2.8.2
# via feedgen
python-dotenv==1.0.0
# via
# pydantic-settings
# uvicorn
python-multipart==0.0.6
# via fastapi
pyyaml==6.0.1
# via
# fastapi
# uvicorn
requests==2.31.0
# via -r requirements.in
six==1.16.0
# via python-dateutil
sniffio==1.3.0
# via
# anyio
# httpcore
# httpx
soupsieve==2.4.1
# via beautifulsoup4
sqlalchemy==2.0.19
# via -r requirements.in
starlette==0.27.0
# via fastapi
typing-extensions==4.7.1
# via
# fastapi
# pydantic
# pydantic-core
# sqlalchemy
# uvicorn
ujson==5.8.0
# via fastapi
urllib3==2.0.4
# via requests
uvicorn[standard]==0.23.1
# via fastapi
uvloop==0.17.0
# via uvicorn
watchfiles==0.19.0
# via uvicorn
websockets==11.0.3
# via uvicorn

View File

@@ -1,43 +1,7 @@
"""
A setuptools based setup module.
See:
https://packaging.python.org/en/latest/distributing.html
https://github.com/pypa/sampleproject
"""
#!/usr/bin/env python
"""Install script for module installation. Compatibility stub because pyproject.toml is used."""
from setuptools import setup
# To use a consistent encoding
from codecs import open as codecopen
from os import path
import setuptools
here = path.abspath(path.dirname(__file__))
# Get the long description from the relevant file
with codecopen(path.join(here, 'README.rst'), encoding='utf-8') as f:
long_description = f.read()
setup(
name='digimarks', # pip install digimarks
description='Simple bookmarking service, using a SQLite database to store bookmarks, supporting tags, automatic title fetching and REST API calls.',
#long_description=open('README.md', 'rt').read(),
long_description=long_description,
# version
# third part for minor release
# second when api changes
# first when it becomes stable someday
version='1.1.99',
author='Michiel Scholten',
author_email='michiel@diginaut.net',
url='https://github.com/aquatix/digimarks',
license='Apache',
# as a practice no need to hard code version unless you know program wont
# work unless the specific versions are used
install_requires=['Flask', 'Peewee', 'Flask-Peewee', 'requests', 'bs4'],
py_modules=['digimarks'],
zip_safe=True,
)
if __name__ == "__main__":
setuptools.setup()

635
src/digimarks/main.py Normal file
View File

@@ -0,0 +1,635 @@
"""digimarks main module."""
import binascii
import hashlib
import logging
import os
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from http import HTTPStatus
from typing import Annotated, Optional, Sequence, Type, TypeVar
from urllib.parse import urlparse, urlunparse
import bs4
import httpx
from extract_favicon import from_html
from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import AnyUrl, DirectoryPath, FilePath, computed_field
from pydantic_settings import BaseSettings
from sqlmodel import AutoString, Field, Session, SQLModel, create_engine, desc, select
DIGIMARKS_USER_AGENT = 'digimarks/2.0.0-dev'
DIGIMARKS_VERSION = '2.0.0a1'
DEFAULT_THEME = 'freshgreen'
class Settings(BaseSettings):
"""Configuration needed for digimarks to find its database, favicons, API integrations."""
# outside the codebase
database_file: FilePath
favicons_dir: DirectoryPath
# inside the codebase
static_dir: DirectoryPath = 'static'
template_dir: DirectoryPath = 'templates'
media_url: str = '/static/'
system_key: str
debug: bool = False
settings = Settings()
print(settings.model_dump())
engine = create_engine(f'sqlite:///{settings.database_file}', connect_args={'check_same_thread': False})
def get_session():
"""SQLAlchemy session factory."""
with Session(engine) as session:
yield session
SessionDep = Annotated[Session, Depends(get_session)]
@asynccontextmanager
async def lifespan(the_app: FastAPI):
"""Upon start, initialise an AsyncClient and assign it to an attribute named requests_client on the app object."""
the_app.requests_client = httpx.AsyncClient()
yield
await the_app.requests_client.aclose()
app = FastAPI(lifespan=lifespan)
app.mount('/static', StaticFiles(directory=settings.static_dir), name='static')
app.mount('/content/favicons', StaticFiles(directory=settings.favicons_dir), name='favicons')
templates = Jinja2Templates(directory=settings.template_dir)
logger = logging.getLogger('digimarks')
if settings.debug:
logger.setLevel(logging.DEBUG)
# CORS configuration
app.add_middleware(
CORSMiddleware,
allow_origins=['*'], # Allow requests from everywhere
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
def i_filter_false(predicate, iterable):
"""Filter an iterable if predicate returns True.
i_filter_false(lambda x: x%2, range(10)) --> 0 2 4 6 8
"""
if predicate is None:
predicate = bool
for x in iterable:
if not predicate(x):
yield x
def unique_ever_seen(iterable, key=None):
"""List unique elements, preserving order. Remember all elements ever seen.
unique_ever_seen('AAAABBBCCDAABBB') --> A B C D
unique_ever_seen('ABBCcAD', str.lower) --> A B C D
"""
seen = set()
seen_add = seen.add
if key is None:
for element in i_filter_false(seen.__contains__, iterable):
seen_add(element)
yield element
else:
for element in iterable:
k = key(element)
if k not in seen:
seen_add(k)
yield element
def clean_tags(tags_list: list) -> list:
"""Generate a unique list of the tags.
:param list tags_list: List with all tags
:return: deduplicated list of the tags, without leading or trailing whitespace
:rtype: list
"""
tags_res = [x.strip() for x in tags_list]
tags_res = list(unique_ever_seen(tags_res))
tags_res.sort()
if tags_res and tags_res[0] == '':
del tags_res[0]
return tags_res
def list_tags_for_bookmarks(bookmarks: list) -> list:
"""Generate a unique list of the tags from the list of bookmarks."""
tags = []
for bookmark in bookmarks:
tags += bookmark.tags_list
return clean_tags(tags)
def file_type(filename: str) -> str:
"""Try to determine the file type for the file in `filename`.
:param str filename: path to file to check
:return: zip file type
:rtype: str
"""
magic_dict = {b'\x1f\x8b\x08': 'gz', b'\x42\x5a\x68': 'bz2', b'\x50\x4b\x03\x04': 'zip'}
max_len = max(len(x) for x in magic_dict)
with open(filename, 'rb') as f:
file_start = f.read(max_len)
for magic, filetype in magic_dict.items():
if file_start.startswith(magic):
return filetype
return 'no match'
def generate_hash(input_text: str) -> str:
"""Generate a hash from string `input`, e.g., for a URL."""
return hashlib.md5(input_text.encode('utf-8')).hexdigest()
def generate_key() -> str:
"""Generate a key to be used for a user or tag."""
return str(binascii.hexlify(os.urandom(24)))
# Type var used for building custom types for the DB
T = TypeVar('T')
def build_custom_type(internal_type: Type[T]) -> Type[AutoString]:
"""Create a type that is compatible with the database.
Based on https://github.com/fastapi/sqlmodel/discussions/847
"""
class CustomType(AutoString):
def process_bind_param(self, value, dialect) -> Optional[str]:
if value is None:
return None
if isinstance(value, str):
# Test if value is valid to avoid `process_result_value` failing
try:
internal_type(value) # type: ignore[call-arg]
except ValueError as e:
raise ValueError(f'Invalid value for {internal_type.__name__}: {e}') from e
return str(value)
def process_result_value(self, value, dialect) -> Optional[T]:
if value is None:
return None
return internal_type(value) # type: ignore[call-arg]
return CustomType
def get_favicon(html_content: str, root_url: str) -> str:
"""Fetch the favicon from `html_content` using `root_url`."""
favicons = from_html(html_content, root_url=root_url, include_fallbacks=True)
for favicon in favicons:
print(favicon.url, favicon.width, favicon.height)
# TODO: save the preferred image to file and return
class User(SQLModel, table=True):
"""User account."""
__tablename__ = 'user'
id: int = Field(primary_key=True)
username: str
key: str
theme: str = Field(default=DEFAULT_THEME)
created_date: datetime
class Visibility:
"""Options for visibility of an object."""
VISIBLE = 0
DELETED = 1
class Bookmark(SQLModel, table=True):
"""Bookmark object."""
__tablename__ = 'bookmark'
id: int = Field(primary_key=True)
userkey: str = Field(foreign_key='user.key')
title: str = Field(default='')
url: AnyUrl = Field(default='', sa_type=build_custom_type(AnyUrl))
note: str = Field(default='')
# image: str = Field(default='')
url_hash: str = Field(default='')
tags: str = Field(default='')
starred: bool = Field(default=False)
favicon: str | None = Field(default=None)
http_status: int = Field(default=HTTPStatus.OK)
created_date: datetime = Field(default=datetime.now(UTC))
modified_date: datetime = Field(default=None)
deleted_date: datetime = Field(default=None)
status: int = Field(default=Visibility.VISIBLE)
@computed_field
@property
def tag_list(self) -> list:
"""The tags but as a proper list."""
if self.tags:
return self.tags.split(',')
# Not tags, return empty list instead of [''] that split returns in that case
return []
async def set_information_from_source(bookmark: Bookmark, request: Request) -> Bookmark:
"""Request the title by requesting the source url."""
logger.info('Extracting information from url %s', bookmark.url)
try:
result = await request.app.requests_client.get(bookmark.url, headers={'User-Agent': DIGIMARKS_USER_AGENT})
bookmark.http_status = result.status_code
except httpx.HTTPError as err:
# For example, 'MissingSchema: Invalid URL 'abc': No schema supplied. Perhaps you meant http://abc?'
logger.error('Exception when trying to retrieve title for %s. Error: %s', bookmark.url, str(err))
bookmark.http_status = 404
bookmark.title = ''
return bookmark
if bookmark.http_status == 200 or bookmark.http_status == 202:
html = bs4.BeautifulSoup(result.text, 'html.parser')
try:
bookmark.title = html.title.text.strip()
except AttributeError:
bookmark.title = ''
url_parts = urlparse(str(bookmark.url))
root_url = url_parts.scheme + '://' + url_parts.netloc
favicon = get_favicon(result.text, root_url)
# filename = os.path.join(settings.media_dir, 'favicons/', domain + file_extension)
# with open(filename, 'wb') as out_file:
# shutil.copyfileobj(response.raw, out_file)
# Extraction was successful
logger.info('Extracting information was successful')
return bookmark
def set_tags(bookmark: Bookmark, new_tags: str) -> None:
"""Set tags from `tags`, strip and sort them.
:param Bookmark bookmark: Bookmark to modify
:param str new_tags: New tags to sort and set.
"""
tags_split = new_tags.split(',')
tags_clean = clean_tags(tags_split)
bookmark.tags = ','.join(tags_clean)
def strip_url_params(url: str) -> str:
"""Strip URL params from URL.
:param url: URL to strip URL params from.
:return: clean URL
:rtype: str
"""
parsed = urlparse(url)
return urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, '', parsed.fragment))
def update_bookmark_with_info(bookmark: Bookmark, request: Request, strip_params: bool = False):
"""Automatically update title, favicon, etc."""
if not bookmark.title:
# Title was empty, automatically fetch it from the url, will also update the status code
set_information_from_source(bookmark, request)
if strip_params:
# Strip URL parameters, e.g., tracking params
bookmark.url = AnyUrl(strip_url_params(str(bookmark.url)))
# Sort and deduplicate tags
set_tags(bookmark, bookmark.tags)
class PublicTag(SQLModel, table=True):
"""Public tag object."""
__tablename__ = 'public_tag'
id: int = Field(primary_key=True)
tagkey: str
userkey: str = Field(foreign_key='user.key')
tag: str
created_date: datetime = Field(default=datetime.now(UTC))
@app.get('/', response_class=HTMLResponse)
@app.head('/', response_class=HTMLResponse)
def index(request: Request):
"""Homepage, point visitors to project page."""
return templates.TemplateResponse(
request=request,
name='index.html',
context={'language': 'en', 'version': DIGIMARKS_VERSION, 'theme': DEFAULT_THEME},
)
@app.get('/api/v1/admin/{system_key}/users/{user_id}', response_model=User)
def get_user(session: SessionDep, system_key: str, user_id: int) -> Type[User]:
"""Show user information."""
if system_key != settings.system_key:
raise HTTPException(status_code=404)
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail='User not found')
return user
# @app.get('/admin/{system_key}/users/', response_model=list[User])
@app.get('/api/v1/admin/{system_key}/users/')
def list_users(
session: SessionDep,
system_key: str,
offset: int = 0,
limit: Annotated[int, Query(le=100)] = 100,
) -> Sequence[User]:
"""List all users in the database.
:param SessionDep session:
:param str system_key: secrit key
:param int offset: [Optional] offset of pagination
:param int limit: [Optional] limits the number of users to return, defaults to 100
:return: list of users in the system
:rtype: list[User]
"""
if system_key != settings.system_key:
raise HTTPException(status_code=404)
users = session.exec(select(User).offset(offset).limit(limit)).all()
return users
@app.get('/api/v1/{user_key}/bookmarks/')
def list_bookmarks(
session: SessionDep,
user_key: str,
offset: int = 0,
limit: Annotated[int, Query(le=10000)] = 100,
) -> list[Bookmark]:
"""List all bookmarks in the database. By default 100 items are returned."""
bookmarks = session.exec(
select(Bookmark)
.where(Bookmark.userkey == user_key, Bookmark.status != Visibility.DELETED)
.offset(offset)
.limit(limit)
).all()
return bookmarks
@app.get('/api/v1/{user_key}/bookmarks/{url_hash}')
def get_bookmark(
session: SessionDep,
user_key: str,
url_hash: str,
) -> Bookmark:
"""Show bookmark details."""
bookmark = session.exec(
select(Bookmark).where(
Bookmark.userkey == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
)
).first()
# bookmark = session.get(Bookmark, {'url_hash': url_hash, 'userkey': user_key})
return bookmark
@app.post('/api/v1/{user_key}/autocomplete_bookmark/', response_model=Bookmark)
def autocomplete_bookmark(
session: SessionDep,
request: Request,
user_key: str,
bookmark: Bookmark,
strip_params: bool = False,
):
"""Autofill some fields for this (new) bookmark for user `user_key`."""
bookmark.userkey = user_key
# Auto-fill title, fix tags etc.
update_bookmark_with_info(bookmark, request, strip_params)
url_hash = generate_hash(str(bookmark.url))
bookmark_db = session.exec(
select(Bookmark).where(
Bookmark.userkey == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
)
).first()
if bookmark_db:
# Bookmark with this URL already exists, provide the hash so the frontend can look it up and the user can
# merge them if so wanted
bookmark.url_hash = url_hash
return bookmark
@app.post('/api/v1/{user_key}/bookmarks/', response_model=Bookmark)
def add_bookmark(
session: SessionDep,
request: Request,
user_key: str,
bookmark: Bookmark,
strip_params: bool = False,
):
"""Add new bookmark for user `user_key`."""
bookmark.userkey = user_key
# Auto-fill title, fix tags etc.
update_bookmark_with_info(bookmark, request, strip_params)
bookmark.url_hash = generate_hash(str(bookmark.url))
session.add(bookmark)
session.commit()
session.refresh(bookmark)
return bookmark
@app.patch('/api/v1/{user_key}/bookmarks/{url_hash}', response_model=Bookmark)
def update_bookmark(
session: SessionDep,
request: Request,
user_key: str,
bookmark: Bookmark,
url_hash: str,
strip_params: bool = False,
):
"""Update existing bookmark `bookmark_key` for user `user_key`."""
bookmark_db = session.exec(
select(Bookmark).where(
Bookmark.userkey == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
)
).first()
if not bookmark_db:
raise HTTPException(status_code=404, detail='Bookmark not found')
bookmark.modified_date = datetime.now(UTC)
# 'patch' endpoint, which means that you can send only the data that you want to update, leaving the rest intact
bookmark_data = bookmark.model_dump(exclude_unset=True)
# Merge the changed fields into the existing object
bookmark_db.sqlmodel_update(bookmark_data)
# Autofill title, fix tags, etc. where (still) needed
update_bookmark_with_info(bookmark, request, strip_params)
session.add(bookmark_db)
session.commit()
session.refresh(bookmark_db)
return bookmark_db
@app.delete('/api/v1/{user_key}/bookmarks/{url_hash}', response_model=Bookmark)
def delete_bookmark(
session: SessionDep,
user_key: str,
url_hash: str,
):
"""(Soft)Delete bookmark `bookmark_key` for user `user_key`."""
bookmark = session.get(Bookmark, {'url_hash': url_hash, 'userkey': user_key})
if not bookmark:
raise HTTPException(status_code=404, detail='Bookmark not found')
bookmark.deleted_date = datetime.now(UTC)
bookmark.status = Visibility.DELETED
session.add(bookmark)
session.commit()
return {'ok': True}
@app.get('/api/v1/{user_key}/latest_changes/')
def bookmarks_changed_since(
session: SessionDep,
user_key: str,
):
"""Last update on server, so the (browser) client knows whether to fetch an update."""
latest_modified_bookmark = session.exec(
select(Bookmark)
.where(Bookmark.userkey == user_key, Bookmark.status != Visibility.DELETED)
.order_by(desc(Bookmark.modified_date))
).first()
latest_created_bookmark = session.exec(
select(Bookmark)
.where(Bookmark.userkey == user_key, Bookmark.status != Visibility.DELETED)
.order_by(desc(Bookmark.created_date))
).first()
latest_modification = max(latest_modified_bookmark.modified_date, latest_created_bookmark.created_date)
return {
'current_time': datetime.now(UTC),
'latest_change': latest_modified_bookmark.modified_date,
'latest_created': latest_created_bookmark.created_date,
'latest_modification': latest_modification,
}
@app.get('/api/v1/{user_key}/tags/')
def list_tags_for_user(
session: SessionDep,
user_key: str,
) -> list[str]:
"""List all tags in use by the user."""
bookmarks = session.exec(
select(Bookmark).where(Bookmark.userkey == user_key, Bookmark.status != Visibility.DELETED)
).all()
tags = []
for bookmark in bookmarks:
tags += bookmark.tag_list
return clean_tags(tags)
@app.get('/api/v1/{user_key}/tags/{tag_key}')
def list_tags_for_user(
session: SessionDep,
user_key: str,
) -> list[str]:
"""List all tags in use by the user."""
bookmarks = session.exec(select(Bookmark).where(Bookmark.userkey == user_key)).all()
return list_tags_for_bookmarks(bookmarks)
@app.get('/{user_key}', response_class=HTMLResponse)
def page_user_landing(
session: SessionDep,
request: Request,
user_key: str,
):
"""HTML page with the main view for the user."""
user = session.exec(select(User).where(User.key == user_key)).first()
if not user:
raise HTTPException(status_code=404, detail='User not found')
language = 'en'
return templates.TemplateResponse(
request=request,
name='user_index.html',
context={'language': language, 'version': DIGIMARKS_VERSION, 'user_key': user_key},
)
# def tags_page(userkey):
# """Overview of all tags used by user"""
# tags = get_cached_tags(userkey)
# alltags = []
# for tag in tags:
# try:
# publictag = PublicTag.get(PublicTag.userkey == userkey, PublicTag.tag == tag)
# except PublicTag.DoesNotExist:
# publictag = None
#
# total = (
# Bookmark.select()
# .where(Bookmark.userkey == userkey, Bookmark.tags.contains(tag), Bookmark.status == Bookmark.VISIBLE)
# .count()
# )
# alltags.append({'tag': tag, 'publictag': publictag, 'total': total})
# totaltags = len(alltags)
# totalbookmarks = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.status == Bookmark.VISIBLE).count()
# totalpublic = PublicTag.select().where(PublicTag.userkey == userkey).count()
# totalstarred = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.starred).count()
# totaldeleted = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.status == Bookmark.DELETED).count()
# totalnotes = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.note != '').count()
# totalhttperrorstatus = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.http_status != 200).count()
# theme = get_theme(userkey)
# return render_template(
# 'tags.html',
# tags=alltags,
# totaltags=totaltags,
# totalpublic=totalpublic,
# totalbookmarks=totalbookmarks,
# totaldeleted=totaldeleted,
# totalstarred=totalstarred,
# totalhttperrorstatus=totalhttperrorstatus,
# totalnotes=totalnotes,
# userkey=userkey,
# theme=theme,
# )

View File

@@ -0,0 +1,25 @@
/**
* digimarks styling
*
* Overrides on and additions to the digui styling
*/
/* Star, error, note etc */
.card .statuses {
display: flex;
flex-direction: column;
}
.star {
color: #ffeb3b;
}
.thumbnail {
/*width: 80px;*/
width: 66;
}
.thumbnail img {
/*width: 72px;*/
width: 60px;
}

View File

@@ -0,0 +1,462 @@
/**
* digui structure and theming
* v0.0.2
*
* Created by: Michiel Scholten
* Source: https://github.com/aquatix/digui
*/
/** Colours and themes */
:root {
--padding: .5rem;
/* Default is nebula-light */
--font-family: sans-serif;
--background-color: #fff;
--background-color-secondary: #ccc;
--button-color: #eee;
--button-text: var(--text-color);
--text-color: #121416d8;
--text-color-secondary: #121416d8;
--text-color-muted: #d5d9d9;
--link-color: #543fd7;
--nav-background-color: #FFF;
--nav-color: var(--text-color);
--border-color: #d5d9d9;
--border-width: 1px;
--border-radius: 8px;
--shadow-color: rgba(213, 217, 217, .5);
--global-theme-toggle-content: ' 🌞';
/* E.g., an active button */
--color-highlight: #fb8c00;
/* Generic colors */
/*--color-danger: #e03131;*/
--color-danger: var(--color-red);
--color-warning: var(--color-yellow);
--color-error: var(--color-danger);
/*--color-ok: #31e031;*/
--color-ok: var(--color-green);
/* Argonaut colours */
--color-black: #000000;
--color-red: #FF000F;
--color-green: #8CE10B;
--color-yellow: #FFB900;
--color-blue: #008DF8;
--color-purple: #6D43A6;
--color-cyan: #00D8EB;
--color-white: #FFFFFF;
}
html[data-theme='nebula'] {
/* Default theme, see :root element */
}
html[data-theme='nebula-dark'] {
--background-color: #29292c;
--background-color-secondary: #29292c;
--button-color: #29292c;
--button-text: var(--text-color);
--text-color: #F7F8F8;
--text-color-secondary: #ddd;
--text-color-muted: #F7F8F8;
--link-color: #ffe7a3;
--color-highlight: #e03131;
--nav-background-color: #FF9800;
--nav-color: var(--text-color);
--border-color: #333;
--border-width: 1px;
--border-radius: 8px;
--shadow-color: rgba(3, 3, 3, .5);
--global-theme-toggle-content: ' 🌝';
}
html[data-theme='bbs'] {
--font-family: monospace;
--background-color: #FFF;
--background-color-secondary: #ccc;
--button-color: #FFFFFF;
--button-text: var(--text-color);
--text-color: #000;
--text-color-secondary: #000;
--text-color-muted: #000;
--link-color: #543fd7;
--color-highlight: #e03131;
/*--nav-background-color: #ccc;*/
/*--nav-color: var(--text-color);*/
--border-color: #333;
--border-width: 2px;
--border-radius: 0;
--global-theme-toggle-content: ' 🖥️';
}
html[data-theme='silo'] {
--font-family: monospace;
/*--background-color: #003eaa;*/
--background-color: #1d212c;
--background-color-secondary: var(--color-highlight);
--button-color: #FFFFFF;
--button-text: var(--text-color);
--text-color: #FFF;
--text-color-secondary: #29292c;
--text-color-muted: #FFF;
--link-color: #FF9800;
--color-highlight: #23B0FF;
/*--nav-background-color: #003eaa;*/
/*--nav-background-color: #23B0FF;*/
/*--nav-color: var(--text-color);*/
--nav-background-color: var(--background-color);
--border-color: #23B0FF;
/*--border-color: #003eaa;*/
--border-width: 2px;
--border-radius: 0;
--global-theme-toggle-content: ' ⌨️';
}
/* AlpineJS blip-preventer */
[x-cloak] {
display: none !important;
}
/** Main structure */
body {
background: var(--background-color);
color: var(--text-color);
height: 125vh;
font-family: var(--font-family), sans-serif;
margin-top: 3rem;
/*padding: 30px;*/
}
main {
color: var(--text-color);
padding-top: .5em;
}
/* Navigation */
header {
background-color: var(--nav-background-color);
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3rem;
display: flex;
align-items: center;
/*box-shadow: 0 0 5px 0 rgba(213, 217, 217, .5);*/
/*box-shadow: 0 0 5px 0 #999;*/
box-shadow: 0 0 5px var(--shadow-color);
}
header * {
display: inline;
}
header li {
/*margin: 10px;*/
}
header li a {
color: black;
text-decoration: none;
}
header li h1 {
font-weight: bold;
font-size: 1.2rem;
vertical-align: middle;
margin-right: 3rem;
}
[data-theme='silo'] header nav::after {
content: '';
background: repeating-linear-gradient(90deg, #23B0FF, #23B0FF 2px, transparent 0, transparent 10px);
display: block;
width: 100%;
right: 10px;
}
[data-theme='silo'] header {
border-bottom: 3px dotted #23B0FF;
}
/** Generic elements */
/* https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/Heading_Elements#specifying_a_uniform_font_size_for_h1 */
h1 {
margin-block: 0.67em;
font-size: 2em;
}
a, a:hover, a:visited, a:active, a.button, a.button:hover, a.button:active, a.button:visited {
text-decoration: none;
}
a {
color: var(--link-color);
}
a:hover {
text-decoration: underline;
filter: brightness(80%);
}
ol li::marker, ul li::marker {
color: var(--text-color-muted);
}
/* Active element, e.g. a button */
.active {
background-color: var(--color-highlight);
color: var(--text-color);
}
/* Special button */
.theme-toggle::after {
content: var(--global-theme-toggle-content);
}
/* Buttons */
button, .button, input, select, textarea {
box-sizing: border-box;
color: var(--text-color-secondary);
background-color: var(--button-color);
cursor: pointer;
display: inline-block;
font-size: 13px;
/*line-height: 29px;*/
/*padding: 0 10px 0 11px;*/
position: relative;
text-align: left;
text-decoration: none;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
vertical-align: middle;
}
button, .button, input, select, textarea, table {
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
padding: .5rem .5rem;
}
button:hover, .button:hover {
/*background-color: #f7fafa;*/
/*background-color: #d57803;*/
background-color: var(--color-highlight);
filter: brightness(80%);
}
button:focus, .button:focus {
/*border-color: #008296;*/
/*box-shadow: rgba(213, 217, 217, .5) 0 2px 5px 0;*/
outline: 0;
/*border-color: #d57803;*/
}
.btn-dangerous {
background: var(--color-danger);
}
.btn-dangerous:hover {
background: var(--color-danger);
filter: brightness(80%);
}
.btn-warning {
background-color: var(--color-warning);
}
.btn-warning:hover {
background-color: var(--color-warning);
filter: brightness(80%);
}
.btn-ok {
background: var(--color-ok);
}
.btn-ok:hover {
background: var(--color-ok);
filter: brightness(80%);
}
/* Table */
th {
text-align: left;
}
th, td {
padding: 0 0.3rem;
}
/* Cards */
.cards {
display: grid;
grid-column-gap: 1rem;
grid-row-gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
}
.card {
display: inline-grid;
border-radius: var(--border-radius);
border: var(--border-width) solid var(--border-color);
/*margin: 1em;*/
/*padding: 1em;*/
}
[data-theme='nebula'] :modal,
[data-theme='nebula'] .card,
[data-theme='nebula'] button,
[data-theme='nebula'] .button,
[data-theme='nebula'] input,
[data-theme='nebula'] select,
[data-theme='nebula'] textarea,
[data-theme='nebula'] table,
[data-theme='nebula-dark'] :modal,
[data-theme='nebula-dark'] .card,
[data-theme='nebula-dark'] button,
[data-theme='nebula-dark'] .button,
[data-theme='nebula-dark'] input,
[data-theme='nebula-dark'] select,
[data-theme='nebula-dark'] textarea,
[data-theme='nebula-dark'] table {
box-shadow: var(--shadow-color) 0 2px 5px 0;
}
.card .card-header {
}
.card-body {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 1rem;
padding: 1em;
}
.card-body > * {
/*padding-left: 1em;*/
}
.card .card-image {
width: 100%;
}
.card .card-image img {
width: 100%;
border-radius: var(--border-radius);
}
.card .card-thumb {
width: 72px;
/*min-width: 60px;*/
/*max-width: 100px;*/
/*position: relative;*/
/*box-sizing: inherit;*/
}
.card .card-thumb img {
width: 72px;
}
.card .card-action {
padding: .5em;
}
.card .meta {
filter: brightness(80%);
color: var(--text-color);
}
.card .card-footer {
display: flex;
flex-direction: row-reverse;
gap: 1rem;
align-items: center;
padding: .3em;
}
.card-footer h1, .card-footer h2, .card-footer h3, .card-footer h4, .card-footer h5, .card-footer h6 {
margin: 0;
}
/*
.card button {
border: none;
background: none;
}
*/
/* Tags/chips */
.chip {
font-size: .8rem;
border-radius: var(--border-radius);
background-color: var(--background-color-secondary);
color: var(--text-color-secondary);
/*color: var(--text-color);*/
padding: .2rem .5rem;
margin-left: .5rem;
}
.chip .button {
border-radius: var(--border-radius);
}
/* Status */
.error {
color: var(--color-error);
}
/** Modal, e.g. for showing info or filling in a form; on top of the other content */
:modal {
color: var(--text-color);
background-color: var(--background-color);
border: 2px solid var(--border-color);
border-radius: var(--border-radius);
}
/** Footer */
footer {
/*background-color: var(--secondary-background-color);*/
margin-top: 1rem;
padding: 2rem 1rem;
display: grid;
grid-column-gap: 1rem;
grid-row-gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
}
footer .column {
display: inline-grid;
}
footer h2 {
text-align: left;
margin-bottom: 0;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

View File

View File

@@ -0,0 +1,225 @@
document.addEventListener('alpine:init', () => {
Alpine.store('digimarks', {
/** Main digimarks application, state etc */
userKey: -1,
/* cache consists of cache[userKey] = {'bookmarks': [], 'tags': [], ??} */
cache: Alpine.$persist({}).as('cache'),
bookmarks: [],
/* nebula (drop-shadows), bbs (monospace, right lines), silo (like bbs but dark) ?? */
themes: ['nebula', 'nebula-dark', 'bbs', 'silo'],
theme: Alpine.$persist('nebula').as('theme'),
showBookmarks: Alpine.$persist(true).as('showBookmarks'),
showBookmarksList: Alpine.$persist(true).as('showBookmarksList'),
showBookmarksCards: Alpine.$persist(false).as('showBookmarksCards'),
showTags: Alpine.$persist(false).as('showTags'),
/* Bookmark that is being edited, used to fill the form, etc. */
bookmarkToEdit: Alpine.$persist(null).as('bookmarkToEdit'),
/* Loading indicator */
loading: false,
/* Search filter */
search: '',
/* Show bookmarks with this tag/these tags */
tagsFilter: [],
/* Hide bookmarks with these tags */
tagsToHide: Alpine.$persist([]).as('tags_to_hide'),
/* Sort on ~ */
sortTitleAsc: Alpine.$persist(false).as('sortTitleAsc'),
sortTitleDesc: Alpine.$persist(false).as('sortTitleDesc'),
sortCreatedAsc: Alpine.$persist(false).as('sortCreatedAsc'),
sortCreatedDesc: Alpine.$persist(false).as('sortCreatedDesc'),
async init() {
/** Initialise the application after loading */
document.documentElement.setAttribute('data-theme', this.theme);
console.log('Set theme', this.theme);
/* Bookmarks are refreshed through the getBookmarks() call in the HTML page */
/* await this.getBookmarks(); */
setInterval(() => {
// Update counter to next game (midnight UTC, fetched from API) every second
// this.countDownTimer();
}, 1000);
},
async loopToNextTheme() {
/* Loop through themes */
let currentThemeIndex = this.themes.indexOf(this.theme);
if (currentThemeIndex + 1 >= this.themes.length) {
currentThemeIndex = 0
} else {
currentThemeIndex++;
}
this.theme = this.themes[currentThemeIndex];
console.log('Switching to theme', this.theme)
document.documentElement.setAttribute('data-theme', this.theme);
/* Optionally, change the theme CSS file too */
// document.getElementById('theme-link').setAttribute('href', 'digui-theme-' + this.theme + '.css');
},
async loadCache() {
/* Load bookmarks and tags from cache */
if (this.userKey in this.cache) {
console.log('Loading bookmarks from cache for user "' + this.userKey + '"');
this.filterBookmarksByTags();
}
},
async getBookmarks() {
/** Get the bookmarks from the backend */
this.loading = true;
if (!(this.userKey in this.cache)) {
/* There is no cache for this userKey yet, create on */
console.log('Creating cache for user "' + this.userKey + '"');
this.cache[this.userKey] = {'bookmarks': [], 'latest_changes': {}};
}
let latestStatusResponse = await fetch('/api/v1/' + this.userKey + '/latest_changes/');
let latestStatusResult = await latestStatusResponse.json();
let shouldFetch = false;
let latestModificationInCache = this.cache[this.userKey].latest_changes.latest_modification || "0000-00-00";
shouldFetch = latestStatusResult.latest_modification > latestModificationInCache;
this.cache[this.userKey].latest_changes = latestStatusResult;
if (!shouldFetch) {
console.log('Cache is up-to-date');
this.loading = false;
return;
}
console.log('Fetching latest bookmarks from backend for user "' + this.userKey + '"...');
/* At the moment, request 'a lot' bookmarks; likely all of them in one go; paging tbd if needed */
let response = await fetch('/api/v1/' + this.userKey + '/bookmarks/?limit=10000');
/* Cache the bookmarks to Local Storage */
this.cache[this.userKey]['bookmarks'] = await response.json();
let tagsResponse = await fetch('/api/v1/' + this.userKey + '/tags/');
this.cache[this.userKey]['tags'] = await tagsResponse.json();
/* Filter bookmarks by (blacklisted) tags */
await this.filterBookmarksByTags();
this.loading = false;
},
hasTag(tagList, filterList) {
/* Looks for the items in filterList and returns True when one appears on the tagList */
if (tagList === undefined) {
return false;
}
for (let tag in filterList) {
if (tagList.includes(tag))
return true;
}
return false;
},
filterBookmarksByTags() {
/* Filter away bookmarks with a tag on the 'blacklist' */
/* First make a shallow copy of all bookmarks */
let prefilteredBookmarks = [...this.cache[this.userKey]['bookmarks']] || [];
if (this.tagsToHide.length > 0) {
console.log('Filtering away bookmarks containing blacklisted tags');
this.bookmarks = prefilteredBookmarks.filter(
i => !this.hasTag(i.tag_list, this.tagsToHide)
)
} else {
this.bookmarks = prefilteredBookmarks;
}
this.sortBookmarks();
},
get filteredBookmarks() {
/* Get the bookmarks, optionally filtered by search text or tag black-/whitelists */
/* Use 'bookmarks' and not the cache, as it can already be pre-filtered */
if (this.search === '') {
/* No need to filter, quickly return the set */
return this.bookmarks;
}
return this.bookmarks.filter(
i => i.title.match(new RegExp(this.search, "i"))
)
},
get filteredTags() {
/* Search in the list of all tags */
return this.cache[this.userKey].tags.filter(
i => i.match(new RegExp(this.search, "i"))
)
},
sortBookmarks() {
/* Sort the bookmarks according to the setting */
if (this.sortTitleAsc) {
this.bookmarks.sort((a, b) => a.title.localeCompare(b.title));
} else if (this.sortTitleDesc) {
this.bookmarks.sort((a, b) => b.title.localeCompare(a.title));
} else if (this.sortCreatedAsc) {
this.bookmarks.sort((a, b) => a.created_date.localeCompare(b.created_date));
} else if (this.sortCreatedDesc) {
this.bookmarks.sort((a, b) => b.created_date.localeCompare(a.created_date));
}
},
async sortAlphabetically(order = 'asc') {
/* Sort the bookmarks (reverse) alphabetically, based on 'asc' or 'desc' */
this.loading = true;
this.sortCreatedAsc = false;
this.sortCreatedDesc = false;
this.sortTitleAsc = false;
this.sortTitleDesc = false;
if (order === 'desc') {
this.sortTitleDesc = true;
} else {
this.sortTitleAsc = true;
}
this.sortBookmarks();
this.loading = false;
},
async sortCreated(order = 'asc') {
/* Sort the bookmarks (reverse) chronologically, based on 'asc' or 'desc' */
this.loading = true;
this.sortCreatedAsc = false;
this.sortCreatedDesc = false;
this.sortTitleAsc = false;
this.sortTitleDesc = false;
if (order === 'desc') {
this.sortCreatedDesc = true;
} else {
this.sortCreatedAsc = true;
}
this.sortBookmarks();
this.loading = false;
},
async toggleTagPage() {
/* Show or hide the tag page instead of the bookmarks */
this.showBookmarks = !this.showBookmarks;
this.showTags = !this.showBookmarks;
},
async toggleListOrGrid() {
/* Toggle between 'list' or 'grid' (cards) view */
this.showBookmarksList = !this.showBookmarksList;
this.showBookmarksCards = !this.showBookmarksList;
},
async startAddingBookmark() {
/* Open 'add bookmark' page */
console.log('Start adding bookmark');
this.bookmarkToEdit = {
'url': ''
}
// this.show_bookmark_details = true;
const editFormDialog = document.getElementById("editFormDialog");
editFormDialog.showModal();
},
async saveBookmark() {
console.log('Saving bookmark');
// this.show_bookmark_details = false;
},
async addBookmark() {
/* Post new bookmark to the backend */
//
}
})
});

View File

@@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block title %}404: Page not found{% endblock %}
{% block page_header %}404: Page not found{% endblock %}
{% block page_content %}
The page you requested was not found.
{% endblock %}

View File

@@ -0,0 +1,33 @@
<!doctype html>
<html lang="{{ language }}">
<head>
<title>{% block title %}{% endblock %} - digimarks</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/digui.css?v={{ version }}">
<link rel="stylesheet" href="/static/css/digimarks.css?v={{ version }}">
<link id="favicon" rel="icon" type="image/x-icon" href="/static/images/favicon.ico">
<link rel="apple-touch-icon" sizes="180x180" href="/static/images/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon-16x16.png">
<link rel="manifest" href="/static/images/site.webmanifest">
<script src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-clipboard@2.x.x/dist/alpine-clipboard.js"
defer></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
{% block page_content %}
<div id="container" x-data="">
</div>
{% endblock %}
<!-- Scripts -->
<script src="/static/js/digimarks.js?v={{ version }}"></script>
{% block extrajs %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,127 @@
{% extends "base.html" %}
{% if not action %}
{% set action = 'Bookmarks' %}
{% endif %}
{% block title %}{{ action }}{% endblock %}
{% block page_header %}{{ action }}{% endblock %}
{% block page_content %}
{% if tag and not publictag %}
<div class="row">
<div class="col s12">
<a href="{{ url_for('addpublictag', userkey=userkey, tag=tag) }}">Create public page <i
class="material-icons right">tag</i></a>
</div>
</div>
{% endif %}
{% if tag and publictag %}
<div class="row">
<div class="col s12"><a href="{{ url_for('publictag_page', tagkey=publictag.tagkey) }}">Public link</a>
</div>
</div>
{% endif %}
{% if message %}
<div class="row">
<div class="col s12">
<div class="card-panel {{ theme.MESSAGE_BACKGROUND }}">
<span class="{{ theme.MESSAGE_TEXT }}">
{{ message|safe }}
</span>
</div>
</div>
</div>
{% endif %}
<div class="row">
<form action="{{ url_for('bookmarks_page', userkey=userkey) }}" name="filterForm" method="POST"
autocomplete="off">
<div class="input-field col l9 m9 s8">
<input placeholder="search text" type="text" name="filter_text" id="filter_text" class="autocomplete"
value="{{ filter_text }}" autocomplete="false"/>
</div>
<div class="input-field col l3 m3 s4">
<p class="right-align">
<button class="btn waves-effect waves-light" type="submit" name="submitBtn" title="Find"><i
class="material-icons">search</i></button>
{% if show_as and show_as == 'list' %}
<a href="{{ url_for('bookmarks_page', userkey=userkey, filtermethod=filtermethod, sortmethod=sortmethod, show_as=None) }}"
class="waves-effect waves-light btn" title="Show as cards"><i class="material-icons">apps</i></a>
{% else %}
<a href="{{ url_for('bookmarks_page', userkey=userkey, filtermethod=filtermethod, sortmethod=sortmethod, show_as='list') }}"
class="waves-effect waves-light btn" title="Show as list"><i
class="material-icons">reorder</i></a>
{% endif %}
</p>
</div>
</form>
</div>
{% if tags %}
<div class="row">
<div class="col s12">
<ul class="collapsible" data-collapsible="expandable">
<li>
<div class="collapsible-header"><i class="material-icons">label</i>Filter on
star/problem/comment/tag
</div>
<div class="collapsible-body" style="padding: 10px;">
<div class="chip">
<a href="{{ url_for('bookmarks_page', userkey=userkey, filtermethod='starred') }}"><i
class="tiny material-icons {{ theme.STAR }}">star</i></a>
</div>
<div class="chip">
<a href="{{ url_for('bookmarks_page', userkey=userkey, filtermethod='broken') }}"><i
class="tiny material-icons {{ theme.PROBLEM }}">report_problem</i></a>
</div>
<div class="chip">
<a href="{{ url_for('bookmarks_page', userkey=userkey, filtermethod='note') }}"><i
class="tiny material-icons {{ theme.COMMENT }}">comment</i></a>
</div>
{% for tag in tags %}
<div class="chip">
<a href="{{ url_for('tag_page', userkey=userkey, tag=tag) }}">{{ tag }}</a>
</div>
{% endfor %}
</li>
</ul>
</div>
</div>
{% endif %}
{% if show_as and show_as == 'list' %}
{% include 'list.html' %}
{% else %}
{% include 'cards.html' %}
{% endif %}
<div class="fixed-action-btn" style="bottom: 20px; right: 20px;">
<a class="btn-floating btn-large {{ theme.FAB }}" href="{{ url_for('addbookmark', userkey=userkey) }}">
<i class="large material-icons">add</i>
</a>
</div>
{% endblock %}
{% block extrajs %}
<script>
function submitFilter() {
document.filterForm.submit();
}
/* Search filter autocomplete */
var options = {
onAutocomplete: submitFilter,
minLength: 3,
limit: 10,
data: {},
}
var elem = document.querySelector('.autocomplete');
var instance = M.Autocomplete.init(elem, options);
/* TODO: fetch from API
instance.updateData({
});
*/
</script>
<script src="{{ url_for('bookmarks_js', userkey=userkey) }}"></script>
{% endblock %}

View File

@@ -0,0 +1,65 @@
<div class="row">
{% for bookmark in bookmarks %}
<div class="col s12 m6 l4">
<div class="card horizontal tiny {{ theme.CARD_BACKGROUND }}">
<div class="card-image">
{% if bookmark.favicon %}
<div><img src="{{ url_for('static', filename='favicons/' + bookmark.favicon) }}" class="favicon" /></div>
{% else %}
<div><img src="{{ url_for('static', filename='faviconfallback.png') }}" class="favicon" /></div>
{% endif %}
{% if bookmark.http_status != 200 and bookmark.http_status != 304 %}
<div><i class="small material-icons {{ theme.PROBLEM }}" title="HTTP status {{ bookmark.http_status }}">report_problem</i></div>
{% endif %}
{% if bookmark.starred == True %}
<div><i class="small material-icons {{ theme.STAR }}">star</i></div>
{% endif %}
{% if bookmark.note %}
<div><i class="small material-icons {{ theme.CARD_TEXT }}" title="{{ bookmark.note|truncate(100) }}">comment</i></div>
{% endif %}
</div>
<div class="card-stacked">
<div class="card-content {{ theme.CARD_TEXT }}">
<span class="digimark-card-header activator">
<i class="material-icons right">more_vert</i>
</span>
<div class="digimark-card-content">
<a href="{{ bookmark.url }}" title="{{ bookmark.url }}" rel="noreferrer noopener" target="_blank">
{% if bookmark.title %}
{{ bookmark.title }}
{% else %}
{{ bookmark.get_uri_domain() }} (no title)
{% endif %}
</a>
</div>
</div>
</div>
<div class="card-reveal {{ theme.CARD_BACKGROUND }}">
<span class="card-title {{ theme.CARD_TEXT }}">Added @ {{ bookmark.created_date.strftime('%Y-%m-%d %H:%M') }}<i class="material-icons right">close</i></span>
{% if editable %}
<div class="{{ theme.CARD_TEXT }}" style="padding-top: 10px;">
<a href="{{ url_for('editbookmark', userkey=userkey, urlhash=bookmark.url_hash) }}" style="padding: 3px"><i class="tiny material-icons">mode_edit</i> EDIT</a>
<a href="{{ url_for('deletingbookmark', userkey=userkey, urlhash=bookmark.url_hash) }}" style="padding: 3px" class="red-text"><i class="tiny material-icons">delete</i> DELETE</a>
</div>
{% endif %}
{% if showtags %}
<div class="digimark-card-header-tags">
{% for tag in bookmark.tags_list %}
<div class="chip">
<a href="{{ url_for('tag_page', userkey=userkey, tag=tag) }}">{{ tag }}</a>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{#
<div class="pagination">
{% if page > 1 %}<a href="./?page={{ page - 1 }}">Previous</a>{% endif %}
{% if pagination.get_pages() > page %}<a href="./?page={{ page + 1 }}">Next</a>{% endif %}
</div>
#}
</div>

View File

@@ -0,0 +1,175 @@
{% extends "base.html" %}
{% block title %}{{ action }}{% endblock %}
{% block pageheader %}{{ action }}{% endblock %}
{% block pagecontent %}
{% if bookmark.http_status != 200 and bookmark.http_status != 202 and bookmark.http_status != 304 %}
<div class="row">
<div class="col s12">
<div class="card-panel {{ theme.ERRORMESSAGE_BACKGROUND }}">
<span class="{{ theme.ERRORMESSAGE_TEXT }}">
{% if bookmark.http_status == 404 %}
<i class="material-icons">report_problem</i>&nbsp;&nbsp;URL not found (404), broken/outdated link?
{% elif bookmark.http_status == 301 %}
<i class="material-icons">report_problem</i>&nbsp;&nbsp;HTTP status (301), moved permanently. Use button for new target
{% elif bookmark.http_status == 302 %}
<i class="material-icons">report_problem</i>&nbsp;&nbsp;HTTP status (302), moved temporarily. Use button for new target
{% elif bookmark.http_status == bookmark.HTTP_CONNECTIONERROR %}
<i class="material-icons">report_problem</i>&nbsp;&nbsp;Connection error, server might have been offline at the time of last edit
{% else %}
<i class="material-icons">report_problem</i>&nbsp;&nbsp;HTTP status {{ bookmark.http_status }}
{% endif %}
</span>
</div>
</div>
</div>
{% endif %}
{% if message %}
<div class="row">
<div class="col s12">
<div class="card-panel {{ theme.MESSAGE_BACKGROUND }}">
<span class="{{ theme.MESSAGE_TEXT }}">
{{ message }}
</span>
</div>
</div>
</div>
{% endif %}
{% if formaction and formaction == 'edit' %}
<form class="digimark" id="digimark" action="{{ url_for('editingbookmark', userkey=userkey, urlhash=bookmark.url_hash) }}" method="POST" onsubmit="return onSubmitForm();" autocomplete="off">
{% else %}
<form class="digimark" id="digimark" action="{{ url_for('addingbookmark', userkey=userkey) }}" method="POST" onsubmit="return onSubmitForm();" autocomplete="off">
{% endif %}
<div class="row">
<div class="input-field col s12">
<i class="material-icons prefix">description</i>
<input placeholder="title (leave empty for autofetch)" type="text" name="title" id="title" value="{{ bookmark.title }}" autocomplete="false" />
<label for="title">Title</label>
{# <span class="helper-text">Leave title empty for autofetching from the page</span>#}
</div>
<div class="input-field col s12">
<i class="material-icons prefix">turned_in</i>
<input placeholder="url" type="text" name="url" id="url" value="{{ bookmark.url }}" autocomplete="false" />
<label for="url">URL</label>
{% if bookmark.get_redirect_uri() %}
<div>
<a class="waves-effect waves-light btn" id="btn_urlupdate" onclick="updateURL()"><i class="material-icons left">turned_in</i>{{ bookmark.get_redirect_uri() }}</a>
</div>
<script type="text/javascript">
function updateURL() {
var text = document.getElementById('url');
text.value = '{{ bookmark.get_redirect_uri() }}';
}
</script>
{% endif %}
</div>
<div class="input-field col s12">
<i class="material-icons prefix">comment</i>
<input placeholder="note" type="text" name="note" id="note" value="{{ bookmark.note }}" autocomplete="false" />
<label for="note">Note</label>
</div>
<div class="input-field col s12">
<i class="material-icons prefix">label</i>
<input placeholder="tags, divided by comma's" type="text" name="tags" id="tags" value="{{ bookmark.tags }}" autocomplete="false" />
<label for="tags">Tags</label>
</div>
</div>
{% if tags %}
<div class="row">
<div class="col s12">
<ul class="collapsible" data-collapsible="expandable">
<li>
<div class="collapsible-header"><i class="material-icons">label</i>Existing tags</div>
<div class="collapsible-body" style="padding: 10px;">
{% for tag in tags %}
<div class="chip clickable" id="chip_{{ tag }}" onclick="addTag('{{ tag }}');">
{{ tag }}
</div>
{% endfor %}
</div>
</li>
</ul>
</div>
</div>
{% endif %}
<div class="row">
<div class="col s12">
{#<i class="material-icons prefix">star</i>#}
<label>
<input type="checkbox" name="starred" id="starred" {% if bookmark.starred == True %}checked{% endif %} />
<span>Starred</span>
</label>
</div>
<div class="col s12">
<label>
<input type="checkbox" name="strip" id="strip" />
<span>Strip parameters from url (like <em>?utm_source=social</em> - can break the link!)</span>
</label>
</div>
{% if bookmark.url_hash %}
</div>
<div class="row">
<div class="col l4 m6 s12">
<table>
<tr>
<th>Added</th>
<td>{{ bookmark.created_date.strftime('%Y-%m-%d %H:%M') }}</td>
</tr>
{% if bookmark.modified_date %}
<tr>
<th>Modified</th>
<td>{{ bookmark.modified_date.strftime('%Y-%m-%d %H:%M') }}</td>
</tr>
{% endif %}
{% if bookmark.deleted_date %}
<tr>
<th>Deleted</th>
<td>{{ bookmark.deleted_date.strftime('%Y-%m-%d %H:%M') }}</td>
</tr>
{% endif %}
</table>
</div>
</div>
<div class="row">
{% endif %}
<div class="input-field col l2 m3 s4">
<p class="left-align"><button class="btn waves-effect waves-light" type="submit" name="submit" id="submit">Save <i class="material-icons right">send</i></button></p>
</div>
{% if bookmark.url_hash %}
</form>
<div class="input-field col l4 m4 s6">
<form action="{{ url_for('deletingbookmark', userkey=userkey, urlhash=bookmark.url_hash) }}" method="POST">
<p class="left-align"><button class="btn waves-effect waves-light deletebtn" type="submit" name="delete">Delete <i class="material-icons right">delete</i></button></p>
</form>
</div>
</div>
{% else %}
</div>
</form>
{% endif %}
<script>
function onSubmitForm()
{
var theForm = document.getElementById('digimark');
var submitButton = document.getElementById('submit');
theForm.onsubmit = submitButton.setAttribute("disabled", true);
return true;
}
function addTag(tagText)
{
var text = document.getElementById('tags');
text.value = text.value + ', ' + tagText;
}
</script>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}digimarks{% endblock %}
{% block page_header %}digimarks{% endblock %}
{% block page_content %}
<article>
<header>
<nav class="menu">
<ul>
<li><h1>digimarks</h1></li>
<li>
<a class="button" href="https://github.com/aquatix/digimarks">digimarks project page</a>
</li>
</ul>
</nav>
</header>
<main>
<h1>Welcome to digimarks, your online bookmarking and notes tool</h1>
<p>Please visit your personal url, or <a href="https://github.com/aquatix/digimarks">see the digimarks
project page</a>.</p>
<p>If you forgot/lost your personal url, contact your digimarks
administrator.{# On startup, the personal codes are printed to the standard output (so should be findable in a log). Of course, bookmarks.db contains the user information too.#}
</p>
</main>
</article>
{% endblock %}

View File

@@ -0,0 +1,62 @@
<div class="row">
<table>
<thead>
<tr>
<th>&nbsp;</th>
<th>Bookmark</th>
<th>Added</th>
{% if showtags %}
<th>Tags</th>
{% endif %}
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
{% for bookmark in bookmarks %}
<tr>
<td class="list-image">
{% if bookmark.favicon %}
<img src="{{ url_for('static', filename='favicons/' + bookmark.favicon) }}" class="favicon" />
{% else %}
<img src="{{ url_for('static', filename='faviconfallback.png') }}" class="favicon" />
{% endif %}
{% if bookmark.http_status != 200 and bookmark.http_status != 304 %}
<i class="small material-icons {{ theme.PROBLEM }}" title="HTTP status {{ bookmark.http_status }}">report_problem</i>
{% endif %}
{% if bookmark.starred == True %}
<i class="small material-icons {{ theme.STAR }}">star</i>
{% endif %}
{% if bookmark.note %}
<i class="small material-icons {{ theme.CARD_TEXT }}" title="{{ bookmark.note|truncate(100) }}">comment</i>
{% endif %}
</td>
<td>
<a href="{{ bookmark.url }}" title="{{ bookmark.url }}" rel="noreferrer noopener" target="_blank">
{% if bookmark.title %}
{{ bookmark.title }}
{% else %}
{{ bookmark.get_uri_domain() }} (no title)
{% endif %}
</a>
</td>
<td>{{ bookmark.created_date.strftime('%Y-%m-%d %H:%M') }}</td>
{% if showtags %}
<td>
{% for tag in bookmark.tags_list %}
<div class="chip">
<a href="{{ url_for('tag_page', userkey=userkey, tag=tag) }}">{{ tag }}</a>
</div>
{% endfor %}
</td>
{% endif %}
<td>
{% if editable %}
<a href="{{ url_for('editbookmark', userkey=userkey, urlhash=bookmark.url_hash) }}" class="waves-effect waves-light btn" title="Edit"><i class="tiny material-icons">mode_edit</i></a>
<a href="{{ url_for('deletingbookmark', userkey=userkey, urlhash=bookmark.url_hash) }}" class="waves-effect waves-light btn red" title="DELETE"><i class="tiny material-icons">delete</i></a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

View File

@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% if not action %}
{% set action = 'Bookmarks' %}
{% endif %}
{% block title %}{{ action }}{% endblock %}
{% block page_header %}{{ action }}{% endblock %}
{% block page_content %}
{% if message %}
<div class="row">
<div class="col s12">
<div class="card-panel orange lighten-2">
<span class="white-text">
{{ message }}
</span>
</div>
</div>
</div>
{% endif %}
<div class="row">
<div class="col s12">
<a href="{{ url_for('publictag_feed', tagkey=tagkey) }}"><i class="material-icons tiny">rss_feed</i>
feed</a>
</div>
</div>
{% include 'cards.html' %}
{% endblock %}

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
<title>Redirecting - digimarks</title>
<meta name="referrer" content="never">
<meta name="robots" content="noindex, nofollow">
<meta http-equiv=refresh content="3; URL={{ url }}">
<style>
body { background-color: #000; color: #FFF; }
a { color: #fb8c00; }
</style>
</head>
<body>
<p>You're being redirected. If nothing happens, <a href="{{ url }}">click here instead</a>.</p>
</body>
</html>

View File

@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block title %}Tags{% endblock %}
{% block page_header %}Tags{% endblock %}
{% block page_content %}
<div class="row">
<div class="col s12">
<table class="centered">
<thead>
<tr>
<th><i class="material-icons" title="Unique labels">label</i></th>
<th><i class="material-icons green-text" title="Public tag pages">present_to_all</i></th>
<th><i class="material-icons" title="Total bookmarks">turned_in</i></th>
<th><i class="material-icons" title="Bookmarks with notes">comment</i></th>
<th><i class="material-icons yellow-text" title="Starred bookmarks">star</i></th>
<th><i class="material-icons orange-text" title="HTTP status is not 200 OK">warning</i></th>
<th><i class="material-icons red-text" title="Deleted bookmarks">delete</i></th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ totaltags }}</td>
<td>{{ totalpublic }}</td>
<td>{{ totalbookmarks }}</td>
<td>{{ totalnotes }}</td>
<td>{{ totalstarred }}</td>
<td>{{ totalhttperrorstatus }}</td>
<td>{{ totaldeleted }}</td>
</tr>
</tbody>
</table>
<br/><br/>
<table>
<thead>
<tr>
<th>Tag</th>
<th>Public link</th>
<th>Number of bookmarks</th>
</tr>
</thead>
<tbody>
{% for tag in tags %}
<tr>
<td>
<a href="{{ url_for('tag_page', userkey=userkey, tag=tag['tag']) }}">{{ tag['tag'] }}</a>
</td>
<td>
{% if tag['publictag'] %}
<a href="{{ url_for('publictag_page', tagkey=tag['publictag'].tagkey) }}">Public
link</a> (
<a href="{{ url_for('removepublictag', tag=tag['tag'], tagkey=tag['publictag'].tagkey, userkey=userkey) }}">Delete</a>
<i class="tiny material-icons red-text">warning</i>)
{% else %}
<a href="{{ url_for('addpublictag', userkey=userkey, tag=tag['tag']) }}">Create</a>
{% endif %}
</td>
<td>
{{ tag['total'] }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,190 @@
{% extends "base.html" %}
{% block title %}Bookmarks{% endblock %}
{% block page_header %}Bookmarks{% endblock %}
{% block page_content %}
<article
x-init="$store.digimarks.userKey = '{{ user_key }}'; $store.digimarks.loadCache(); $store.digimarks.getBookmarks()"
x-data="">
<header>
<nav class="menu">
<ul>
<li><h1>digimarks</h1></li>
<li>
<button x-data @click="$store.digimarks.toggleTagPage()"
:class="$store.digimarks.showTags && 'active'">tags
</button>
</li>
<li>
<button @click="$store.digimarks.startAddingBookmark()">add bookmark</button>
</li>
<li>
<button @click="$store.digimarks.loopToNextTheme()" class="theme-toggle">theme</button>
</li>
<li><input x-model="$store.digimarks.search" placeholder="Search/filter..."></li>
<li x-show="$store.digimarks.loading"><i class="fa-solid fa-rotate-right fa-spin"></i></li>
</ul>
</nav>
</header>
<main>
<section x-cloak x-show="$store.digimarks.showBookmarks" x-transition.opacity>
<h1 x-bind:title="$store.digimarks.userKey">Bookmarks</h1>
<p>
<button @click="$store.digimarks.sortAlphabetically()"
:class="$store.digimarks.sortTitleAsc && 'active'">a-z &darr;
</button>
<button @click="$store.digimarks.sortAlphabetically('desc')"
:class="$store.digimarks.sortTitleDesc && 'active'">z-a &uarr;
</button>
<button @click="$store.digimarks.sortCreated()"
:class="$store.digimarks.sortCreatedAsc && 'active'">date &darr;
</button>
<button @click="$store.digimarks.sortCreated('desc')"
:class="$store.digimarks.sortCreatedDesc && 'active'">date &uarr;
</button>
<button @click="$store.digimarks.toggleListOrGrid()"
:class="$store.digimarks.showBookmarksCards && 'active'">list or grid
</button>
</p>
<table x-cloak x-show="$store.digimarks.showBookmarksList">
<thead>
<tr>
<th colspan="2">&nbsp;</th>
<th>Title</th>
<th>Note</th>
<th>Tags</th>
</tr>
</thead>
<tbody>
<template x-for="bookmark in $store.digimarks.filteredBookmarks" :key="bookmark.id">
<tr>
<td class="thumbnail">
<div class="card-thumb" x-show="bookmark.favicon"><img
x-bind:src="'/content/favicons/' + bookmark.favicon"></div>
</td>
<td>
<div x-show="bookmark.starred" class="star"><i class="fa-fw fa-solid fa-star"></i>
</div>
<div x-show="bookmark.http_status !== 200 && bookmark.http_status !== 304"
class="error"><i
class="fa-fw fa-solid fa-triangle-exclamation"></i>
</div>
</td>
<td><a x-text="bookmark.title" x-bind:href="bookmark.url" target="_blank"></a></td>
<td x-text="bookmark.note"></td>
<td>
<template x-for="tag in bookmark.tag_list">
<span x-text="tag" class="chip"></span>
</template>
</td>
</tr>
</template>
</tbody>
</table>
{#
<ul x-cloak x-show="$store.digimarks.show_bookmarks_list">
<template x-for="bookmark in $store.digimarks.filteredBookmarks" :key="bookmark.id">
<li><a x-text="bookmark.title" x-bind:href="bookmark.url" target="_blank"></a></li>
</template>
</ul>
#}
<section x-cloak x-show="$store.digimarks.showBookmarksCards" class="cards">
<template x-for="bookmark in $store.digimarks.filteredBookmarks" :key="bookmark.id">
<div class="card">
<div class="card-body">
<div class="card-thumb" x-show="bookmark.favicon"><img
x-bind:src="'/content/favicons/' + bookmark.favicon"></div>
<div class="statuses">
<div x-show="bookmark.starred" class="star"><i class="fa-fw fa-solid fa-star"></i>
</div>
<div x-show="bookmark.http_status !== 200 && bookmark.http_status !== 304"
class="error"><i
class="fa-fw fa-solid fa-triangle-exclamation"></i>
</div>
<div x-show="bookmark.note"><i class="fa-fw fa-regular fa-note-sticky"></i></div>
</div>
<div><a x-text="bookmark.title" x-bind:href="bookmark.url" target="_blank"></a></div>
</div>
<div class="card-footer">
<button title="show actions"><i class="fa-solid fa-square-caret-down"></i></button>
<div class="meta">
<template x-for="tag in bookmark.tag_list">
<span x-text="tag" class="chip"></span>
</template>
</div>
{# <div x-text="bookmark.created_date" class="meta"></div>#}
</div>
</div>
</template>
</section>
</section>
<section x-cloak x-show="$store.digimarks.showTags" x-transition.opacity>
<h1>Tags</h1>
<table>
<thead>
<tr>
<th>Tag</th>
<th>Public link</th>
<th>Number of bookmarks</th>
</tr>
</thead>
<tbody>
<template x-for="tag in $store.digimarks.filteredTags" :key="tag">
<tr>
<td x-text="tag"></td>
<td></td>
<td></td>
</tr>
</template>
</tbody>
</table>
</section>
<dialog x-cloak id="editFormDialog"
x-transition:enter="modal-enter"
x-transition:enter-start="modal-enter"
x-transition:enter-end="modal-enter-active"
x-transition:leave="modal-leave-active"
x-transition:leave-start="modal-enter-active"
x-transition:leave-end="modal-enter">
<h1>Add/Edit bookmark</h1>
{#
<div class="card-panel {{ theme.ERRORMESSAGE_BACKGROUND }}">
<span class="error">
{% if bookmark.http_status == 404 %}
<i class="material-icons">report_problem</i>&nbsp;&nbsp;URL not found (404), broken/outdated link?
{% elif bookmark.http_status == 301 %}
<i class="material-icons">report_problem</i>&nbsp;&nbsp;HTTP status (301), moved permanently. Use button for new target
{% elif bookmark.http_status == 302 %}
<i class="material-icons">report_problem</i>&nbsp;&nbsp;HTTP status (302), moved temporarily. Use button for new target
{% elif bookmark.http_status == bookmark.HTTP_CONNECTIONERROR %}
<i class="material-icons">report_problem</i>&nbsp;&nbsp;Connection error, server might have been offline at the time of last edit
{% else %}
<i class="material-icons">report_problem</i>&nbsp;&nbsp;HTTP status {{ bookmark.http_status }}
{% endif %}
</span>
</div>
#}
<form method="dialog">
<input type="text" name="">
<p>
<label>
<input type="checkbox" name="strip" id="strip"/>
<span>Strip parameters from url (like <em>?utm_source=social</em> - can break the link!)</span>
</label>
</p>
<div>
<button value="cancel">Cancel</button>
<button @click="$store.digimarks.saveBookmark()">Save</button>
</div>
</form>
</dialog>
</main>
</article>
{% endblock %}

21
tox.ini Normal file
View File

@@ -0,0 +1,21 @@
[flake8]
ignore = D203, W503
exclude =
.git,
__pycache__,
docs/source/conf.py,
build,
dist,
example_config/gunicorn_webhaak_conf.py,
example_config/rq_settings.example.py,
example_config/settings.py,
max-line-length = 120
max-complexity = 10
[pycodestyle]
max_line_length = 120
ignore = E501, W503
[isort]
line_length = 120
multi_line_output = 3

16
wsgi.py
View File

@@ -1,16 +0,0 @@
# Activate virtualenv
import settings
activate_this = getattr(settings, 'VENV', None)
# FIXME: python 2 *and* python 3 compatibility
# Python 2
#if activate_this:
# execfile(activate_this, dict(__file__=activate_this))
# Python 3
with open(activate_this) as file_:
exec(file_.read(), dict(__file__=activate_this))
from digimarks import app as application
if __name__ == "__main__":
# application is ran standalone
application.run(debug=settings.DEBUG)