mirror of
https://github.com/aquatix/digimarks.git
synced 2025-12-07 00:15:10 +01:00
Compare commits
8 Commits
f05525a9cd
...
modules
| Author | SHA1 | Date | |
|---|---|---|---|
| c1024485a3 | |||
| 9712b269ff | |||
| 51b94e4b64 | |||
| 099e6d7ed7 | |||
| bba8d86de1 | |||
| 1c5cedf759 | |||
| a7c7dae9fc | |||
| 958ff11a99 |
@@ -1,4 +0,0 @@
|
|||||||
---
|
|
||||||
exclude_paths:
|
|
||||||
- "example_config/**"
|
|
||||||
- "docs/source/**"
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -88,9 +88,6 @@ ENV/
|
|||||||
# Rope project settings
|
# Rope project settings
|
||||||
.ropeproject
|
.ropeproject
|
||||||
|
|
||||||
# JetBrains PyCharm/Idea
|
|
||||||
.idea
|
|
||||||
|
|
||||||
# vim
|
# vim
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
|
|||||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -22,21 +22,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### Added
|
||||||
- 'lightblue' theme
|
- 'lightblue' theme
|
||||||
- 'black amoled' theme
|
- 'black amoled' theme
|
||||||
|
|||||||
43
README.rst
43
README.rst
@@ -33,48 +33,16 @@ necessary packages:
|
|||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
|
||||||
Migrating from version 1
|
|
||||||
------------------------
|
|
||||||
|
|
||||||
To be able to use the new database schema's, you will need to migrate your existing ``bookmarks.db`` to one under the control of the ``alembic`` migrations tool.
|
|
||||||
|
|
||||||
To do so, start with making a backup of this ``bookmarks.db`` file to a safe place.
|
|
||||||
|
|
||||||
Then, stamp the initial migration into the database, and migrate to the latest version:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
alembic stamp 115bcd2e1a38
|
|
||||||
|
|
||||||
alembic upgrade head
|
|
||||||
|
|
||||||
|
|
||||||
Usage / example configuration
|
Usage / example configuration
|
||||||
-----------------------------
|
-----------------------------
|
||||||
|
|
||||||
OUT OF DATE!
|
|
||||||
|
|
||||||
Copy ``settings.py`` from example_config to the parent directory and
|
Copy ``settings.py`` from example_config to the parent directory and
|
||||||
configure to your needs (*at the least* change the value of `SYSTEMKEY`).
|
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
|
Run digimarks as a service under nginx or apache and call the appropriate
|
||||||
url's when wanted.
|
url's when wanted.
|
||||||
|
|
||||||
Url's are of the form ``https://marks.example.com/<userkey>/<action>``
|
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
|
Bookmarklet
|
||||||
@@ -104,9 +72,8 @@ If you for whatever reason would lose this user key, just either look on the con
|
|||||||
Server configuration
|
Server configuration
|
||||||
~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
* `systemd for digimarks API`_ which uses the `gunicorn config`_
|
* `vhost for Apache2.4`_
|
||||||
* `nginx for digimarks API`_
|
* `uwsgi.ini`_
|
||||||
* `more config`_
|
|
||||||
|
|
||||||
|
|
||||||
What's new?
|
What's new?
|
||||||
@@ -122,6 +89,7 @@ Attributions
|
|||||||
|
|
||||||
|
|
||||||
.. _digimarks: https://github.com/aquatix/digimarks
|
.. _digimarks: https://github.com/aquatix/digimarks
|
||||||
|
.. _webhook: https://en.wikipedia.org/wiki/Webhook
|
||||||
.. |PyPI version| image:: https://img.shields.io/pypi/v/digimarks.svg
|
.. |PyPI version| image:: https://img.shields.io/pypi/v/digimarks.svg
|
||||||
:target: https://pypi.python.org/pypi/digimarks/
|
:target: https://pypi.python.org/pypi/digimarks/
|
||||||
.. |PyPI license| image:: https://img.shields.io/github/license/aquatix/digimarks.svg
|
.. |PyPI license| image:: https://img.shields.io/github/license/aquatix/digimarks.svg
|
||||||
@@ -137,6 +105,3 @@ Attributions
|
|||||||
.. _uwsgi.ini: https://github.com/aquatix/digimarks/blob/master/example_config/uwsgi.ini
|
.. _uwsgi.ini: https://github.com/aquatix/digimarks/blob/master/example_config/uwsgi.ini
|
||||||
.. _Changelog: https://github.com/aquatix/digimarks/blob/master/CHANGELOG.md
|
.. _Changelog: https://github.com/aquatix/digimarks/blob/master/CHANGELOG.md
|
||||||
.. _Freepik: http://www.flaticon.com/free-icon/letter-m_2041
|
.. _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
|
|
||||||
|
|||||||
147
alembic.ini
147
alembic.ini
@@ -1,147 +0,0 @@
|
|||||||
# A generic, single database configuration.
|
|
||||||
|
|
||||||
[alembic]
|
|
||||||
# path to migration scripts.
|
|
||||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
|
||||||
# format, relative to the token %(here)s which refers to the location of this
|
|
||||||
# ini file
|
|
||||||
script_location = %(here)s/migrations
|
|
||||||
|
|
||||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
|
||||||
# Uncomment the line below if you want the files to be prepended with date and time
|
|
||||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
|
||||||
# for all available tokens
|
|
||||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
|
||||||
|
|
||||||
# sys.path path, will be prepended to sys.path if present.
|
|
||||||
# defaults to the current working directory. for multiple paths, the path separator
|
|
||||||
# is defined by "path_separator" below.
|
|
||||||
prepend_sys_path = .
|
|
||||||
|
|
||||||
# timezone to use when rendering the date within the migration file
|
|
||||||
# as well as the filename.
|
|
||||||
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
|
||||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
|
||||||
# string value is passed to ZoneInfo()
|
|
||||||
# leave blank for localtime
|
|
||||||
# timezone =
|
|
||||||
|
|
||||||
# max length of characters to apply to the "slug" field
|
|
||||||
# truncate_slug_length = 40
|
|
||||||
|
|
||||||
# set to 'true' to run the environment during
|
|
||||||
# the 'revision' command, regardless of autogenerate
|
|
||||||
# revision_environment = false
|
|
||||||
|
|
||||||
# set to 'true' to allow .pyc and .pyo files without
|
|
||||||
# a source .py file to be detected as revisions in the
|
|
||||||
# versions/ directory
|
|
||||||
# sourceless = false
|
|
||||||
|
|
||||||
# version location specification; This defaults
|
|
||||||
# to <script_location>/versions. When using multiple version
|
|
||||||
# directories, initial revisions must be specified with --version-path.
|
|
||||||
# The path separator used here should be the separator specified by "path_separator"
|
|
||||||
# below.
|
|
||||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
|
||||||
|
|
||||||
# path_separator; This indicates what character is used to split lists of file
|
|
||||||
# paths, including version_locations and prepend_sys_path within configparser
|
|
||||||
# files such as alembic.ini.
|
|
||||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
|
||||||
# to provide os-dependent path splitting.
|
|
||||||
#
|
|
||||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
|
||||||
# take place if path_separator is not present in alembic.ini. If this
|
|
||||||
# option is omitted entirely, fallback logic is as follows:
|
|
||||||
#
|
|
||||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
|
||||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
|
||||||
# behavior of splitting on spaces and/or commas.
|
|
||||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
|
||||||
# behavior of splitting on spaces, commas, or colons.
|
|
||||||
#
|
|
||||||
# Valid values for path_separator are:
|
|
||||||
#
|
|
||||||
# path_separator = :
|
|
||||||
# path_separator = ;
|
|
||||||
# path_separator = space
|
|
||||||
# path_separator = newline
|
|
||||||
#
|
|
||||||
# Use os.pathsep. Default configuration used for new projects.
|
|
||||||
path_separator = os
|
|
||||||
|
|
||||||
|
|
||||||
# set to 'true' to search source files recursively
|
|
||||||
# in each "version_locations" directory
|
|
||||||
# new in Alembic version 1.10
|
|
||||||
# recursive_version_locations = false
|
|
||||||
|
|
||||||
# the output encoding used when revision files
|
|
||||||
# are written from script.py.mako
|
|
||||||
# output_encoding = utf-8
|
|
||||||
|
|
||||||
# database URL. This is consumed by the user-maintained env.py script only.
|
|
||||||
# other means of configuring database URLs may be customized within the env.py
|
|
||||||
# file.
|
|
||||||
sqlalchemy.url = sqlite+aiosqlite:///bookmarks.db
|
|
||||||
|
|
||||||
|
|
||||||
[post_write_hooks]
|
|
||||||
# post_write_hooks defines scripts or Python functions that are run
|
|
||||||
# on newly generated revision scripts. See the documentation for further
|
|
||||||
# detail and examples
|
|
||||||
|
|
||||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
|
||||||
# hooks = black
|
|
||||||
# black.type = console_scripts
|
|
||||||
# black.entrypoint = black
|
|
||||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
|
||||||
|
|
||||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
|
||||||
# hooks = ruff
|
|
||||||
# ruff.type = module
|
|
||||||
# ruff.module = ruff
|
|
||||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
|
||||||
|
|
||||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
|
||||||
# hooks = ruff
|
|
||||||
# ruff.type = exec
|
|
||||||
# ruff.executable = ruff
|
|
||||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
|
||||||
|
|
||||||
# Logging configuration. This is also consumed by the user-maintained
|
|
||||||
# env.py script only.
|
|
||||||
[loggers]
|
|
||||||
keys = root,sqlalchemy,alembic
|
|
||||||
|
|
||||||
[handlers]
|
|
||||||
keys = console
|
|
||||||
|
|
||||||
[formatters]
|
|
||||||
keys = generic
|
|
||||||
|
|
||||||
[logger_root]
|
|
||||||
level = WARNING
|
|
||||||
handlers = console
|
|
||||||
qualname =
|
|
||||||
|
|
||||||
[logger_sqlalchemy]
|
|
||||||
level = WARNING
|
|
||||||
handlers =
|
|
||||||
qualname = sqlalchemy.engine
|
|
||||||
|
|
||||||
[logger_alembic]
|
|
||||||
level = INFO
|
|
||||||
handlers =
|
|
||||||
qualname = alembic
|
|
||||||
|
|
||||||
[handler_console]
|
|
||||||
class = StreamHandler
|
|
||||||
args = (sys.stderr,)
|
|
||||||
level = NOTSET
|
|
||||||
formatter = generic
|
|
||||||
|
|
||||||
[formatter_generic]
|
|
||||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
||||||
datefmt = %H:%M:%S
|
|
||||||
663
digimarks.py
Normal file
663
digimarks.py
Normal file
@@ -0,0 +1,663 @@
|
|||||||
|
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)
|
||||||
321
digimarks/models.py
Normal file
321
digimarks/models.py
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
"""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)
|
||||||
126
digimarks/themes.py
Normal file
126
digimarks/themes.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""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': '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
2
digimarks/views.py
Normal file
2
digimarks/views.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""digimarks views"""
|
||||||
|
|
||||||
@@ -10,10 +10,6 @@ DEBUG = False
|
|||||||
# echo -n "yourstring" | sha1sum
|
# echo -n "yourstring" | sha1sum
|
||||||
SYSTEMKEY = 'S3kr1t'
|
SYSTEMKEY = 'S3kr1t'
|
||||||
|
|
||||||
# RapidAPI key for favicons
|
|
||||||
# https://rapidapi.com/realfavicongenerator/api/realfavicongenerator
|
|
||||||
MASHAPE_API_KEY = 'your_MASHAPE_key'
|
|
||||||
|
|
||||||
LOG_LOCATION = 'digimarks.log'
|
LOG_LOCATION = 'digimarks.log'
|
||||||
#LOG_LOCATION = '/var/log/digimarks/digimarks.log'
|
#LOG_LOCATION = '/var/log/digimarks/digimarks.log'
|
||||||
# How many logs to keep in log rotation:
|
# How many logs to keep in log rotation:
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
Generic single-database configuration with an async dbapi.
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from logging.config import fileConfig
|
|
||||||
|
|
||||||
from alembic import context
|
|
||||||
from sqlalchemy import pool
|
|
||||||
from sqlalchemy.engine import Connection
|
|
||||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
|
|
||||||
from src.digimarks.models import Bookmark, PublicTag, User
|
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
|
||||||
# access to the values within the .ini file in use.
|
|
||||||
config = context.config
|
|
||||||
|
|
||||||
# Interpret the config file for Python logging.
|
|
||||||
# This line sets up loggers basically.
|
|
||||||
if config.config_file_name is not None:
|
|
||||||
fileConfig(config.config_file_name)
|
|
||||||
|
|
||||||
# add your model's MetaData object here
|
|
||||||
# for 'autogenerate' support
|
|
||||||
# from myapp import mymodel
|
|
||||||
# target_metadata = mymodel.Base.metadata
|
|
||||||
target_metadata = SQLModel.metadata
|
|
||||||
|
|
||||||
# other values from the config, defined by the needs of env.py,
|
|
||||||
# can be acquired:
|
|
||||||
# my_important_option = config.get_main_option("my_important_option")
|
|
||||||
# ... etc.
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
|
||||||
"""Run migrations in 'offline' mode.
|
|
||||||
|
|
||||||
This configures the context with just a URL
|
|
||||||
and not an Engine, though an Engine is acceptable
|
|
||||||
here as well. By skipping the Engine creation
|
|
||||||
we don't even need a DBAPI to be available.
|
|
||||||
|
|
||||||
Calls to context.execute() here emit the given string to the
|
|
||||||
script output.
|
|
||||||
|
|
||||||
"""
|
|
||||||
url = config.get_main_option('sqlalchemy.url')
|
|
||||||
context.configure(
|
|
||||||
url=url,
|
|
||||||
target_metadata=target_metadata,
|
|
||||||
literal_binds=True,
|
|
||||||
dialect_opts={'paramstyle': 'named'},
|
|
||||||
render_as_batch=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
def do_run_migrations(connection: Connection) -> None:
|
|
||||||
context.configure(
|
|
||||||
connection=connection,
|
|
||||||
target_metadata=target_metadata,
|
|
||||||
render_as_batch=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
async def run_async_migrations() -> None:
|
|
||||||
"""In this scenario we need to create an Engine and associate a connection with the context."""
|
|
||||||
connectable = async_engine_from_config(
|
|
||||||
config.get_section(config.config_ini_section, {}),
|
|
||||||
prefix='sqlalchemy.',
|
|
||||||
poolclass=pool.NullPool,
|
|
||||||
)
|
|
||||||
|
|
||||||
async with connectable.connect() as connection:
|
|
||||||
await connection.run_sync(do_run_migrations)
|
|
||||||
|
|
||||||
await connectable.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online() -> None:
|
|
||||||
"""Run migrations in 'online' mode."""
|
|
||||||
asyncio.run(run_async_migrations())
|
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
|
||||||
run_migrations_offline()
|
|
||||||
else:
|
|
||||||
run_migrations_online()
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
"""${message}
|
|
||||||
|
|
||||||
Revision ID: ${up_revision}
|
|
||||||
Revises: ${down_revision | comma,n}
|
|
||||||
Create Date: ${create_date}
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
import sqlmodel
|
|
||||||
${imports if imports else ""}
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = ${repr(up_revision)}
|
|
||||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
||||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Upgrade schema."""
|
|
||||||
${upgrades if upgrades else "pass"}
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade schema."""
|
|
||||||
${downgrades if downgrades else "pass"}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
"""Initial migration
|
|
||||||
|
|
||||||
Revision ID: 115bcd2e1a38
|
|
||||||
Revises:
|
|
||||||
Create Date: 2025-09-12 16:06:16.479075
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '115bcd2e1a38'
|
|
||||||
down_revision: Union[str, Sequence[str], None] = None
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Upgrade schema."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('bookmark',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('userkey', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('title', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('url', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('created_date', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('url_hash', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('tags', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('http_status', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('modified_date', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('favicon', sa.String(length=255), nullable=True),
|
|
||||||
sa.Column('starred', sa.Boolean(), server_default=sa.text('0'), nullable=True),
|
|
||||||
sa.Column('deleted_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True),
|
|
||||||
sa.Column('status', sa.Integer(), server_default=sa.text('0'), nullable=True),
|
|
||||||
sa.Column('note', sa.Text(), server_default=sa.text('(null)'), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('publictag',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('tagkey', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('userkey', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('tag', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('created_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('user',
|
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('username', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('key', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('created_date', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('theme', sa.String(length=20), server_default=sa.text("'green'"), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade schema."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_table('user')
|
|
||||||
op.drop_table('publictag')
|
|
||||||
op.drop_table('bookmark')
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
"""Migrate to sqlmodel.
|
|
||||||
|
|
||||||
Revision ID: a8d8e45f60a1
|
|
||||||
Revises: 115bcd2e1a38
|
|
||||||
Create Date: 2025-09-12 16:10:41.378716
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
import sqlalchemy as sa
|
|
||||||
import sqlmodel
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = 'a8d8e45f60a1'
|
|
||||||
down_revision: Union[str, Sequence[str], None] = '115bcd2e1a38'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Upgrade schema."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
with op.batch_alter_table('bookmark', schema=None) as batch_op:
|
|
||||||
batch_op.alter_column('note',
|
|
||||||
existing_type=sa.TEXT(),
|
|
||||||
type_=sqlmodel.sql.sqltypes.AutoString(),
|
|
||||||
nullable=True,
|
|
||||||
existing_server_default=sa.text('(null)'))
|
|
||||||
batch_op.alter_column('starred',
|
|
||||||
existing_type=sa.BOOLEAN(),
|
|
||||||
nullable=False,
|
|
||||||
existing_server_default=sa.text('0'))
|
|
||||||
batch_op.alter_column('modified_date',
|
|
||||||
existing_type=sa.DATETIME(),
|
|
||||||
nullable=True)
|
|
||||||
batch_op.alter_column('deleted_date',
|
|
||||||
existing_type=sa.DATETIME(),
|
|
||||||
nullable=True,
|
|
||||||
existing_server_default=sa.text('(null)'))
|
|
||||||
batch_op.alter_column('status',
|
|
||||||
existing_type=sa.INTEGER(),
|
|
||||||
nullable=False,
|
|
||||||
existing_server_default=sa.text('0'))
|
|
||||||
batch_op.create_foreign_key('bookmark_user', 'user', ['userkey'], ['key'])
|
|
||||||
with op.batch_alter_table('publictag', schema=None) as batch_op:
|
|
||||||
batch_op.alter_column('created_date',
|
|
||||||
existing_type=sa.DATETIME(),
|
|
||||||
nullable=True,
|
|
||||||
existing_server_default=sa.text(str(datetime.now(UTC))))
|
|
||||||
batch_op.create_foreign_key('publictag_user', 'user', ['userkey'], ['key'])
|
|
||||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
|
||||||
batch_op.alter_column('theme',
|
|
||||||
existing_type=sa.VARCHAR(length=20),
|
|
||||||
nullable=False,
|
|
||||||
existing_server_default=sa.text("'green'"))
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade schema."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.alter_column('user', 'theme',
|
|
||||||
existing_type=sa.VARCHAR(length=20),
|
|
||||||
nullable=True,
|
|
||||||
existing_server_default=sa.text("'green'"))
|
|
||||||
op.drop_constraint(None, 'publictag', type_='foreignkey')
|
|
||||||
op.alter_column('publictag', 'created_date',
|
|
||||||
existing_type=sa.DATETIME(),
|
|
||||||
nullable=True,
|
|
||||||
existing_server_default=sa.text('(null)'))
|
|
||||||
op.drop_constraint(None, 'bookmark', type_='foreignkey')
|
|
||||||
op.alter_column('bookmark', 'status',
|
|
||||||
existing_type=sa.INTEGER(),
|
|
||||||
nullable=True,
|
|
||||||
existing_server_default=sa.text('0'))
|
|
||||||
op.alter_column('bookmark', 'deleted_date',
|
|
||||||
existing_type=sa.DATETIME(),
|
|
||||||
nullable=True,
|
|
||||||
existing_server_default=sa.text('(null)'))
|
|
||||||
op.alter_column('bookmark', 'modified_date',
|
|
||||||
existing_type=sa.DATETIME(),
|
|
||||||
nullable=True)
|
|
||||||
op.alter_column('bookmark', 'starred',
|
|
||||||
existing_type=sa.BOOLEAN(),
|
|
||||||
nullable=True,
|
|
||||||
existing_server_default=sa.text('0'))
|
|
||||||
op.alter_column('bookmark', 'note',
|
|
||||||
existing_type=sqlmodel.sql.sqltypes.AutoString(),
|
|
||||||
type_=sa.TEXT(),
|
|
||||||
nullable=True,
|
|
||||||
existing_server_default=sa.text('(null)'))
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"""Renamed keys
|
|
||||||
|
|
||||||
Revision ID: b8cbc6957df5
|
|
||||||
Revises: a8d8e45f60a1
|
|
||||||
Create Date: 2025-09-12 22:26:38.684120
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
import sqlmodel
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = 'b8cbc6957df5'
|
|
||||||
down_revision: Union[str, Sequence[str], None] = 'a8d8e45f60a1'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Upgrade schema."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
with op.batch_alter_table('bookmark', schema=None) as batch_op:
|
|
||||||
batch_op.drop_constraint(batch_op.f('bookmark_user'), type_='foreignkey')
|
|
||||||
batch_op.alter_column('userkey', new_column_name='user_key')
|
|
||||||
batch_op.create_foreign_key('bookmark_user', 'user', ['user_key'], ['key'])
|
|
||||||
|
|
||||||
with op.batch_alter_table('publictag', schema=None) as batch_op:
|
|
||||||
batch_op.drop_constraint(batch_op.f('publictag_user'), type_='foreignkey')
|
|
||||||
batch_op.alter_column('userkey', new_column_name='user_key')
|
|
||||||
batch_op.alter_column('tagkey', new_column_name='tag_key')
|
|
||||||
batch_op.create_foreign_key('publictag_user', 'user', ['user_key'], ['key'])
|
|
||||||
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade schema."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
with op.batch_alter_table('publictag', schema=None) as batch_op:
|
|
||||||
batch_op.drop_constraint(batch_op.f('publictag_user'), type_='foreignkey')
|
|
||||||
batch_op.alter_column('user_key', new_column_name='userkey')
|
|
||||||
batch_op.alter_column('tag_key', new_column_name='tagkey')
|
|
||||||
batch_op.create_foreign_key('publictag_user', 'user', ['userkey'], ['key'])
|
|
||||||
|
|
||||||
with op.batch_alter_table('bookmark', schema=None) as batch_op:
|
|
||||||
batch_op.drop_constraint(batch_op.f('bookmark_user'), type_='foreignkey')
|
|
||||||
batch_op.alter_column('user_key', new_column_name='userkey')
|
|
||||||
batch_op.create_foreign_key('bookmark_user', 'user', ['userkey'], ['key'])
|
|
||||||
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
22
pylintrc
22
pylintrc
@@ -1,22 +0,0 @@
|
|||||||
[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,
|
|
||||||
_
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
[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]",
|
|
||||||
"sqlmodel",
|
|
||||||
"alembic",
|
|
||||||
"aiosqlite",
|
|
||||||
"pydantic>2.0",
|
|
||||||
"httpx",
|
|
||||||
"beautifulsoup4",
|
|
||||||
"extract_favicon",
|
|
||||||
"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
|
|
||||||
@@ -1,11 +1,3 @@
|
|||||||
-r requirements.in
|
-r requirements.in
|
||||||
|
|
||||||
# Linting and fixing, including isort
|
pylint
|
||||||
ruff
|
|
||||||
|
|
||||||
# Test suite
|
|
||||||
pytest
|
|
||||||
|
|
||||||
# Publishing on PyPI
|
|
||||||
build
|
|
||||||
twine
|
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
-r requirements.txt
|
#
|
||||||
|
# This file is autogenerated by pip-compile
|
||||||
# Linting and fixing, including isort
|
# To update, run:
|
||||||
ruff
|
#
|
||||||
|
# pip-compile --output-file requirements-dev.txt requirements-dev.in
|
||||||
# Test suite
|
#
|
||||||
pytest
|
astroid==2.0.4 # via pylint
|
||||||
|
beautifulsoup4==4.6.3 # via bs4
|
||||||
# Publishing on PyPI
|
bs4==0.0.1
|
||||||
build
|
certifi==2018.10.15 # via requests
|
||||||
twine
|
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
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
-r requirements.in
|
|
||||||
|
|
||||||
gunicorn
|
|
||||||
@@ -1,14 +1,4 @@
|
|||||||
# Core application
|
flask
|
||||||
fastapi[all]
|
peewee
|
||||||
sqlmodel
|
bs4
|
||||||
alembic
|
requests
|
||||||
aiosqlite
|
|
||||||
|
|
||||||
# Fetch title etc from links
|
|
||||||
beautifulsoup4
|
|
||||||
|
|
||||||
# Fetch favicons
|
|
||||||
extract_favicon
|
|
||||||
|
|
||||||
# Generate (atom) feeds for tags and such
|
|
||||||
feedgen
|
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
# Core application
|
#
|
||||||
fastapi[all]
|
# This file is autogenerated by pip-compile
|
||||||
sqlmodel
|
# To update, run:
|
||||||
alembic
|
#
|
||||||
aiosqlite
|
# pip-compile --output-file requirements.txt requirements.in
|
||||||
|
#
|
||||||
# Fetch title etc from links
|
beautifulsoup4==4.6.3 # via bs4
|
||||||
beautifulsoup4
|
bs4==0.0.1
|
||||||
|
certifi==2018.10.15 # via requests
|
||||||
# Fetch favicons
|
chardet==3.0.4 # via requests
|
||||||
extract_favicon
|
click==7.0 # via flask
|
||||||
|
flask==1.0.2
|
||||||
# Generate (atom) feeds for tags and such
|
idna==2.7 # via requests
|
||||||
feedgen
|
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
|
||||||
|
|||||||
46
setup.py
46
setup.py
@@ -1,7 +1,43 @@
|
|||||||
#!/usr/bin/env python
|
"""
|
||||||
"""Install script for module installation. Compatibility stub because pyproject.toml is used."""
|
A setuptools based setup module.
|
||||||
|
See:
|
||||||
|
https://packaging.python.org/en/latest/distributing.html
|
||||||
|
https://github.com/pypa/sampleproject
|
||||||
|
"""
|
||||||
|
|
||||||
import setuptools
|
from setuptools import setup
|
||||||
|
# To use a consistent encoding
|
||||||
|
from codecs import open as codecopen
|
||||||
|
from os import path
|
||||||
|
|
||||||
if __name__ == "__main__":
|
here = path.abspath(path.dirname(__file__))
|
||||||
setuptools.setup()
|
|
||||||
|
# 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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
"""Bookmark helper functions, like content scrapers, favicon extractor, updater functions."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import Annotated, Sequence
|
|
||||||
from urllib.parse import urlparse, urlunparse
|
|
||||||
|
|
||||||
import bs4
|
|
||||||
import httpx
|
|
||||||
from extract_favicon import from_html
|
|
||||||
from fastapi import Query, Request
|
|
||||||
from pydantic import AnyUrl
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from src.digimarks import tags_service, utils
|
|
||||||
from src.digimarks.exceptions import BookmarkNotFound
|
|
||||||
from src.digimarks.models import Bookmark, Visibility
|
|
||||||
|
|
||||||
DIGIMARKS_USER_AGENT = 'digimarks/2.0.0-dev'
|
|
||||||
|
|
||||||
logger = logging.getLogger('digimarks')
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def set_information_from_source(logger, 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 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
|
|
||||||
tags_service.set_tags(bookmark, bookmark.tags)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_bookmarks_for_user(
|
|
||||||
session,
|
|
||||||
user_key: str,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: Annotated[int, Query(le=10000)] = 100,
|
|
||||||
) -> Sequence[Bookmark]:
|
|
||||||
"""List all bookmarks in the database. By default, 100 items are returned."""
|
|
||||||
result = await session.exec(
|
|
||||||
select(Bookmark)
|
|
||||||
.where(Bookmark.user_key == user_key, Bookmark.status != Visibility.DELETED)
|
|
||||||
.offset(offset)
|
|
||||||
.limit(limit)
|
|
||||||
)
|
|
||||||
bookmarks = result.all()
|
|
||||||
return bookmarks
|
|
||||||
|
|
||||||
|
|
||||||
async def get_bookmark_for_user_with_url_hash(session, user_key: str, url_hash: str) -> Bookmark:
|
|
||||||
"""Get a bookmark from the database by its URL hash."""
|
|
||||||
result = await session.exec(
|
|
||||||
select(Bookmark).where(
|
|
||||||
Bookmark.user_key == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not result.first():
|
|
||||||
raise BookmarkNotFound(f'url_hash: {url_hash}')
|
|
||||||
return result.first()
|
|
||||||
|
|
||||||
|
|
||||||
async def autocomplete_bookmark(
|
|
||||||
session,
|
|
||||||
request: Request,
|
|
||||||
user_key: str,
|
|
||||||
bookmark: Bookmark,
|
|
||||||
strip_params: bool = False,
|
|
||||||
):
|
|
||||||
"""Autofill some fields for this (new) bookmark for user `user_key`."""
|
|
||||||
bookmark.user_key = user_key
|
|
||||||
|
|
||||||
# Auto-fill title, fix tags etc.
|
|
||||||
update_bookmark_with_info(bookmark, request, strip_params)
|
|
||||||
|
|
||||||
url_hash = utils.generate_hash(str(bookmark.url))
|
|
||||||
result = await session.exec(
|
|
||||||
select(Bookmark).where(
|
|
||||||
Bookmark.user_key == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
|
|
||||||
)
|
|
||||||
)
|
|
||||||
bookmark_db = result.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
|
|
||||||
|
|
||||||
|
|
||||||
async def add_bookmark(
|
|
||||||
session,
|
|
||||||
request: Request,
|
|
||||||
user_key: str,
|
|
||||||
bookmark: Bookmark,
|
|
||||||
strip_params: bool = False,
|
|
||||||
):
|
|
||||||
"""Add new bookmark for user `user_key`."""
|
|
||||||
bookmark.user_key = user_key
|
|
||||||
|
|
||||||
# Auto-fill title, fix tags etc.
|
|
||||||
update_bookmark_with_info(bookmark, request, strip_params)
|
|
||||||
bookmark.url_hash = utils.generate_hash(str(bookmark.url))
|
|
||||||
logger.info('Adding bookmark %s for user %s', bookmark.url_hash, user_key)
|
|
||||||
|
|
||||||
session.add(bookmark)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(bookmark)
|
|
||||||
return bookmark
|
|
||||||
|
|
||||||
|
|
||||||
async def update_bookmark(
|
|
||||||
session,
|
|
||||||
request: Request,
|
|
||||||
user_key: str,
|
|
||||||
bookmark: Bookmark,
|
|
||||||
url_hash: str,
|
|
||||||
strip_params: bool = False,
|
|
||||||
):
|
|
||||||
"""Update existing bookmark `bookmark_key` for user `user_key`."""
|
|
||||||
result = await session.exec(
|
|
||||||
select(Bookmark).where(
|
|
||||||
Bookmark.user_key == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
|
|
||||||
)
|
|
||||||
)
|
|
||||||
bookmark_db = result.first()
|
|
||||||
if not bookmark_db:
|
|
||||||
raise BookmarkNotFound(message='Bookmark with hash {url_hash} 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)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(bookmark_db)
|
|
||||||
return bookmark_db
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_bookmark(
|
|
||||||
session,
|
|
||||||
user_key: str,
|
|
||||||
url_hash: str,
|
|
||||||
):
|
|
||||||
"""(Soft)Delete bookmark `bookmark_key` for user `user_key`."""
|
|
||||||
result = await session.get(Bookmark, {'url_hash': url_hash, 'user_key': user_key})
|
|
||||||
bookmark = result
|
|
||||||
if not bookmark:
|
|
||||||
raise BookmarkNotFound(message='Bookmark with hash {url_hash} not found')
|
|
||||||
bookmark.deleted_date = datetime.now(UTC)
|
|
||||||
bookmark.status = Visibility.DELETED
|
|
||||||
session.add(bookmark)
|
|
||||||
await session.commit()
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""Exceptions that could be encountered managing digimarks."""
|
|
||||||
|
|
||||||
|
|
||||||
class BookmarkNotFound(Exception):
|
|
||||||
def __init__(self, message='Bookmark not found'):
|
|
||||||
"""Initialise the exception.
|
|
||||||
|
|
||||||
:param str message: The message for the exception
|
|
||||||
"""
|
|
||||||
super().__init__(message)
|
|
||||||
self.message = message
|
|
||||||
|
|
||||||
|
|
||||||
class BookmarkAlreadyExists(Exception):
|
|
||||||
def __init__(self, message='Bookmark already exists'):
|
|
||||||
"""Initialise the exception.
|
|
||||||
|
|
||||||
:param str message: The message for the exception
|
|
||||||
"""
|
|
||||||
super().__init__(message)
|
|
||||||
self.message = message
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
"""digimarks main module."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import Annotated, Sequence, Type
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
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 DirectoryPath, FilePath
|
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
from sqlmodel import desc, select
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from src.digimarks import bookmarks_service, tags_service
|
|
||||||
from src.digimarks.exceptions import BookmarkNotFound
|
|
||||||
from src.digimarks.models import DEFAULT_THEME, Bookmark, User, Visibility
|
|
||||||
|
|
||||||
DIGIMARKS_VERSION = '2.0.0a1'
|
|
||||||
|
|
||||||
|
|
||||||
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_async_engine(f'sqlite+aiosqlite:///{settings.database_file}', connect_args={'check_same_thread': False})
|
|
||||||
|
|
||||||
|
|
||||||
async def get_session() -> AsyncSession:
|
|
||||||
"""SQLAlchemy session factory."""
|
|
||||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
||||||
async with async_session() as session:
|
|
||||||
yield session
|
|
||||||
|
|
||||||
|
|
||||||
SessionDep = Annotated[AsyncSession, 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)
|
|
||||||
|
|
||||||
# Set up logging
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
||||||
datefmt='%Y-%m-%d %H:%M:%S',
|
|
||||||
)
|
|
||||||
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 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'
|
|
||||||
|
|
||||||
|
|
||||||
@app.get('/', response_class=HTMLResponse)
|
|
||||||
@app.head('/', response_class=HTMLResponse)
|
|
||||||
def index(request: Request):
|
|
||||||
"""Homepage, point visitors to project page."""
|
|
||||||
logger.info('Root page requested')
|
|
||||||
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)
|
|
||||||
async def get_user(session: SessionDep, system_key: str, user_id: int) -> Type[User]:
|
|
||||||
"""Show user information."""
|
|
||||||
logger.info('User %d requested', user_id)
|
|
||||||
if system_key != settings.system_key:
|
|
||||||
logger.error('User %s requested but incorrect system key %s provided', user_id, system_key)
|
|
||||||
raise HTTPException(status_code=404)
|
|
||||||
|
|
||||||
result = await session.get(User, user_id)
|
|
||||||
user = result
|
|
||||||
if not user:
|
|
||||||
logger.error('User %s not found', user_id)
|
|
||||||
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/')
|
|
||||||
async 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]
|
|
||||||
"""
|
|
||||||
logger.info('User listing requested')
|
|
||||||
if system_key != settings.system_key:
|
|
||||||
logger.error('User listing requested but incorrect system key %s provided', system_key)
|
|
||||||
raise HTTPException(status_code=404)
|
|
||||||
|
|
||||||
result = await session.exec(select(User).offset(offset).limit(limit))
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
|
|
||||||
@app.get('/api/v1/{user_key}/bookmarks/')
|
|
||||||
async def list_bookmarks(
|
|
||||||
session: SessionDep,
|
|
||||||
user_key: str,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: Annotated[int, Query(le=10000)] = 100,
|
|
||||||
) -> Sequence[Bookmark]:
|
|
||||||
"""List all bookmarks in the database. By default, 100 items are returned."""
|
|
||||||
logger.info('List bookmarks for user %s with offset %d, limit %d', user_key, offset, limit)
|
|
||||||
return await bookmarks_service.list_bookmarks_for_user(session, user_key, offset, limit)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get('/api/v1/{user_key}/bookmarks/{url_hash}')
|
|
||||||
async def get_bookmark(
|
|
||||||
session: SessionDep,
|
|
||||||
user_key: str,
|
|
||||||
url_hash: str,
|
|
||||||
) -> Bookmark:
|
|
||||||
"""Show bookmark details."""
|
|
||||||
logger.info('Bookmark details for user %s with url_hash %s', user_key, url_hash)
|
|
||||||
try:
|
|
||||||
return await bookmarks_service.get_bookmark_for_user_with_url_hash(session, user_key, url_hash)
|
|
||||||
except BookmarkNotFound as exc:
|
|
||||||
logger.error('Bookmark not found: %s', exc)
|
|
||||||
raise HTTPException(status_code=404, detail=f'Bookmark not found: {exc.message}')
|
|
||||||
|
|
||||||
|
|
||||||
@app.post('/api/v1/{user_key}/autocomplete_bookmark/', response_model=Bookmark)
|
|
||||||
async 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`."""
|
|
||||||
logger.info('Autocompleting bookmark %s for user %s', bookmark.url_hash, user_key)
|
|
||||||
return await bookmarks_service.autocomplete_bookmark(session, request, user_key, bookmark, strip_params)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post('/api/v1/{user_key}/bookmarks/', response_model=Bookmark)
|
|
||||||
async def add_bookmark(
|
|
||||||
session: SessionDep,
|
|
||||||
request: Request,
|
|
||||||
user_key: str,
|
|
||||||
bookmark: Bookmark,
|
|
||||||
strip_params: bool = False,
|
|
||||||
):
|
|
||||||
"""Add new bookmark for user `user_key`."""
|
|
||||||
logger.info('Adding bookmark %s for user %s', bookmark.url, user_key)
|
|
||||||
return await bookmarks_service.add_bookmark(session, request, user_key, bookmark, strip_params)
|
|
||||||
|
|
||||||
|
|
||||||
@app.patch('/api/v1/{user_key}/bookmarks/{url_hash}', response_model=Bookmark)
|
|
||||||
async 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`."""
|
|
||||||
logger.info('Updating bookmark %s for user %s', url_hash, user_key)
|
|
||||||
try:
|
|
||||||
return await bookmarks_service.update_bookmark(session, request, user_key, bookmark, url_hash, strip_params)
|
|
||||||
except Exception:
|
|
||||||
logger.exception('Failed to update bookmark %s', bookmark.id)
|
|
||||||
raise HTTPException(status_code=404, detail='Bookmark not found')
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete('/api/v1/{user_key}/bookmarks/{url_hash}', response_model=Bookmark)
|
|
||||||
async def delete_bookmark(
|
|
||||||
session: SessionDep,
|
|
||||||
user_key: str,
|
|
||||||
url_hash: str,
|
|
||||||
):
|
|
||||||
"""(Soft)Delete bookmark `bookmark_key` for user `user_key`."""
|
|
||||||
logger.info('Deleting bookmark %s for user %s', url_hash, user_key)
|
|
||||||
try:
|
|
||||||
result = await bookmarks_service.delete_bookmark(session, user_key, url_hash)
|
|
||||||
return {'ok': True}
|
|
||||||
except Exception:
|
|
||||||
logger.exception('Failed to delete bookmark %s', url_hash)
|
|
||||||
raise HTTPException(status_code=404, detail='Bookmark not found')
|
|
||||||
|
|
||||||
|
|
||||||
@app.get('/api/v1/{user_key}/latest_changes/')
|
|
||||||
async def bookmarks_changed_since(
|
|
||||||
session: SessionDep,
|
|
||||||
user_key: str,
|
|
||||||
):
|
|
||||||
"""Last update on server, so the (browser) client knows whether to fetch an update."""
|
|
||||||
logger.info('Retrieving latest changes for user %s', user_key)
|
|
||||||
result = await session.exec(
|
|
||||||
select(Bookmark)
|
|
||||||
.where(Bookmark.user_key == user_key, Bookmark.status != Visibility.DELETED)
|
|
||||||
.order_by(desc(Bookmark.modified_date))
|
|
||||||
)
|
|
||||||
latest_modified_bookmark = result.first()
|
|
||||||
result = await session.exec(
|
|
||||||
select(Bookmark)
|
|
||||||
.where(Bookmark.user_key == user_key, Bookmark.status != Visibility.DELETED)
|
|
||||||
.order_by(desc(Bookmark.created_date))
|
|
||||||
)
|
|
||||||
latest_created_bookmark = result.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/')
|
|
||||||
async def list_tags_for_user(
|
|
||||||
session: SessionDep,
|
|
||||||
user_key: str,
|
|
||||||
) -> list[str]:
|
|
||||||
"""List all tags in use by the user."""
|
|
||||||
return await tags_service.list_tags_for_user(session, user_key)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get('/api/v1/{user_key}/tags/{tag_key}')
|
|
||||||
async def list_bookmarks_for_tag_for_user(
|
|
||||||
session: SessionDep,
|
|
||||||
user_key: str,
|
|
||||||
tag_key: str,
|
|
||||||
) -> list[str]:
|
|
||||||
"""List all tags in use by the user."""
|
|
||||||
logger.info('List bookmarks for tag %s user %s', user_key)
|
|
||||||
return await tags_service.list_bookmarks_for_tag_for_user(session, user_key, tag_key)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get('/{user_key}', response_class=HTMLResponse)
|
|
||||||
async def page_user_landing(
|
|
||||||
session: SessionDep,
|
|
||||||
request: Request,
|
|
||||||
user_key: str,
|
|
||||||
):
|
|
||||||
"""HTML page with the main view for the user."""
|
|
||||||
result = await session.exec(select(User).where(User.key == user_key))
|
|
||||||
user = result.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,
|
|
||||||
# )
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
"""Models for digimarks.
|
|
||||||
|
|
||||||
Contains the bookmarks administration, users, tags, public tags and more.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from http import HTTPStatus
|
|
||||||
from typing import Optional, Type, TypeVar
|
|
||||||
|
|
||||||
from pydantic import AnyUrl, computed_field
|
|
||||||
from sqlmodel import AutoString, Field, SQLModel
|
|
||||||
|
|
||||||
DEFAULT_THEME = 'freshgreen'
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
HIDDEN = 2
|
|
||||||
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
class Bookmark(SQLModel, table=True):
|
|
||||||
"""Bookmark object."""
|
|
||||||
|
|
||||||
__tablename__ = 'bookmark'
|
|
||||||
|
|
||||||
id: int = Field(primary_key=True)
|
|
||||||
user_key: str = Field(foreign_key='user.key', nullable=False)
|
|
||||||
title: str = Field(default='', nullable=False)
|
|
||||||
url: AnyUrl = Field(default='', sa_type=build_custom_type(AnyUrl))
|
|
||||||
note: str = Field(default='', nullable=True)
|
|
||||||
# image: str = Field(default='')
|
|
||||||
url_hash: str = Field(default='', nullable=False)
|
|
||||||
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, nullable=True)
|
|
||||||
deleted_date: datetime = Field(default=None, nullable=True)
|
|
||||||
|
|
||||||
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 []
|
|
||||||
|
|
||||||
|
|
||||||
class PublicTag(SQLModel, table=True):
|
|
||||||
"""Public tag object."""
|
|
||||||
|
|
||||||
__tablename__ = 'publictag'
|
|
||||||
|
|
||||||
id: int = Field(primary_key=True)
|
|
||||||
tag_key: str
|
|
||||||
user_key: str = Field(foreign_key='user.key')
|
|
||||||
tag: str
|
|
||||||
created_date: datetime = Field(default=datetime.now(UTC))
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
@@ -1,478 +0,0 @@
|
|||||||
/**
|
|
||||||
* digui structure and theming
|
|
||||||
* v0.0.2
|
|
||||||
*
|
|
||||||
* Created by: Michiel Scholten
|
|
||||||
* Source: https://github.com/aquatix/digui
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Colours and themes */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--padding: .5rem;
|
|
||||||
|
|
||||||
color-scheme: only light;
|
|
||||||
|
|
||||||
/* 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'] {
|
|
||||||
color-scheme: 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme='nebula'] header,
|
|
||||||
[data-theme='nebula-dark'] header {
|
|
||||||
/*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 */
|
|
||||||
|
|
||||||
dialog:modal {
|
|
||||||
color: var(--text-color);
|
|
||||||
background-color: var(--background-color);
|
|
||||||
/*background-color: var(--nav-background-color);*/
|
|
||||||
border: 2px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
width: 90%;
|
|
||||||
/*height: 90%;*/
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The umwelt of the modal, on top of the regular content */
|
|
||||||
dialog::backdrop {
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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.
|
Before Width: | Height: | Size: 89 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 254 B |
@@ -1,225 +0,0 @@
|
|||||||
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 */
|
|
||||||
//
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
"""Helper functions for tags used with Bookmark models."""
|
|
||||||
|
|
||||||
from sqlalchemy import Sequence
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from src.digimarks.models import Bookmark, Visibility
|
|
||||||
|
|
||||||
|
|
||||||
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[str]:
|
|
||||||
"""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: Sequence[Bookmark]) -> list[str]:
|
|
||||||
"""Generate a unique list of the tags from the list of bookmarks."""
|
|
||||||
tags = []
|
|
||||||
for bookmark in bookmarks:
|
|
||||||
tags += bookmark.tag_list
|
|
||||||
return clean_tags(tags)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_tags_for_user(
|
|
||||||
session,
|
|
||||||
user_key: str,
|
|
||||||
) -> list[str]:
|
|
||||||
"""List all tags in use by the user."""
|
|
||||||
result = await session.exec(
|
|
||||||
select(Bookmark).where(Bookmark.user_key == user_key, Bookmark.status != Visibility.DELETED)
|
|
||||||
)
|
|
||||||
bookmarks = result.all()
|
|
||||||
tags = []
|
|
||||||
for bookmark in bookmarks:
|
|
||||||
tags += bookmark.tag_list
|
|
||||||
return clean_tags(tags)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_bookmarks_for_tag_for_user(
|
|
||||||
session,
|
|
||||||
user_key: str,
|
|
||||||
tag_key: str,
|
|
||||||
) -> list[str]:
|
|
||||||
"""List all tags in use by the user."""
|
|
||||||
result = await session.exec(select(Bookmark).where(Bookmark.user_key == user_key))
|
|
||||||
# TODO: filter on tag_key
|
|
||||||
bookmarks = result.all()
|
|
||||||
return list_tags_for_bookmarks(bookmarks)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
{% 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> URL not found (404), broken/outdated link?
|
|
||||||
{% elif bookmark.http_status == 301 %}
|
|
||||||
<i class="material-icons">report_problem</i> HTTP status (301), moved permanently. Use button for new target
|
|
||||||
{% elif bookmark.http_status == 302 %}
|
|
||||||
<i class="material-icons">report_problem</i> HTTP status (302), moved temporarily. Use button for new target
|
|
||||||
{% elif bookmark.http_status == bookmark.HTTP_CONNECTIONERROR %}
|
|
||||||
<i class="material-icons">report_problem</i> Connection error, server might have been offline at the time of last edit
|
|
||||||
{% else %}
|
|
||||||
<i class="material-icons">report_problem</i> 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 %}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<div class="row">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th> </th>
|
|
||||||
<th>Bookmark</th>
|
|
||||||
<th>Added</th>
|
|
||||||
{% if showtags %}
|
|
||||||
<th>Tags</th>
|
|
||||||
{% endif %}
|
|
||||||
<th> </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>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
{% 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 ↓
|
|
||||||
</button>
|
|
||||||
<button @click="$store.digimarks.sortAlphabetically('desc')"
|
|
||||||
:class="$store.digimarks.sortTitleDesc && 'active'">z-a ↑
|
|
||||||
</button>
|
|
||||||
<button @click="$store.digimarks.sortCreated()"
|
|
||||||
:class="$store.digimarks.sortCreatedAsc && 'active'">date ↓
|
|
||||||
</button>
|
|
||||||
<button @click="$store.digimarks.sortCreated('desc')"
|
|
||||||
:class="$store.digimarks.sortCreatedDesc && 'active'">date ↑
|
|
||||||
</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"> </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> URL not found (404), broken/outdated link?
|
|
||||||
{% elif bookmark.http_status == 301 %}
|
|
||||||
<i class="material-icons">report_problem</i> HTTP status (301), moved permanently. Use button for new target
|
|
||||||
{% elif bookmark.http_status == 302 %}
|
|
||||||
<i class="material-icons">report_problem</i> HTTP status (302), moved temporarily. Use button for new target
|
|
||||||
{% elif bookmark.http_status == bookmark.HTTP_CONNECTIONERROR %}
|
|
||||||
<i class="material-icons">report_problem</i> Connection error, server might have been offline at the time of last edit
|
|
||||||
{% else %}
|
|
||||||
<i class="material-icons">report_problem</i> 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 %}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
"""General utility functions."""
|
|
||||||
|
|
||||||
import binascii
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
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)))
|
|
||||||
21
tox.ini
21
tox.ini
@@ -1,21 +0,0 @@
|
|||||||
[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
Normal file
16
wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# 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)
|
||||||
Reference in New Issue
Block a user