mirror of
https://github.com/aquatix/digimarks.git
synced 2025-12-06 23:05:10 +01:00
Compare commits
228 Commits
v1.1.0
...
18b40ed485
| Author | SHA1 | Date | |
|---|---|---|---|
| 18b40ed485 | |||
| fc0c668ba1 | |||
| fe360a1c7a | |||
| 231620ca3b | |||
| 925aeba18e | |||
| 3a566f60ca | |||
| 5adbb8dce0 | |||
| 284768d9d9 | |||
| 1229680abe | |||
| 4dc46ba3c7 | |||
| 3e917efe37 | |||
| 343ac01086 | |||
| d8c8d87568 | |||
| 22e73bc991 | |||
| f750a547d3 | |||
| d96bde9bb1 | |||
| 70979b3350 | |||
| 1836eedfe8 | |||
| 5efc12e698 | |||
| eb8e764a61 | |||
| d9b1d99e32 | |||
| e7021cdf3d | |||
| 45d8743b65 | |||
| 4d5aae7881 | |||
| 3f5d43b0fa | |||
| d9e8ca76fe | |||
| faae900b06 | |||
| 0f7e280bb3 | |||
| d28228fc03 | |||
| 5d71250408 | |||
| d073bc079a | |||
| 72b131c77f | |||
| 5943265687 | |||
| 3642753266 | |||
| 127284716e | |||
| 40a0f773c4 | |||
| b364f865b1 | |||
| 324c77f985 | |||
| 9b11ae65c3 | |||
| 85639b128f | |||
| 3369ee3b89 | |||
| 3a17b5aa63 | |||
| bf36d726ac | |||
| 8e8d3bfcdc | |||
| d89e8be72e | |||
| e4d303a72b | |||
| cfcca3e161 | |||
| 533e21141f | |||
| c695972a94 | |||
| 8968b47ddd | |||
| 7cbde4cc37 | |||
| 29c3ccca59 | |||
| 71b0543771 | |||
| 374db18181 | |||
| 0351760d3f | |||
| a3cdccdb8a | |||
| 65af6b5762 | |||
| 81825379cb | |||
| f6f129d67c | |||
| 30bd835e41 | |||
| 7e397f9d2b | |||
| 96e7ef16d4 | |||
| 0b49186559 | |||
| 3092f83c8b | |||
| a27787e956 | |||
| 21800911db | |||
| 20a3d9f838 | |||
| 0ab3bd2263 | |||
| 32b074b859 | |||
| 5789bbe006 | |||
| 2ef7358ac7 | |||
| d1e590390c | |||
| 7a1bc11004 | |||
| 315c664fcc | |||
| db5944cec4 | |||
|
|
becb734d17 | ||
| 64ee0856c5 | |||
|
|
6c2be3070e | ||
| 426c2eda68 | |||
| b0e53d4a85 | |||
| 1f69d9e53f | |||
|
|
fc27d9f186 | ||
|
|
6341b384bf | ||
| f698ebfe18 | |||
| 9f736ffe82 | |||
| e1a45a21b5 | |||
| 9492d26511 | |||
| f7762ebc7b | |||
| 1c4bc61494 | |||
| 2a87e0aa1f | |||
| 0a24c7d170 | |||
| 2615089acd | |||
| 14bf22f3e5 | |||
| e55fb7bd5f | |||
| 4e1261857d | |||
| f9861c1491 | |||
| 836077ad12 | |||
| 0913ffca2c | |||
| 834f95c34b | |||
| c82e3a02d4 | |||
| 7874002fef | |||
| 6db0355cc7 | |||
|
|
0495296f0f | ||
|
|
a5d225fb56 | ||
| f6401a3e9f | |||
| 46e7fc9899 | |||
| f6befd0700 | |||
| 95ff9c01ca | |||
| b36cd8db6b | |||
| 2b19b770dc | |||
| 6dc74c7102 | |||
| ac7906d781 | |||
| 5d2329ff90 | |||
| a29e14b7a7 | |||
| 5e4a35527b | |||
| 55609aa353 | |||
| 0d86c2608a | |||
| dd1e3a19ff | |||
| 9694ca566b | |||
| 17caef1aed | |||
| 52a01794f6 | |||
| 8148a79d28 | |||
| d6e74ff328 | |||
| 540fd6ba91 | |||
| 3becf27b42 | |||
| fe990ecf63 | |||
| 75080579fd | |||
| 94eb42a882 | |||
| d501c6b4db | |||
| 11e159db8d | |||
| 6516c4af1d | |||
| 63636d3355 | |||
| 52cc93d4c3 | |||
| 6f9d44ce86 | |||
| 718b39a267 | |||
| bfc4fb702a | |||
| 7e2f2f6f6e | |||
| 29c8c875be | |||
| 0f0caed748 | |||
| 971590196e | |||
| 3653b5e424 | |||
| 330523ba3f | |||
| 199b641a38 | |||
| c0c8e35246 | |||
| 6de9ba2642 | |||
| e48f2c98c3 | |||
| 554f651ec8 | |||
| 6def8d60a5 | |||
| 1d531989bb | |||
| fc2712f5e3 | |||
| 76ef520815 | |||
| ea4a7bdcd7 | |||
| aee0515eae | |||
| 3835497918 | |||
| d7b2c28c96 | |||
| fac3a4f747 | |||
| 0548f35b39 | |||
| 8372d6e2a5 | |||
| cd2911e7f0 | |||
| 127d99b1e0 | |||
| bb4f81262e | |||
| 3c697d3162 | |||
| fefb317ddf | |||
| 37ebdda933 | |||
| 3516fbfbb2 | |||
| c4f921ac68 | |||
| fec54c51f7 | |||
| 9f467f8a09 | |||
| cac31e40c9 | |||
| 1e14163d42 | |||
| 88eee28b88 | |||
| 45c95f5f17 | |||
| 9539c48b7b | |||
| 46aa230fae | |||
| c6089f1caa | |||
| 45d44d3bdf | |||
| e4662351c2 | |||
| 62f3ddf654 | |||
| 072ec6c426 | |||
| 5055947351 | |||
| 5f131b15ef | |||
| bd808a9e1d | |||
| 3b019e4368 | |||
| 3af1239326 | |||
| e8ea948566 | |||
| 1c2090f300 | |||
| cdfd0341f0 | |||
| 01d6525861 | |||
| c70e53a658 | |||
| dc76a592e0 | |||
| a4aae2d6c4 | |||
| 389e63bdbb | |||
| 6ba4803ed2 | |||
| 71756f9ea0 | |||
| 67635c199a | |||
| 7b2a861652 | |||
| 53887c8ece | |||
| 2cc2d382c1 | |||
| fa033452f1 | |||
| c14e24430b | |||
| cca4504fd7 | |||
| 7f6dc3f3df | |||
| 4170a7818b | |||
|
|
48e77f551d | ||
| e35d9952bd | |||
| 6933357a61 | |||
| 9578ee624b | |||
| ded047d749 | |||
| 57226b88f5 | |||
| de5d4d30ef | |||
| 60f5a48d89 | |||
| 47f6e36e4b | |||
| 5402dfc320 | |||
| 90f1322c48 | |||
| d1aef0284f | |||
| d7a5bd921f | |||
| b85ee43cc7 | |||
| 8077499eae | |||
| ab06f7e583 | |||
| f309d4acf2 | |||
| 88a9806d44 | |||
| 6f4d270858 | |||
| c354613b60 | |||
| 383c77ee8b | |||
| 0e77afd000 | |||
| f617fb8190 | |||
| 34af0e9ab7 | |||
| 7f866658e3 |
4
.codacy.yaml
Normal file
4
.codacy.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
exclude_paths:
|
||||
- "example_config/**"
|
||||
- "docs/source/**"
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -87,3 +87,16 @@ ENV/
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# JetBrains PyCharm/Idea
|
||||
.idea
|
||||
|
||||
# vim
|
||||
*.swp
|
||||
|
||||
# digimarks
|
||||
static/favicons
|
||||
tags
|
||||
*.db_*
|
||||
*.db
|
||||
settings.py
|
||||
|
||||
66
CHANGELOG.md
66
CHANGELOG.md
@@ -1,8 +1,8 @@
|
||||
# Changelog
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## TODO
|
||||
@@ -11,14 +11,73 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
|
||||
- Sort by title
|
||||
- Sort by date
|
||||
- Logging of actions
|
||||
- Change tags to the MaterializeCSS tags: http://materializecss.com/chips.html
|
||||
- Add new way of authentication and editing bookmark collections:
|
||||
https://github.com/aquatix/digimarks/issues/8 and https://github.com/aquatix/digimarks/issues/9
|
||||
- Change adding tags to use the MaterializeCSS tags: https://materializecss.com/chips.html
|
||||
- Do calls to the API endpoint of an existing bookmark when editing properties
|
||||
(for example to update tags, title and such, also to already suggest title)
|
||||
- Look into compatibility with del.icio.us, so we can make use of existing browser integration
|
||||
- Add unit tests
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Settings through Pydantic Settings
|
||||
|
||||
### Changed
|
||||
- Moved from Flask to FastAPI
|
||||
- Moved from Peewee ORM to SQLAlchemy
|
||||
|
||||
### Removed
|
||||
- Jinja2 templates
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
## [1.2.0] - Flask is Fine (2023-07-30)
|
||||
|
||||
### Added
|
||||
- 'lightblue' theme
|
||||
- 'black amoled' theme
|
||||
- Python 3 compatibility (tested with Python 3.5 and 3.6)
|
||||
- Accept 'HTTP 202' responses as 'OK'
|
||||
- API: Added endpoint for 'bookmarks', returning JSON
|
||||
- Top navigation items now have icons too, like the sidebar in mobile view
|
||||
- Download favicons from RealFaviconGenerator: https://realfavicongenerator.net/api/download_website_favicon
|
||||
- Added `/<systemkey>/findmissingfavicons` endpoint to fill in the blanks in the favicon collection
|
||||
- Added fallback favicon image (semitransparent digimarks 'M' logo) for bookmarks without a favicon. No more broken images.
|
||||
- Added theme support for buttons.
|
||||
- Autocompletion in bookmark search field
|
||||
- API: search endpoint
|
||||
- Redirect endpoint for a bookmark, de-referring to its url (`/r/<userkey>/<urlhash>`)
|
||||
|
||||
### Changed
|
||||
- Fixed theming of browser chrome in mobile browsers
|
||||
- Changed link colour of 'dark' theme from blue to orange
|
||||
- Modified card padding so it fits more content
|
||||
- Fixed ability to select a checkbox in the add/edit bookmark form
|
||||
- Made the 404 page theme aware, falls back to default (green) theme
|
||||
- Fixed admin pages not working anymore due to `settings` object name clash
|
||||
- On Add/Edit bookmark and encountering a 301, show a better message about automatically changing the URL with the provided button
|
||||
- Switched to 1.0 (alpha 4) version of MaterializeCSS
|
||||
- jQuery-b-gone: changed all jQuery code to regular JavaScript code/MaterializeCSS framework
|
||||
- Fixed colour of filter text in search field for dark themes
|
||||
- Unified rendering of 'private' and 'public' views of bookmark cards
|
||||
- Code cleanups, readability fixes
|
||||
- digimarks User Agent string to correctly identify ourselves, also preventing servers blocking 'bots'
|
||||
- Text search now also finds matches in the 'note' and 'url' of a bookmark, aside from its title
|
||||
- Main navigation items ('tags' and 'add bookmark') are now buttons, better visible as action items.
|
||||
- Removed item limit for feeds
|
||||
- Form fields are now themed
|
||||
- Disabled browser autocomplete for forms, which generally interfered with editing bookmarks (e.g., tag field) and the search field,
|
||||
which has its own autocomplete now
|
||||
- Changed default theme to the 'freshgreen' variant
|
||||
- Links are now themed in the proper colours everywhere
|
||||
|
||||
### Removed
|
||||
- Removed dependency on jQuery
|
||||
|
||||
|
||||
## [1.1.0] - 2017-07-22
|
||||
|
||||
@@ -27,6 +86,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
|
||||
- Cache buster to force loading of the latest styling
|
||||
- Theming support, default is 'green'
|
||||
- Themes need an extra `theme` field in the User table
|
||||
- Added 'freshgreen' and 'dark' themes
|
||||
|
||||
### Changed
|
||||
- Make running in a virtualenv optional
|
||||
|
||||
30
README.rst
30
README.rst
@@ -1,7 +1,7 @@
|
||||
digimarks
|
||||
=========
|
||||
|
||||
|PyPI version| |PyPI downloads| |PyPI license| |Code health|
|
||||
|PyPI version| |PyPI license| |Code health| |Codacy|
|
||||
|
||||
Simple bookmarking service, using a SQLite database to store bookmarks, supporting tags and automatic title fetching.
|
||||
|
||||
@@ -39,11 +39,25 @@ Usage / example configuration
|
||||
Copy ``settings.py`` from example_config to the parent directory and
|
||||
configure to your needs (*at the least* change the value of `SYSTEMKEY`).
|
||||
|
||||
Do not forget to fill in the `MASHAPE_API_KEY` value, which you [can request on the RapidAPI website](https://rapidapi.com/realfavicongenerator/api/realfavicongenerator).
|
||||
|
||||
Run digimarks as a service under nginx or apache and call the appropriate
|
||||
url's when wanted.
|
||||
|
||||
Url's are of the form https://marks.example.com/<userkey>/<action>
|
||||
|
||||
digimarks can also be run from the command line: ``uvicorn digimarks:app --reload``
|
||||
|
||||
Be sure to export/set the ``SECRETKEY`` environment variable before running, it's needed for some management URI's.
|
||||
|
||||
Run ``gunicorn -k uvicorn.workers.UvicornWorker`` for production. For an example of how to set up a server `see this article <https://www.slingacademy.com/article/deploying-fastapi-on-ubuntu-with-nginx-and-lets-encrypt/>`_ with configuration for nginx, uvicorn, systemd, security and such.
|
||||
|
||||
The RQ background worker can be run from the command line: ``rq worker --with-scheduler``
|
||||
|
||||
Url's are of the form https://hook.example.com/app/<appkey>/<triggerkey>
|
||||
|
||||
API documentation is auto-generated, and can be browsed at https://hook.example.com/docs
|
||||
|
||||
|
||||
Bookmarklet
|
||||
~~~~~~~~~~~
|
||||
@@ -72,8 +86,9 @@ If you for whatever reason would lose this user key, just either look on the con
|
||||
Server configuration
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
* `vhost for Apache2.4`_
|
||||
* `uwsgi.ini`_
|
||||
* `systemd for digimarks API`_ which uses the `gunicorn config`_
|
||||
* `nginx for digimarks API`_
|
||||
* `more config`_
|
||||
|
||||
|
||||
What's new?
|
||||
@@ -89,18 +104,21 @@ Attributions
|
||||
|
||||
|
||||
.. _digimarks: https://github.com/aquatix/digimarks
|
||||
.. _webhook: https://en.wikipedia.org/wiki/Webhook
|
||||
.. |PyPI version| image:: https://img.shields.io/pypi/v/digimarks.svg
|
||||
:target: https://pypi.python.org/pypi/digimarks/
|
||||
.. |PyPI downloads| image:: https://img.shields.io/pypi/dm/digimarks.svg
|
||||
:target: https://pypi.python.org/pypi/digimarks/
|
||||
.. |PyPI license| image:: https://img.shields.io/github/license/aquatix/digimarks.svg
|
||||
:target: https://pypi.python.org/pypi/digimarks/
|
||||
.. |Code health| image:: https://landscape.io/github/aquatix/digimarks/master/landscape.svg?style=flat
|
||||
:target: https://landscape.io/github/aquatix/digimarks/master
|
||||
:alt: Code Health
|
||||
.. |Codacy| image:: https://api.codacy.com/project/badge/Grade/9a34319d917b43219a29e59e9ac75e3b
|
||||
:alt: Codacy Badge
|
||||
:target: https://app.codacy.com/app/aquatix/digimarks?utm_source=github.com&utm_medium=referral&utm_content=aquatix/digimarks&utm_campaign=badger
|
||||
.. _hook settings: https://github.com/aquatix/digimarks/blob/master/example_config/examples.yaml
|
||||
.. _vhost for Apache2.4: https://github.com/aquatix/digimarks/blob/master/example_config/apache_vhost.conf
|
||||
.. _uwsgi.ini: https://github.com/aquatix/digimarks/blob/master/example_config/uwsgi.ini
|
||||
.. _Changelog: https://github.com/aquatix/digimarks/blob/master/CHANGELOG.md
|
||||
.. _Freepik: http://www.flaticon.com/free-icon/letter-m_2041
|
||||
.. _systemd for digimarks API: https://github.com/aquatix/digimarks/blob/master/example_config/systemd/digimarks.service
|
||||
.. _gunicorn config: https://github.com/aquatix/digimarks/blob/master/example_config/gunicorn_digimarks_conf.py
|
||||
.. _more config: https://github.com/aquatix/digimarks/tree/master/example_config
|
||||
|
||||
755
digimarks.py
755
digimarks.py
@@ -1,755 +0,0 @@
|
||||
from __future__ import print_function
|
||||
import datetime
|
||||
import gzip
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
import shutil
|
||||
import bs4
|
||||
from urlparse import urlparse, urlunparse, urljoin
|
||||
|
||||
from flask import Flask, abort, redirect, render_template, request, url_for, jsonify
|
||||
from werkzeug.contrib.atom import AtomFeed
|
||||
from flask_peewee.db import Database
|
||||
#from flask_peewee.utils import get_object_or_404
|
||||
from peewee import * # noqa
|
||||
|
||||
DEFAULT_THEME = 'green'
|
||||
themes = {
|
||||
'green': {
|
||||
'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',
|
||||
'CARD_BACKGROUND': 'green darken-3',
|
||||
'CARD_TEXT': 'white-text',
|
||||
'FAB': 'red',
|
||||
|
||||
'STAR': 'yellow-text',
|
||||
'PROBLEM': 'red-text',
|
||||
'COMMENT': '',
|
||||
},
|
||||
'freshgreen': {
|
||||
'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',
|
||||
'CARD_BACKGROUND': 'green darken-1',
|
||||
'CARD_TEXT': 'white-text',
|
||||
'FAB': 'red',
|
||||
|
||||
'STAR': 'yellow-text',
|
||||
'PROBLEM': 'red-text',
|
||||
'COMMENT': '',
|
||||
},
|
||||
'dark': {
|
||||
'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',
|
||||
'CARD_BACKGROUND': 'grey darken-3',
|
||||
'CARD_TEXT': 'grey-text lighten-1',
|
||||
'FAB': 'red',
|
||||
|
||||
'STAR': 'yellow-text',
|
||||
'PROBLEM': 'red-text',
|
||||
'COMMENT': '',
|
||||
}
|
||||
}
|
||||
|
||||
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/'
|
||||
DATABASE = {
|
||||
'name': os.path.join(APP_ROOT, 'bookmarks.db'),
|
||||
'engine': 'peewee.SqliteDatabase',
|
||||
}
|
||||
#PHANTOM = '/usr/local/bin/phantomjs'
|
||||
#SCRIPT = os.path.join(APP_ROOT, 'screenshot.js')
|
||||
|
||||
# create our flask app and a database wrapper
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(__name__)
|
||||
db = Database(app)
|
||||
|
||||
# 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 = {}
|
||||
settings = {}
|
||||
|
||||
|
||||
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 = {
|
||||
"\x1f\x8b\x08": "gz",
|
||||
"\x42\x5a\x68": "bz2",
|
||||
"\x50\x4b\x03\x04": "zip"
|
||||
}
|
||||
|
||||
max_len = max(len(x) for x in magic_dict)
|
||||
|
||||
def file_type(filename):
|
||||
with open(filename) 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 User(db.Model):
|
||||
""" User account """
|
||||
username = CharField()
|
||||
key = CharField()
|
||||
theme = CharField(default=DEFAULT_THEME)
|
||||
created_date = DateTimeField(default=datetime.datetime.now)
|
||||
|
||||
def generate_key(self):
|
||||
""" Generate userkey """
|
||||
self.key = os.urandom(24).encode('hex')
|
||||
return self.key
|
||||
|
||||
|
||||
class Bookmark(db.Model):
|
||||
""" 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_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 fetch_image(self):
|
||||
# url_hash = hashlib.md5(self.url).hexdigest()
|
||||
# filename = 'bookmark-%s.png' % url_hash
|
||||
|
||||
# outfile = os.path.join(MEDIA_ROOT, filename)
|
||||
# params = [PHANTOM, SCRIPT, self.url, outfile]
|
||||
|
||||
# exitcode = subprocess.call(params)
|
||||
# if exitcode == 0:
|
||||
# self.image = os.path.join(MEDIA_URL, filename)
|
||||
|
||||
def set_hash(self):
|
||||
""" Generate hash """
|
||||
self.url_hash = hashlib.md5(self.url).hexdigest()
|
||||
|
||||
def set_title_from_source(self):
|
||||
""" Request the title by requesting the source url """
|
||||
try:
|
||||
result = requests.get(self.url)
|
||||
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:
|
||||
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)
|
||||
self.http_status = result.status_code
|
||||
except requests.ConnectionError:
|
||||
self.http_status = self.HTTP_CONNECTIONERROR
|
||||
return self.http_status
|
||||
|
||||
def set_favicon(self):
|
||||
""" Fetch favicon for the domain """
|
||||
# http://codingclues.eu/2009/retrieve-the-favicon-for-any-url-thanks-to-google/
|
||||
u = urlparse(self.url)
|
||||
domain = u.netloc
|
||||
# if file exists, don't re-download it
|
||||
#response = requests.get('http://www.google.com/s2/favicons?domain=' + domain, stream=True)
|
||||
fileextension = '.png'
|
||||
meta = requests.head('http://icons.better-idea.org/icon?size=60&url=' + domain, allow_redirects=True)
|
||||
if meta.url[-3:].lower() == 'ico':
|
||||
fileextension = '.ico'
|
||||
response = requests.get('http://icons.better-idea.org/icon?size=60&url=' + domain, stream=True)
|
||||
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)
|
||||
new = file(filename, 'wb')
|
||||
new.write(origcontent)
|
||||
new.close()
|
||||
self.favicon = domain + fileextension
|
||||
|
||||
def set_tags(self, tags):
|
||||
""" Set tags from `tags`, strip and sort them """
|
||||
tags_split = tags.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)
|
||||
self.http_status = result.status_code
|
||||
self.redirect_uri = result.url
|
||||
return result.url
|
||||
else:
|
||||
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(',')
|
||||
else:
|
||||
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,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
class PublicTag(db.Model):
|
||||
""" 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 = os.urandom(16).encode('hex')
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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 = settings[userkey]['theme']
|
||||
return themes[usertheme]
|
||||
except KeyError:
|
||||
return themes[DEFAULT_THEME] # default
|
||||
|
||||
|
||||
def make_external(url):
|
||||
return urljoin(request.url_root, url)
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def page_not_found(e):
|
||||
return render_template('404.html', error=e), 404
|
||||
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
""" Homepage, point visitors to project page """
|
||||
theme = themes[DEFAULT_THEME]
|
||||
return render_template('index.html', theme=theme)
|
||||
|
||||
|
||||
@app.route('/<userkey>', methods=['GET', 'POST'])
|
||||
@app.route('/<userkey>/filter/<filtermethod>', methods=['GET', 'POST'])
|
||||
@app.route('/<userkey>/sort/<sortmethod>', methods=['GET', 'POST'])
|
||||
def 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')
|
||||
tags = 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 = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.title.contains(filter_text),
|
||||
Bookmark.status == Bookmark.VISIBLE).order_by(Bookmark.created_date.desc())
|
||||
elif filter_starred:
|
||||
bookmarks = Bookmark.select().where(Bookmark.userkey == userkey,
|
||||
Bookmark.starred == True).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())
|
||||
|
||||
theme = get_theme(userkey)
|
||||
return render_template('bookmarks.html', bookmarks=bookmarks, userkey=userkey, tags=tags, filter_text=filter_text, message=message, theme=theme)
|
||||
|
||||
|
||||
|
||||
#@app.route('/<userkey>/<urlhash>')
|
||||
#def viewbookmark(userkey, urlhash):
|
||||
# """ Bookmark detail view """
|
||||
# bookmark = Bookmark.select(Bookmark.url_hash == urlhash, Bookmark.userkey == userkey)
|
||||
# return render_template('viewbookmark.html', userkey=userkey, bookmark=bookmark)
|
||||
|
||||
|
||||
@app.route('/<userkey>/<urlhash>/json')
|
||||
def viewbookmarkjson(userkey, urlhash):
|
||||
""" Serialise bookmark to json """
|
||||
bookmark = Bookmark.select(Bookmark.url_hash == urlhash, Bookmark.userkey == userkey, Bookmark.status == Bookmark.VISIBLE)[0]
|
||||
return jsonify(bookmark.to_dict())
|
||||
|
||||
|
||||
@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, request, 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:
|
||||
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, request)
|
||||
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, request, 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', 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', userkey=userkey, message=message))
|
||||
|
||||
|
||||
@app.route('/<userkey>/tags')
|
||||
def tags(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 == True).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(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)
|
||||
|
||||
|
||||
@app.route('/pub/<tagkey>')
|
||||
def publictag(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 = 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())
|
||||
theme = themes[DEFAULT_THEME]
|
||||
return render_template('publicbookmarks.html', bookmarks=bookmarks, tag=tag, action=this_tag.tag, tagkey=tagkey, theme=theme)
|
||||
except PublicTag.DoesNotExist:
|
||||
abort(404)
|
||||
|
||||
|
||||
@app.route('/pub/<tagkey>/json')
|
||||
def publictagjson(tagkey):
|
||||
""" json 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)
|
||||
result = {'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 publictagfeed(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).limit(15)
|
||||
feed = AtomFeed(this_tag.tag, feed_url=request.url, url=make_external(url_for('publictag', tagkey=tagkey)))
|
||||
for bookmark in bookmarks:
|
||||
updated_date = bookmark.modified_date
|
||||
if not bookmark.modified_date:
|
||||
updated_date = bookmark.created_date
|
||||
feed.add(bookmark.title,
|
||||
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', userkey=userkey, tag=tag, message=message))
|
||||
else:
|
||||
message = 'Public link already existed'
|
||||
return redirect(url_for('tag', 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', 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('/' + newuser.key, 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)
|
||||
|
||||
|
||||
# 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)
|
||||
settings[user.key] = {'theme': user.theme}
|
||||
print(user.key)
|
||||
|
||||
# Run when called standalone
|
||||
if __name__ == '__main__':
|
||||
# run the application
|
||||
app.run(port=9999, debug=True)
|
||||
@@ -10,6 +10,10 @@ DEBUG = False
|
||||
# echo -n "yourstring" | sha1sum
|
||||
SYSTEMKEY = 'S3kr1t'
|
||||
|
||||
# RapidAPI key for favicons
|
||||
# https://rapidapi.com/realfavicongenerator/api/realfavicongenerator
|
||||
MASHAPE_API_KEY = 'your_MASHAPE_key'
|
||||
|
||||
LOG_LOCATION = 'digimarks.log'
|
||||
#LOG_LOCATION = '/var/log/digimarks/digimarks.log'
|
||||
# How many logs to keep in log rotation:
|
||||
|
||||
22
pylintrc
Normal file
22
pylintrc
Normal file
@@ -0,0 +1,22 @@
|
||||
[FORMAT]
|
||||
max-line-length=120
|
||||
|
||||
|
||||
[BASIC]
|
||||
# Good variable names which should always be accepted, separated by a comma.
|
||||
good-names=i,
|
||||
j,
|
||||
k,
|
||||
e,
|
||||
ex,
|
||||
extra,
|
||||
f,
|
||||
fd,
|
||||
fp,
|
||||
logger,
|
||||
Run,
|
||||
q,
|
||||
s,
|
||||
x,
|
||||
y,
|
||||
_
|
||||
74
pyproject.toml
Normal file
74
pyproject.toml
Normal file
@@ -0,0 +1,74 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "digimarks"
|
||||
version = "1.1.99"
|
||||
authors = [
|
||||
{name = "Michiel Scholten", email = "michiel@diginaut.net"},
|
||||
]
|
||||
description='Simple bookmarking service, using a SQLite database to store bookmarks, supporting tags, automatic title fetching and REST API calls.'
|
||||
readme = "README.rst"
|
||||
requires-python = ">=3.7"
|
||||
keywords = ["bookmarks", "api"]
|
||||
license = {text = "Apache"}
|
||||
classifiers = [
|
||||
"Framework :: FastAPI",
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
]
|
||||
dependencies = [
|
||||
"importlib-metadata; python_version<'3.8'",
|
||||
"fastapi[all]",
|
||||
"pydantic>2.0",
|
||||
"requests",
|
||||
"bs4",
|
||||
"feedgen"
|
||||
]
|
||||
# dynamic = ["version"]
|
||||
|
||||
[project.scripts]
|
||||
my-script = "digimarks:app"
|
||||
|
||||
[project.urls]
|
||||
"Homepage" = "https://github.com/aquatix/digimarks"
|
||||
"Bug Tracker" = "https://github.com/aquatix/digimarks/issues"
|
||||
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
|
||||
[tool.ruff]
|
||||
exclude = [
|
||||
".git",
|
||||
"__pycache__",
|
||||
"docs/source/conf.py",
|
||||
"build",
|
||||
"dist",
|
||||
"example_config/gunicorn_digimarks_conf.py",
|
||||
"example_config/settings.py",
|
||||
]
|
||||
line-length = 120
|
||||
|
||||
[tool.ruff.format]
|
||||
# Prefer single quotes over double quotes
|
||||
quote-style = "single"
|
||||
|
||||
[tool.ruff.lint]
|
||||
ignore = ["D203", "D213"]
|
||||
select = [
|
||||
"C9",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"I",
|
||||
"W",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.flake8-quotes]
|
||||
docstring-quotes = "double"
|
||||
inline-quotes = "single"
|
||||
multiline-quotes = "double"
|
||||
|
||||
[tool.ruff.lint.mccabe]
|
||||
max-complexity = 10
|
||||
11
requirements-dev.in
Normal file
11
requirements-dev.in
Normal file
@@ -0,0 +1,11 @@
|
||||
-r requirements.in
|
||||
|
||||
# Linting and fixing, including isort
|
||||
ruff
|
||||
|
||||
# Test suite
|
||||
pytest
|
||||
|
||||
# Publishing on PyPI
|
||||
build
|
||||
twine
|
||||
223
requirements-dev.txt
Normal file
223
requirements-dev.txt
Normal file
@@ -0,0 +1,223 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.10
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile requirements-dev.in
|
||||
#
|
||||
annotated-types==0.5.0
|
||||
# via pydantic
|
||||
anyio==3.7.1
|
||||
# via
|
||||
# httpcore
|
||||
# starlette
|
||||
# watchfiles
|
||||
astroid==2.15.6
|
||||
# via pylint
|
||||
beautifulsoup4==4.12.2
|
||||
# via bs4
|
||||
black==23.7.0
|
||||
# via -r requirements-dev.in
|
||||
bleach==6.0.0
|
||||
# via readme-renderer
|
||||
bs4==0.0.1
|
||||
# via -r requirements.in
|
||||
build==0.10.0
|
||||
# via -r requirements-dev.in
|
||||
certifi==2023.7.22
|
||||
# via
|
||||
# httpcore
|
||||
# httpx
|
||||
# requests
|
||||
cffi==1.15.1
|
||||
# via cryptography
|
||||
charset-normalizer==3.2.0
|
||||
# via requests
|
||||
click==8.1.6
|
||||
# via
|
||||
# black
|
||||
# uvicorn
|
||||
cryptography==41.0.2
|
||||
# via secretstorage
|
||||
dill==0.3.7
|
||||
# via pylint
|
||||
dnspython==2.4.1
|
||||
# via email-validator
|
||||
docutils==0.20.1
|
||||
# via readme-renderer
|
||||
email-validator==2.0.0.post2
|
||||
# via fastapi
|
||||
exceptiongroup==1.1.2
|
||||
# via anyio
|
||||
fastapi[all]==0.100.1
|
||||
# via -r requirements.in
|
||||
feedgen==0.9.0
|
||||
# via -r requirements.in
|
||||
greenlet==2.0.2
|
||||
# via sqlalchemy
|
||||
h11==0.14.0
|
||||
# via
|
||||
# httpcore
|
||||
# uvicorn
|
||||
httpcore==0.17.3
|
||||
# via httpx
|
||||
httptools==0.6.0
|
||||
# via uvicorn
|
||||
httpx==0.24.1
|
||||
# via fastapi
|
||||
idna==3.4
|
||||
# via
|
||||
# anyio
|
||||
# email-validator
|
||||
# httpx
|
||||
# requests
|
||||
importlib-metadata==6.8.0
|
||||
# via
|
||||
# keyring
|
||||
# twine
|
||||
isort==5.12.0
|
||||
# via pylint
|
||||
itsdangerous==2.1.2
|
||||
# via fastapi
|
||||
jaraco-classes==3.3.0
|
||||
# via keyring
|
||||
jeepney==0.8.0
|
||||
# via
|
||||
# keyring
|
||||
# secretstorage
|
||||
jinja2==3.1.2
|
||||
# via fastapi
|
||||
keyring==24.2.0
|
||||
# via twine
|
||||
lazy-object-proxy==1.9.0
|
||||
# via astroid
|
||||
lxml==4.9.3
|
||||
# via feedgen
|
||||
markdown-it-py==3.0.0
|
||||
# via rich
|
||||
markupsafe==2.1.3
|
||||
# via jinja2
|
||||
mccabe==0.7.0
|
||||
# via pylint
|
||||
mdurl==0.1.2
|
||||
# via markdown-it-py
|
||||
more-itertools==10.0.0
|
||||
# via jaraco-classes
|
||||
mypy-extensions==1.0.0
|
||||
# via black
|
||||
orjson==3.9.2
|
||||
# via fastapi
|
||||
packaging==23.1
|
||||
# via
|
||||
# black
|
||||
# build
|
||||
pathspec==0.11.2
|
||||
# via black
|
||||
pkginfo==1.9.6
|
||||
# via twine
|
||||
platformdirs==3.10.0
|
||||
# via
|
||||
# black
|
||||
# pylint
|
||||
pycparser==2.21
|
||||
# via cffi
|
||||
pydantic==2.1.1
|
||||
# via
|
||||
# fastapi
|
||||
# pydantic-extra-types
|
||||
# pydantic-settings
|
||||
pydantic-core==2.4.0
|
||||
# via pydantic
|
||||
pydantic-extra-types==2.0.0
|
||||
# via fastapi
|
||||
pydantic-settings==2.0.2
|
||||
# via fastapi
|
||||
pygments==2.15.1
|
||||
# via
|
||||
# readme-renderer
|
||||
# rich
|
||||
pylint==2.17.5
|
||||
# via -r requirements-dev.in
|
||||
pyproject-hooks==1.0.0
|
||||
# via build
|
||||
python-dateutil==2.8.2
|
||||
# via feedgen
|
||||
python-dotenv==1.0.0
|
||||
# via
|
||||
# pydantic-settings
|
||||
# uvicorn
|
||||
python-multipart==0.0.6
|
||||
# via fastapi
|
||||
pyyaml==6.0.1
|
||||
# via
|
||||
# fastapi
|
||||
# uvicorn
|
||||
readme-renderer==40.0
|
||||
# via twine
|
||||
requests==2.31.0
|
||||
# via
|
||||
# -r requirements.in
|
||||
# requests-toolbelt
|
||||
# twine
|
||||
requests-toolbelt==1.0.0
|
||||
# via twine
|
||||
rfc3986==2.0.0
|
||||
# via twine
|
||||
rich==13.5.0
|
||||
# via twine
|
||||
ruff==0.0.280
|
||||
# via -r requirements-dev.in
|
||||
secretstorage==3.3.3
|
||||
# via keyring
|
||||
six==1.16.0
|
||||
# via
|
||||
# bleach
|
||||
# python-dateutil
|
||||
sniffio==1.3.0
|
||||
# via
|
||||
# anyio
|
||||
# httpcore
|
||||
# httpx
|
||||
soupsieve==2.4.1
|
||||
# via beautifulsoup4
|
||||
sqlalchemy==2.0.19
|
||||
# via -r requirements.in
|
||||
starlette==0.27.0
|
||||
# via fastapi
|
||||
tomli==2.0.1
|
||||
# via
|
||||
# black
|
||||
# build
|
||||
# pylint
|
||||
# pyproject-hooks
|
||||
tomlkit==0.12.1
|
||||
# via pylint
|
||||
twine==4.0.2
|
||||
# via -r requirements-dev.in
|
||||
typing-extensions==4.7.1
|
||||
# via
|
||||
# astroid
|
||||
# fastapi
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
# sqlalchemy
|
||||
# uvicorn
|
||||
ujson==5.8.0
|
||||
# via fastapi
|
||||
urllib3==2.0.4
|
||||
# via
|
||||
# requests
|
||||
# twine
|
||||
uvicorn[standard]==0.23.1
|
||||
# via fastapi
|
||||
uvloop==0.17.0
|
||||
# via uvicorn
|
||||
watchfiles==0.19.0
|
||||
# via uvicorn
|
||||
webencodings==0.5.1
|
||||
# via bleach
|
||||
websockets==11.0.3
|
||||
# via uvicorn
|
||||
wrapt==1.15.0
|
||||
# via astroid
|
||||
zipp==3.16.2
|
||||
# via importlib-metadata
|
||||
3
requirements-server.in
Normal file
3
requirements-server.in
Normal file
@@ -0,0 +1,3 @@
|
||||
-r requirements.in
|
||||
|
||||
gunicorn
|
||||
@@ -1,7 +1,12 @@
|
||||
pkg-resources==0.0.0
|
||||
# Core application
|
||||
fastapi[all]
|
||||
sqlmodel
|
||||
|
||||
flask
|
||||
peewee
|
||||
flask-peewee
|
||||
bs4
|
||||
requests
|
||||
# Fetch title etc from links
|
||||
beautifulsoup4
|
||||
|
||||
# Fetch favicons
|
||||
extract_favicon
|
||||
|
||||
# Generate (atom) feeds for tags and such
|
||||
feedgen
|
||||
|
||||
137
requirements.txt
137
requirements.txt
@@ -1,24 +1,121 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile
|
||||
# To update, run:
|
||||
# This file is autogenerated by pip-compile with Python 3.10
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --output-file requirements.txt requirements.in
|
||||
# pip-compile requirements.in
|
||||
#
|
||||
beautifulsoup4==4.6.0 # via bs4
|
||||
annotated-types==0.5.0
|
||||
# via pydantic
|
||||
anyio==3.7.1
|
||||
# via
|
||||
# httpcore
|
||||
# starlette
|
||||
# watchfiles
|
||||
beautifulsoup4==4.12.2
|
||||
# via bs4
|
||||
bs4==0.0.1
|
||||
certifi==2017.4.17 # via requests
|
||||
chardet==3.0.4 # via requests
|
||||
click==6.7 # via flask
|
||||
flask-peewee==0.6.7
|
||||
flask==0.12.2
|
||||
idna==2.5 # via requests
|
||||
itsdangerous==0.24 # via flask
|
||||
jinja2==2.9.6 # via flask, flask-peewee
|
||||
markupsafe==1.0 # via jinja2
|
||||
peewee==2.10.1
|
||||
pkg-resources==0.0.0
|
||||
requests==2.18.1
|
||||
urllib3==1.21.1 # via requests
|
||||
werkzeug==0.12.2 # via flask, flask-peewee
|
||||
wtf-peewee==0.2.6 # via flask-peewee
|
||||
wtforms==2.1 # via flask-peewee, wtf-peewee
|
||||
# via -r requirements.in
|
||||
certifi==2023.7.22
|
||||
# via
|
||||
# httpcore
|
||||
# httpx
|
||||
# requests
|
||||
charset-normalizer==3.2.0
|
||||
# via requests
|
||||
click==8.1.6
|
||||
# via uvicorn
|
||||
dnspython==2.4.1
|
||||
# via email-validator
|
||||
email-validator==2.0.0.post2
|
||||
# via fastapi
|
||||
exceptiongroup==1.1.2
|
||||
# via anyio
|
||||
fastapi[all]==0.100.1
|
||||
# via -r requirements.in
|
||||
feedgen==0.9.0
|
||||
# via -r requirements.in
|
||||
greenlet==2.0.2
|
||||
# via sqlalchemy
|
||||
h11==0.14.0
|
||||
# via
|
||||
# httpcore
|
||||
# uvicorn
|
||||
httpcore==0.17.3
|
||||
# via httpx
|
||||
httptools==0.6.0
|
||||
# via uvicorn
|
||||
httpx==0.24.1
|
||||
# via fastapi
|
||||
idna==3.4
|
||||
# via
|
||||
# anyio
|
||||
# email-validator
|
||||
# httpx
|
||||
# requests
|
||||
itsdangerous==2.1.2
|
||||
# via fastapi
|
||||
jinja2==3.1.2
|
||||
# via fastapi
|
||||
lxml==4.9.3
|
||||
# via feedgen
|
||||
markupsafe==2.1.3
|
||||
# via jinja2
|
||||
orjson==3.9.2
|
||||
# via fastapi
|
||||
pydantic==2.1.1
|
||||
# via
|
||||
# fastapi
|
||||
# pydantic-extra-types
|
||||
# pydantic-settings
|
||||
pydantic-core==2.4.0
|
||||
# via pydantic
|
||||
pydantic-extra-types==2.0.0
|
||||
# via fastapi
|
||||
pydantic-settings==2.0.2
|
||||
# via fastapi
|
||||
python-dateutil==2.8.2
|
||||
# via feedgen
|
||||
python-dotenv==1.0.0
|
||||
# via
|
||||
# pydantic-settings
|
||||
# uvicorn
|
||||
python-multipart==0.0.6
|
||||
# via fastapi
|
||||
pyyaml==6.0.1
|
||||
# via
|
||||
# fastapi
|
||||
# uvicorn
|
||||
requests==2.31.0
|
||||
# via -r requirements.in
|
||||
six==1.16.0
|
||||
# via python-dateutil
|
||||
sniffio==1.3.0
|
||||
# via
|
||||
# anyio
|
||||
# httpcore
|
||||
# httpx
|
||||
soupsieve==2.4.1
|
||||
# via beautifulsoup4
|
||||
sqlalchemy==2.0.19
|
||||
# via -r requirements.in
|
||||
starlette==0.27.0
|
||||
# via fastapi
|
||||
typing-extensions==4.7.1
|
||||
# via
|
||||
# fastapi
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
# sqlalchemy
|
||||
# uvicorn
|
||||
ujson==5.8.0
|
||||
# via fastapi
|
||||
urllib3==2.0.4
|
||||
# via requests
|
||||
uvicorn[standard]==0.23.1
|
||||
# via fastapi
|
||||
uvloop==0.17.0
|
||||
# via uvicorn
|
||||
watchfiles==0.19.0
|
||||
# via uvicorn
|
||||
websockets==11.0.3
|
||||
# via uvicorn
|
||||
|
||||
46
setup.py
46
setup.py
@@ -1,43 +1,7 @@
|
||||
"""
|
||||
A setuptools based setup module.
|
||||
See:
|
||||
https://packaging.python.org/en/latest/distributing.html
|
||||
https://github.com/pypa/sampleproject
|
||||
"""
|
||||
#!/usr/bin/env python
|
||||
"""Install script for module installation. Compatibility stub because pyproject.toml is used."""
|
||||
|
||||
from setuptools import setup
|
||||
# To use a consistent encoding
|
||||
from codecs import open
|
||||
from os import path
|
||||
import setuptools
|
||||
|
||||
here = path.abspath(path.dirname(__file__))
|
||||
|
||||
# Get the long description from the relevant file
|
||||
with open(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 and automatic title fetching.',
|
||||
#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.0',
|
||||
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'],
|
||||
|
||||
py_modules=['digimarks'],
|
||||
|
||||
zip_safe=True,
|
||||
)
|
||||
if __name__ == "__main__":
|
||||
setuptools.setup()
|
||||
|
||||
0
src/digimarks/__init__.py
Normal file
0
src/digimarks/__init__.py
Normal file
635
src/digimarks/main.py
Normal file
635
src/digimarks/main.py
Normal file
@@ -0,0 +1,635 @@
|
||||
"""digimarks main module."""
|
||||
|
||||
import binascii
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated, Optional, Sequence, Type, TypeVar
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import bs4
|
||||
import httpx
|
||||
from extract_favicon import from_html
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import AnyUrl, DirectoryPath, FilePath, computed_field
|
||||
from pydantic_settings import BaseSettings
|
||||
from sqlmodel import AutoString, Field, Session, SQLModel, create_engine, desc, select
|
||||
|
||||
DIGIMARKS_USER_AGENT = 'digimarks/2.0.0-dev'
|
||||
DIGIMARKS_VERSION = '2.0.0a1'
|
||||
|
||||
DEFAULT_THEME = 'freshgreen'
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuration needed for digimarks to find its database, favicons, API integrations."""
|
||||
|
||||
# outside the codebase
|
||||
database_file: FilePath
|
||||
favicons_dir: DirectoryPath
|
||||
|
||||
# inside the codebase
|
||||
static_dir: DirectoryPath = 'static'
|
||||
template_dir: DirectoryPath = 'templates'
|
||||
|
||||
media_url: str = '/static/'
|
||||
|
||||
system_key: str
|
||||
|
||||
debug: bool = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
print(settings.model_dump())
|
||||
|
||||
engine = create_engine(f'sqlite:///{settings.database_file}', connect_args={'check_same_thread': False})
|
||||
|
||||
|
||||
def get_session():
|
||||
"""SQLAlchemy session factory."""
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
SessionDep = Annotated[Session, Depends(get_session)]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(the_app: FastAPI):
|
||||
"""Upon start, initialise an AsyncClient and assign it to an attribute named requests_client on the app object."""
|
||||
the_app.requests_client = httpx.AsyncClient()
|
||||
yield
|
||||
await the_app.requests_client.aclose()
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
app.mount('/static', StaticFiles(directory=settings.static_dir), name='static')
|
||||
app.mount('/content/favicons', StaticFiles(directory=settings.favicons_dir), name='favicons')
|
||||
templates = Jinja2Templates(directory=settings.template_dir)
|
||||
|
||||
logger = logging.getLogger('digimarks')
|
||||
if settings.debug:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# CORS configuration
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=['*'], # Allow requests from everywhere
|
||||
allow_credentials=True,
|
||||
allow_methods=['*'],
|
||||
allow_headers=['*'],
|
||||
)
|
||||
|
||||
|
||||
def i_filter_false(predicate, iterable):
|
||||
"""Filter an iterable if predicate returns True.
|
||||
|
||||
i_filter_false(lambda x: x%2, range(10)) --> 0 2 4 6 8
|
||||
"""
|
||||
if predicate is None:
|
||||
predicate = bool
|
||||
for x in iterable:
|
||||
if not predicate(x):
|
||||
yield x
|
||||
|
||||
|
||||
def unique_ever_seen(iterable, key=None):
|
||||
"""List unique elements, preserving order. Remember all elements ever seen.
|
||||
|
||||
unique_ever_seen('AAAABBBCCDAABBB') --> A B C D
|
||||
unique_ever_seen('ABBCcAD', str.lower) --> A B C D
|
||||
"""
|
||||
seen = set()
|
||||
seen_add = seen.add
|
||||
if key is None:
|
||||
for element in i_filter_false(seen.__contains__, iterable):
|
||||
seen_add(element)
|
||||
yield element
|
||||
else:
|
||||
for element in iterable:
|
||||
k = key(element)
|
||||
if k not in seen:
|
||||
seen_add(k)
|
||||
yield element
|
||||
|
||||
|
||||
def clean_tags(tags_list: list) -> list:
|
||||
"""Generate a unique list of the tags.
|
||||
|
||||
:param list tags_list: List with all tags
|
||||
:return: deduplicated list of the tags, without leading or trailing whitespace
|
||||
:rtype: list
|
||||
"""
|
||||
tags_res = [x.strip() for x in tags_list]
|
||||
tags_res = list(unique_ever_seen(tags_res))
|
||||
tags_res.sort()
|
||||
if tags_res and tags_res[0] == '':
|
||||
del tags_res[0]
|
||||
return tags_res
|
||||
|
||||
|
||||
def list_tags_for_bookmarks(bookmarks: list) -> list:
|
||||
"""Generate a unique list of the tags from the list of bookmarks."""
|
||||
tags = []
|
||||
for bookmark in bookmarks:
|
||||
tags += bookmark.tags_list
|
||||
return clean_tags(tags)
|
||||
|
||||
|
||||
def file_type(filename: str) -> str:
|
||||
"""Try to determine the file type for the file in `filename`.
|
||||
|
||||
:param str filename: path to file to check
|
||||
:return: zip file type
|
||||
:rtype: str
|
||||
"""
|
||||
magic_dict = {b'\x1f\x8b\x08': 'gz', b'\x42\x5a\x68': 'bz2', b'\x50\x4b\x03\x04': 'zip'}
|
||||
|
||||
max_len = max(len(x) for x in magic_dict)
|
||||
|
||||
with open(filename, 'rb') as f:
|
||||
file_start = f.read(max_len)
|
||||
for magic, filetype in magic_dict.items():
|
||||
if file_start.startswith(magic):
|
||||
return filetype
|
||||
return 'no match'
|
||||
|
||||
|
||||
def generate_hash(input_text: str) -> str:
|
||||
"""Generate a hash from string `input`, e.g., for a URL."""
|
||||
return hashlib.md5(input_text.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def generate_key() -> str:
|
||||
"""Generate a key to be used for a user or tag."""
|
||||
return str(binascii.hexlify(os.urandom(24)))
|
||||
|
||||
|
||||
# Type var used for building custom types for the DB
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
def build_custom_type(internal_type: Type[T]) -> Type[AutoString]:
|
||||
"""Create a type that is compatible with the database.
|
||||
|
||||
Based on https://github.com/fastapi/sqlmodel/discussions/847
|
||||
"""
|
||||
|
||||
class CustomType(AutoString):
|
||||
def process_bind_param(self, value, dialect) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if isinstance(value, str):
|
||||
# Test if value is valid to avoid `process_result_value` failing
|
||||
try:
|
||||
internal_type(value) # type: ignore[call-arg]
|
||||
except ValueError as e:
|
||||
raise ValueError(f'Invalid value for {internal_type.__name__}: {e}') from e
|
||||
|
||||
return str(value)
|
||||
|
||||
def process_result_value(self, value, dialect) -> Optional[T]:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
return internal_type(value) # type: ignore[call-arg]
|
||||
|
||||
return CustomType
|
||||
|
||||
|
||||
def get_favicon(html_content: str, root_url: str) -> str:
|
||||
"""Fetch the favicon from `html_content` using `root_url`."""
|
||||
favicons = from_html(html_content, root_url=root_url, include_fallbacks=True)
|
||||
for favicon in favicons:
|
||||
print(favicon.url, favicon.width, favicon.height)
|
||||
# TODO: save the preferred image to file and return
|
||||
|
||||
|
||||
class User(SQLModel, table=True):
|
||||
"""User account."""
|
||||
|
||||
__tablename__ = 'user'
|
||||
|
||||
id: int = Field(primary_key=True)
|
||||
username: str
|
||||
key: str
|
||||
theme: str = Field(default=DEFAULT_THEME)
|
||||
created_date: datetime
|
||||
|
||||
|
||||
class Visibility:
|
||||
"""Options for visibility of an object."""
|
||||
|
||||
VISIBLE = 0
|
||||
DELETED = 1
|
||||
|
||||
|
||||
class Bookmark(SQLModel, table=True):
|
||||
"""Bookmark object."""
|
||||
|
||||
__tablename__ = 'bookmark'
|
||||
|
||||
id: int = Field(primary_key=True)
|
||||
userkey: str = Field(foreign_key='user.key')
|
||||
title: str = Field(default='')
|
||||
url: AnyUrl = Field(default='', sa_type=build_custom_type(AnyUrl))
|
||||
note: str = Field(default='')
|
||||
# image: str = Field(default='')
|
||||
url_hash: str = Field(default='')
|
||||
tags: str = Field(default='')
|
||||
starred: bool = Field(default=False)
|
||||
|
||||
favicon: str | None = Field(default=None)
|
||||
|
||||
http_status: int = Field(default=HTTPStatus.OK)
|
||||
|
||||
created_date: datetime = Field(default=datetime.now(UTC))
|
||||
modified_date: datetime = Field(default=None)
|
||||
deleted_date: datetime = Field(default=None)
|
||||
|
||||
status: int = Field(default=Visibility.VISIBLE)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def tag_list(self) -> list:
|
||||
"""The tags but as a proper list."""
|
||||
if self.tags:
|
||||
return self.tags.split(',')
|
||||
# Not tags, return empty list instead of [''] that split returns in that case
|
||||
return []
|
||||
|
||||
|
||||
async def set_information_from_source(bookmark: Bookmark, request: Request) -> Bookmark:
|
||||
"""Request the title by requesting the source url."""
|
||||
logger.info('Extracting information from url %s', bookmark.url)
|
||||
try:
|
||||
result = await request.app.requests_client.get(bookmark.url, headers={'User-Agent': DIGIMARKS_USER_AGENT})
|
||||
bookmark.http_status = result.status_code
|
||||
except httpx.HTTPError as err:
|
||||
# For example, 'MissingSchema: Invalid URL 'abc': No schema supplied. Perhaps you meant http://abc?'
|
||||
logger.error('Exception when trying to retrieve title for %s. Error: %s', bookmark.url, str(err))
|
||||
bookmark.http_status = 404
|
||||
bookmark.title = ''
|
||||
return bookmark
|
||||
if bookmark.http_status == 200 or bookmark.http_status == 202:
|
||||
html = bs4.BeautifulSoup(result.text, 'html.parser')
|
||||
try:
|
||||
bookmark.title = html.title.text.strip()
|
||||
except AttributeError:
|
||||
bookmark.title = ''
|
||||
|
||||
url_parts = urlparse(str(bookmark.url))
|
||||
root_url = url_parts.scheme + '://' + url_parts.netloc
|
||||
favicon = get_favicon(result.text, root_url)
|
||||
# filename = os.path.join(settings.media_dir, 'favicons/', domain + file_extension)
|
||||
# with open(filename, 'wb') as out_file:
|
||||
# shutil.copyfileobj(response.raw, out_file)
|
||||
|
||||
# Extraction was successful
|
||||
logger.info('Extracting information was successful')
|
||||
return bookmark
|
||||
|
||||
|
||||
def set_tags(bookmark: Bookmark, new_tags: str) -> None:
|
||||
"""Set tags from `tags`, strip and sort them.
|
||||
|
||||
:param Bookmark bookmark: Bookmark to modify
|
||||
:param str new_tags: New tags to sort and set.
|
||||
"""
|
||||
tags_split = new_tags.split(',')
|
||||
tags_clean = clean_tags(tags_split)
|
||||
bookmark.tags = ','.join(tags_clean)
|
||||
|
||||
|
||||
def strip_url_params(url: str) -> str:
|
||||
"""Strip URL params from URL.
|
||||
|
||||
:param url: URL to strip URL params from.
|
||||
:return: clean URL
|
||||
:rtype: str
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
return urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, '', parsed.fragment))
|
||||
|
||||
|
||||
def update_bookmark_with_info(bookmark: Bookmark, request: Request, strip_params: bool = False):
|
||||
"""Automatically update title, favicon, etc."""
|
||||
if not bookmark.title:
|
||||
# Title was empty, automatically fetch it from the url, will also update the status code
|
||||
set_information_from_source(bookmark, request)
|
||||
|
||||
if strip_params:
|
||||
# Strip URL parameters, e.g., tracking params
|
||||
bookmark.url = AnyUrl(strip_url_params(str(bookmark.url)))
|
||||
|
||||
# Sort and deduplicate tags
|
||||
set_tags(bookmark, bookmark.tags)
|
||||
|
||||
|
||||
class PublicTag(SQLModel, table=True):
|
||||
"""Public tag object."""
|
||||
|
||||
__tablename__ = 'public_tag'
|
||||
|
||||
id: int = Field(primary_key=True)
|
||||
tagkey: str
|
||||
userkey: str = Field(foreign_key='user.key')
|
||||
tag: str
|
||||
created_date: datetime = Field(default=datetime.now(UTC))
|
||||
|
||||
|
||||
@app.get('/', response_class=HTMLResponse)
|
||||
@app.head('/', response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
"""Homepage, point visitors to project page."""
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name='index.html',
|
||||
context={'language': 'en', 'version': DIGIMARKS_VERSION, 'theme': DEFAULT_THEME},
|
||||
)
|
||||
|
||||
|
||||
@app.get('/api/v1/admin/{system_key}/users/{user_id}', response_model=User)
|
||||
def get_user(session: SessionDep, system_key: str, user_id: int) -> Type[User]:
|
||||
"""Show user information."""
|
||||
if system_key != settings.system_key:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
user = session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail='User not found')
|
||||
return user
|
||||
|
||||
|
||||
# @app.get('/admin/{system_key}/users/', response_model=list[User])
|
||||
@app.get('/api/v1/admin/{system_key}/users/')
|
||||
def list_users(
|
||||
session: SessionDep,
|
||||
system_key: str,
|
||||
offset: int = 0,
|
||||
limit: Annotated[int, Query(le=100)] = 100,
|
||||
) -> Sequence[User]:
|
||||
"""List all users in the database.
|
||||
|
||||
:param SessionDep session:
|
||||
:param str system_key: secrit key
|
||||
:param int offset: [Optional] offset of pagination
|
||||
:param int limit: [Optional] limits the number of users to return, defaults to 100
|
||||
:return: list of users in the system
|
||||
:rtype: list[User]
|
||||
"""
|
||||
if system_key != settings.system_key:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
users = session.exec(select(User).offset(offset).limit(limit)).all()
|
||||
return users
|
||||
|
||||
|
||||
@app.get('/api/v1/{user_key}/bookmarks/')
|
||||
def list_bookmarks(
|
||||
session: SessionDep,
|
||||
user_key: str,
|
||||
offset: int = 0,
|
||||
limit: Annotated[int, Query(le=10000)] = 100,
|
||||
) -> list[Bookmark]:
|
||||
"""List all bookmarks in the database. By default 100 items are returned."""
|
||||
bookmarks = session.exec(
|
||||
select(Bookmark)
|
||||
.where(Bookmark.userkey == user_key, Bookmark.status != Visibility.DELETED)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).all()
|
||||
return bookmarks
|
||||
|
||||
|
||||
@app.get('/api/v1/{user_key}/bookmarks/{url_hash}')
|
||||
def get_bookmark(
|
||||
session: SessionDep,
|
||||
user_key: str,
|
||||
url_hash: str,
|
||||
) -> Bookmark:
|
||||
"""Show bookmark details."""
|
||||
bookmark = session.exec(
|
||||
select(Bookmark).where(
|
||||
Bookmark.userkey == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
|
||||
)
|
||||
).first()
|
||||
# bookmark = session.get(Bookmark, {'url_hash': url_hash, 'userkey': user_key})
|
||||
return bookmark
|
||||
|
||||
|
||||
@app.post('/api/v1/{user_key}/autocomplete_bookmark/', response_model=Bookmark)
|
||||
def autocomplete_bookmark(
|
||||
session: SessionDep,
|
||||
request: Request,
|
||||
user_key: str,
|
||||
bookmark: Bookmark,
|
||||
strip_params: bool = False,
|
||||
):
|
||||
"""Autofill some fields for this (new) bookmark for user `user_key`."""
|
||||
bookmark.userkey = user_key
|
||||
|
||||
# Auto-fill title, fix tags etc.
|
||||
update_bookmark_with_info(bookmark, request, strip_params)
|
||||
|
||||
url_hash = generate_hash(str(bookmark.url))
|
||||
bookmark_db = session.exec(
|
||||
select(Bookmark).where(
|
||||
Bookmark.userkey == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
|
||||
)
|
||||
).first()
|
||||
if bookmark_db:
|
||||
# Bookmark with this URL already exists, provide the hash so the frontend can look it up and the user can
|
||||
# merge them if so wanted
|
||||
bookmark.url_hash = url_hash
|
||||
|
||||
return bookmark
|
||||
|
||||
|
||||
@app.post('/api/v1/{user_key}/bookmarks/', response_model=Bookmark)
|
||||
def add_bookmark(
|
||||
session: SessionDep,
|
||||
request: Request,
|
||||
user_key: str,
|
||||
bookmark: Bookmark,
|
||||
strip_params: bool = False,
|
||||
):
|
||||
"""Add new bookmark for user `user_key`."""
|
||||
bookmark.userkey = user_key
|
||||
|
||||
# Auto-fill title, fix tags etc.
|
||||
update_bookmark_with_info(bookmark, request, strip_params)
|
||||
bookmark.url_hash = generate_hash(str(bookmark.url))
|
||||
|
||||
session.add(bookmark)
|
||||
session.commit()
|
||||
session.refresh(bookmark)
|
||||
return bookmark
|
||||
|
||||
|
||||
@app.patch('/api/v1/{user_key}/bookmarks/{url_hash}', response_model=Bookmark)
|
||||
def update_bookmark(
|
||||
session: SessionDep,
|
||||
request: Request,
|
||||
user_key: str,
|
||||
bookmark: Bookmark,
|
||||
url_hash: str,
|
||||
strip_params: bool = False,
|
||||
):
|
||||
"""Update existing bookmark `bookmark_key` for user `user_key`."""
|
||||
bookmark_db = session.exec(
|
||||
select(Bookmark).where(
|
||||
Bookmark.userkey == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
|
||||
)
|
||||
).first()
|
||||
if not bookmark_db:
|
||||
raise HTTPException(status_code=404, detail='Bookmark not found')
|
||||
|
||||
bookmark.modified_date = datetime.now(UTC)
|
||||
|
||||
# 'patch' endpoint, which means that you can send only the data that you want to update, leaving the rest intact
|
||||
bookmark_data = bookmark.model_dump(exclude_unset=True)
|
||||
# Merge the changed fields into the existing object
|
||||
bookmark_db.sqlmodel_update(bookmark_data)
|
||||
|
||||
# Autofill title, fix tags, etc. where (still) needed
|
||||
update_bookmark_with_info(bookmark, request, strip_params)
|
||||
|
||||
session.add(bookmark_db)
|
||||
session.commit()
|
||||
session.refresh(bookmark_db)
|
||||
return bookmark_db
|
||||
|
||||
|
||||
@app.delete('/api/v1/{user_key}/bookmarks/{url_hash}', response_model=Bookmark)
|
||||
def delete_bookmark(
|
||||
session: SessionDep,
|
||||
user_key: str,
|
||||
url_hash: str,
|
||||
):
|
||||
"""(Soft)Delete bookmark `bookmark_key` for user `user_key`."""
|
||||
bookmark = session.get(Bookmark, {'url_hash': url_hash, 'userkey': user_key})
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=404, detail='Bookmark not found')
|
||||
bookmark.deleted_date = datetime.now(UTC)
|
||||
bookmark.status = Visibility.DELETED
|
||||
session.add(bookmark)
|
||||
session.commit()
|
||||
return {'ok': True}
|
||||
|
||||
|
||||
@app.get('/api/v1/{user_key}/latest_changes/')
|
||||
def bookmarks_changed_since(
|
||||
session: SessionDep,
|
||||
user_key: str,
|
||||
):
|
||||
"""Last update on server, so the (browser) client knows whether to fetch an update."""
|
||||
latest_modified_bookmark = session.exec(
|
||||
select(Bookmark)
|
||||
.where(Bookmark.userkey == user_key, Bookmark.status != Visibility.DELETED)
|
||||
.order_by(desc(Bookmark.modified_date))
|
||||
).first()
|
||||
latest_created_bookmark = session.exec(
|
||||
select(Bookmark)
|
||||
.where(Bookmark.userkey == user_key, Bookmark.status != Visibility.DELETED)
|
||||
.order_by(desc(Bookmark.created_date))
|
||||
).first()
|
||||
|
||||
latest_modification = max(latest_modified_bookmark.modified_date, latest_created_bookmark.created_date)
|
||||
|
||||
return {
|
||||
'current_time': datetime.now(UTC),
|
||||
'latest_change': latest_modified_bookmark.modified_date,
|
||||
'latest_created': latest_created_bookmark.created_date,
|
||||
'latest_modification': latest_modification,
|
||||
}
|
||||
|
||||
|
||||
@app.get('/api/v1/{user_key}/tags/')
|
||||
def list_tags_for_user(
|
||||
session: SessionDep,
|
||||
user_key: str,
|
||||
) -> list[str]:
|
||||
"""List all tags in use by the user."""
|
||||
bookmarks = session.exec(
|
||||
select(Bookmark).where(Bookmark.userkey == user_key, Bookmark.status != Visibility.DELETED)
|
||||
).all()
|
||||
tags = []
|
||||
for bookmark in bookmarks:
|
||||
tags += bookmark.tag_list
|
||||
return clean_tags(tags)
|
||||
|
||||
|
||||
@app.get('/api/v1/{user_key}/tags/{tag_key}')
|
||||
def list_tags_for_user(
|
||||
session: SessionDep,
|
||||
user_key: str,
|
||||
) -> list[str]:
|
||||
"""List all tags in use by the user."""
|
||||
bookmarks = session.exec(select(Bookmark).where(Bookmark.userkey == user_key)).all()
|
||||
return list_tags_for_bookmarks(bookmarks)
|
||||
|
||||
|
||||
@app.get('/{user_key}', response_class=HTMLResponse)
|
||||
def page_user_landing(
|
||||
session: SessionDep,
|
||||
request: Request,
|
||||
user_key: str,
|
||||
):
|
||||
"""HTML page with the main view for the user."""
|
||||
user = session.exec(select(User).where(User.key == user_key)).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail='User not found')
|
||||
language = 'en'
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name='user_index.html',
|
||||
context={'language': language, 'version': DIGIMARKS_VERSION, 'user_key': user_key},
|
||||
)
|
||||
|
||||
|
||||
# def tags_page(userkey):
|
||||
# """Overview of all tags used by user"""
|
||||
# tags = get_cached_tags(userkey)
|
||||
# alltags = []
|
||||
# for tag in tags:
|
||||
# try:
|
||||
# publictag = PublicTag.get(PublicTag.userkey == userkey, PublicTag.tag == tag)
|
||||
# except PublicTag.DoesNotExist:
|
||||
# publictag = None
|
||||
#
|
||||
# total = (
|
||||
# Bookmark.select()
|
||||
# .where(Bookmark.userkey == userkey, Bookmark.tags.contains(tag), Bookmark.status == Bookmark.VISIBLE)
|
||||
# .count()
|
||||
# )
|
||||
# alltags.append({'tag': tag, 'publictag': publictag, 'total': total})
|
||||
# totaltags = len(alltags)
|
||||
# totalbookmarks = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.status == Bookmark.VISIBLE).count()
|
||||
# totalpublic = PublicTag.select().where(PublicTag.userkey == userkey).count()
|
||||
# totalstarred = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.starred).count()
|
||||
# totaldeleted = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.status == Bookmark.DELETED).count()
|
||||
# totalnotes = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.note != '').count()
|
||||
# totalhttperrorstatus = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.http_status != 200).count()
|
||||
# theme = get_theme(userkey)
|
||||
# return render_template(
|
||||
# 'tags.html',
|
||||
# tags=alltags,
|
||||
# totaltags=totaltags,
|
||||
# totalpublic=totalpublic,
|
||||
# totalbookmarks=totalbookmarks,
|
||||
# totaldeleted=totaldeleted,
|
||||
# totalstarred=totalstarred,
|
||||
# totalhttperrorstatus=totalhttperrorstatus,
|
||||
# totalnotes=totalnotes,
|
||||
# userkey=userkey,
|
||||
# theme=theme,
|
||||
# )
|
||||
25
src/digimarks/static/css/digimarks.css
Normal file
25
src/digimarks/static/css/digimarks.css
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* digimarks styling
|
||||
*
|
||||
* Overrides on and additions to the digui styling
|
||||
*/
|
||||
|
||||
/* Star, error, note etc */
|
||||
.card .statuses {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.star {
|
||||
color: #ffeb3b;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
/*width: 80px;*/
|
||||
width: 66;
|
||||
}
|
||||
|
||||
.thumbnail img {
|
||||
/*width: 72px;*/
|
||||
width: 60px;
|
||||
}
|
||||
450
src/digimarks/static/css/digui.css
Normal file
450
src/digimarks/static/css/digui.css
Normal file
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* digui structure and theming
|
||||
* v0.0.2
|
||||
*
|
||||
* Created by: Michiel Scholten
|
||||
* Source: https://github.com/aquatix/digui
|
||||
*/
|
||||
|
||||
/** Colours and themes */
|
||||
|
||||
:root {
|
||||
--padding: .5rem;
|
||||
|
||||
/* Default is nebula-light */
|
||||
--font-family: sans-serif;
|
||||
--background-color: #fff;
|
||||
--background-color-secondary: #ccc;
|
||||
--button-color: #eee;
|
||||
--button-text: var(--text-color);
|
||||
--text-color: #121416d8;
|
||||
--text-color-secondary: #121416d8;
|
||||
--text-color-muted: #d5d9d9;
|
||||
--link-color: #543fd7;
|
||||
|
||||
--nav-background-color: #FFF;
|
||||
--nav-color: var(--text-color);
|
||||
|
||||
--border-color: #d5d9d9;
|
||||
--border-width: 1px;
|
||||
--border-radius: 8px;
|
||||
--shadow-color: rgba(213, 217, 217, .5);
|
||||
--global-theme-toggle-content: ' 🌞';
|
||||
|
||||
/* E.g., an active button */
|
||||
--color-highlight: #fb8c00;
|
||||
/* Generic colors */
|
||||
/*--color-danger: #e03131;*/
|
||||
--color-danger: var(--color-red);
|
||||
--color-warning: var(--color-yellow);
|
||||
--color-error: var(--color-danger);
|
||||
/*--color-ok: #31e031;*/
|
||||
--color-ok: var(--color-green);
|
||||
|
||||
/* Argonaut colours */
|
||||
--color-black: #000000;
|
||||
--color-red: #FF000F;
|
||||
--color-green: #8CE10B;
|
||||
--color-yellow: #FFB900;
|
||||
--color-blue: #008DF8;
|
||||
--color-purple: #6D43A6;
|
||||
--color-cyan: #00D8EB;
|
||||
--color-white: #FFFFFF;
|
||||
}
|
||||
|
||||
html[data-theme='nebula'] {
|
||||
/* Default theme, see :root element */
|
||||
}
|
||||
|
||||
html[data-theme='nebula-dark'] {
|
||||
--background-color: #29292c;
|
||||
--background-color-secondary: #29292c;
|
||||
--button-color: #29292c;
|
||||
--button-text: var(--text-color);
|
||||
--text-color: #F7F8F8;
|
||||
--text-color-secondary: #ddd;
|
||||
--text-color-muted: #F7F8F8;
|
||||
--link-color: #ffe7a3;
|
||||
--color-highlight: #e03131;
|
||||
|
||||
--nav-background-color: #FF9800;
|
||||
--nav-color: var(--text-color);
|
||||
|
||||
--border-color: #333;
|
||||
--border-width: 1px;
|
||||
--border-radius: 8px;
|
||||
--shadow-color: rgba(3, 3, 3, .5);
|
||||
--global-theme-toggle-content: ' 🌝';
|
||||
}
|
||||
|
||||
html[data-theme='bbs'] {
|
||||
--font-family: monospace;
|
||||
--background-color: #FFF;
|
||||
--background-color-secondary: #ccc;
|
||||
--button-color: #FFFFFF;
|
||||
--button-text: var(--text-color);
|
||||
--text-color: #000;
|
||||
--text-color-secondary: #000;
|
||||
--text-color-muted: #000;
|
||||
--link-color: #543fd7;
|
||||
--color-highlight: #e03131;
|
||||
|
||||
/*--nav-background-color: #ccc;*/
|
||||
/*--nav-color: var(--text-color);*/
|
||||
|
||||
--border-color: #333;
|
||||
--border-width: 2px;
|
||||
--border-radius: 0;
|
||||
--global-theme-toggle-content: ' 🖥️';
|
||||
}
|
||||
|
||||
html[data-theme='silo'] {
|
||||
--font-family: monospace;
|
||||
/*--background-color: #003eaa;*/
|
||||
--background-color: #1d212c;
|
||||
--background-color-secondary: var(--color-highlight);
|
||||
--button-color: #FFFFFF;
|
||||
--button-text: var(--text-color);
|
||||
--text-color: #FFF;
|
||||
--text-color-secondary: #29292c;
|
||||
--text-color-muted: #FFF;
|
||||
--link-color: #FF9800;
|
||||
--color-highlight: #23B0FF;
|
||||
|
||||
/*--nav-background-color: #003eaa;*/
|
||||
/*--nav-background-color: #23B0FF;*/
|
||||
/*--nav-color: var(--text-color);*/
|
||||
--nav-background-color: var(--background-color);
|
||||
|
||||
--border-color: #23B0FF;
|
||||
/*--border-color: #003eaa;*/
|
||||
--border-width: 2px;
|
||||
--border-radius: 0;
|
||||
--global-theme-toggle-content: ' ⌨️';
|
||||
}
|
||||
|
||||
/* AlpineJS blip-preventer */
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/** Main structure */
|
||||
|
||||
body {
|
||||
background: var(--background-color);
|
||||
color: var(--text-color);
|
||||
height: 125vh;
|
||||
font-family: var(--font-family), sans-serif;
|
||||
margin-top: 3rem;
|
||||
/*padding: 30px;*/
|
||||
}
|
||||
|
||||
main {
|
||||
color: var(--text-color);
|
||||
padding-top: .5em;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
|
||||
header {
|
||||
background-color: var(--nav-background-color);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/*box-shadow: 0 0 5px 0 rgba(213, 217, 217, .5);*/
|
||||
/*box-shadow: 0 0 5px 0 #999;*/
|
||||
box-shadow: 0 0 5px var(--shadow-color);
|
||||
}
|
||||
|
||||
header * {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
header li {
|
||||
/*margin: 10px;*/
|
||||
}
|
||||
|
||||
header li a {
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
header li h1 {
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
vertical-align: middle;
|
||||
margin-right: 3rem;
|
||||
}
|
||||
|
||||
[data-theme='silo'] header nav::after {
|
||||
content: '';
|
||||
background: repeating-linear-gradient(90deg, #23B0FF, #23B0FF 2px, transparent 0, transparent 10px);
|
||||
display: block;
|
||||
width: 100%;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
[data-theme='silo'] header {
|
||||
border-bottom: 3px dotted #23B0FF;
|
||||
}
|
||||
|
||||
/** Generic elements */
|
||||
|
||||
/* https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/Heading_Elements#specifying_a_uniform_font_size_for_h1 */
|
||||
h1 {
|
||||
margin-block: 0.67em;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
a, a:hover, a:visited, a:active, a.button, a.button:hover, a.button:active, a.button:visited {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
filter: brightness(80%);
|
||||
}
|
||||
|
||||
ol li::marker, ul li::marker {
|
||||
color: var(--text-color-muted);
|
||||
}
|
||||
|
||||
/* Active element, e.g. a button */
|
||||
.active {
|
||||
background-color: var(--color-highlight);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Special button */
|
||||
.theme-toggle::after {
|
||||
content: var(--global-theme-toggle-content);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
|
||||
button, .button, input, select, textarea {
|
||||
box-sizing: border-box;
|
||||
color: var(--text-color-secondary);
|
||||
background-color: var(--button-color);
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
/*line-height: 29px;*/
|
||||
/*padding: 0 10px 0 11px;*/
|
||||
position: relative;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: manipulation;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
button, .button, input, select, textarea, table {
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: .5rem .5rem;
|
||||
}
|
||||
|
||||
button:hover, .button:hover {
|
||||
/*background-color: #f7fafa;*/
|
||||
/*background-color: #d57803;*/
|
||||
background-color: var(--color-highlight);
|
||||
filter: brightness(80%);
|
||||
}
|
||||
|
||||
button:focus, .button:focus {
|
||||
/*border-color: #008296;*/
|
||||
/*box-shadow: rgba(213, 217, 217, .5) 0 2px 5px 0;*/
|
||||
outline: 0;
|
||||
/*border-color: #d57803;*/
|
||||
}
|
||||
|
||||
.btn-dangerous {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.btn-dangerous:hover {
|
||||
background: var(--color-danger);
|
||||
filter: brightness(80%);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background-color: var(--color-warning);
|
||||
filter: brightness(80%);
|
||||
}
|
||||
|
||||
.btn-ok {
|
||||
background: var(--color-ok);
|
||||
}
|
||||
|
||||
.btn-ok:hover {
|
||||
background: var(--color-ok);
|
||||
filter: brightness(80%);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-column-gap: 1rem;
|
||||
grid-row-gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
}
|
||||
|
||||
.card {
|
||||
display: inline-grid;
|
||||
border-radius: var(--border-radius);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
/*margin: 1em;*/
|
||||
/*padding: 1em;*/
|
||||
}
|
||||
|
||||
[data-theme='nebula'] .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'] .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);
|
||||
}
|
||||
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
BIN
src/digimarks/static/faviconfallback.png
Normal file
BIN
src/digimarks/static/faviconfallback.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 254 B |
0
src/digimarks/static/favicons/.notempty
Normal file
0
src/digimarks/static/favicons/.notempty
Normal file
221
src/digimarks/static/js/digimarks.js
Normal file
221
src/digimarks/static/js/digimarks.js
Normal file
@@ -0,0 +1,221 @@
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('digimarks', {
|
||||
/** Main digimarks application, state etc */
|
||||
// userKey: Alpine.$persist(0).as('userKey'),
|
||||
userKey: -1,
|
||||
/* cache consists of cache[userKey] = {'bookmarks': [], 'tags': [], ??} */
|
||||
cache: Alpine.$persist({}).as('cache'),
|
||||
|
||||
bookmarks: [],
|
||||
|
||||
/* Bookmark that is being edited, used to fill the form etc */
|
||||
bookmark_to_edit: null,
|
||||
|
||||
/* nebula (dropshadows), bbs (monospace, right lines), silo (like bbs but dark) ?? */
|
||||
themes: ['nebula', 'nebula-dark', 'bbs', 'silo'],
|
||||
theme: Alpine.$persist('nebula').as('theme'),
|
||||
|
||||
show_bookmarks: Alpine.$persist(true).as('show_bookmarks'),
|
||||
show_bookmarks_list: Alpine.$persist(true).as('show_bookmarks_list'),
|
||||
show_bookmarks_cards: Alpine.$persist(false).as('show_bookmarks_cards'),
|
||||
show_tags: Alpine.$persist(false).as('show_tags'),
|
||||
|
||||
/* Loading indicator */
|
||||
loading: false,
|
||||
|
||||
/* Search filter */
|
||||
search: '',
|
||||
/* Show bookmarks with this tag/these tags */
|
||||
tags_filter: [],
|
||||
/* Hide bookmarks with these tags */
|
||||
tags_to_hide: Alpine.$persist([]).as('tags_to_hide'),
|
||||
|
||||
/* Sort on ~ */
|
||||
sort_title_asc: Alpine.$persist(false).as('sort_title_asc'),
|
||||
sort_title_desc: Alpine.$persist(false).as('sort_title_desc'),
|
||||
sort_created_asc: Alpine.$persist(false).as('sort_created_asc'),
|
||||
sort_created_desc: Alpine.$persist(false).as('sort_created_desc'),
|
||||
|
||||
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 latest_status_response = await fetch('/api/v1/' + this.userKey + '/latest_changes/');
|
||||
let latest_status_result = await latest_status_response.json();
|
||||
let should_fetch = false;
|
||||
let latest_modification_in_cache = this.cache[this.userKey].latest_changes.latest_modification || "0000-00-00";
|
||||
should_fetch = latest_status_result.latest_modification > latest_modification_in_cache;
|
||||
this.cache[this.userKey].latest_changes = latest_status_result;
|
||||
|
||||
if (!should_fetch) {
|
||||
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 tags_response = await fetch('/api/v1/' + this.userKey + '/tags/');
|
||||
this.cache[this.userKey]['tags'] = await tags_response.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 prefiltered_bookmarks = [...this.cache[this.userKey]['bookmarks']] || [];
|
||||
if (this.tags_to_hide.length > 0) {
|
||||
console.log('Filtering away bookmarks containing blacklisted tags');
|
||||
this.bookmarks = prefiltered_bookmarks.filter(
|
||||
i => !this.hasTag(i.tag_list, this.tags_to_hide)
|
||||
)
|
||||
} else {
|
||||
this.bookmarks = prefiltered_bookmarks;
|
||||
}
|
||||
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.sort_title_asc) {
|
||||
this.bookmarks.sort((a, b) => a.title.localeCompare(b.title));
|
||||
} else if (this.sort_title_desc) {
|
||||
this.bookmarks.sort((a, b) => b.title.localeCompare(a.title));
|
||||
} else if (this.sort_created_asc) {
|
||||
this.bookmarks.sort((a, b) => a.created_date.localeCompare(b.created_date));
|
||||
} else if (this.sort_created_desc) {
|
||||
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.sort_created_asc = false;
|
||||
this.sort_created_desc = false;
|
||||
this.sort_title_asc = false;
|
||||
this.sort_title_desc = false;
|
||||
if (order === 'desc') {
|
||||
this.sort_title_desc = true;
|
||||
} else {
|
||||
this.sort_title_asc = true;
|
||||
}
|
||||
this.sortBookmarks();
|
||||
this.loading = false;
|
||||
},
|
||||
async sortCreated(order = 'asc') {
|
||||
/* Sort the bookmarks (reverse) chronologically, based on 'asc' or 'desc' */
|
||||
this.loading = true;
|
||||
this.sort_created_asc = false;
|
||||
this.sort_created_desc = false;
|
||||
this.sort_title_asc = false;
|
||||
this.sort_title_desc = false;
|
||||
if (order === 'desc') {
|
||||
this.sort_created_desc = true;
|
||||
} else {
|
||||
this.sort_created_asc = true;
|
||||
}
|
||||
this.sortBookmarks();
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async toggleTagPage() {
|
||||
/* Show or hide the tag page instead of the bookmarks */
|
||||
console.log('Toggle tag page');
|
||||
this.show_bookmarks = !this.show_bookmarks;
|
||||
this.show_tags = !this.show_bookmarks;
|
||||
},
|
||||
async toggleListOrGrid() {
|
||||
/* Toggle between 'list' or 'grid' (cards) view */
|
||||
this.show_bookmarks_list = !this.show_bookmarks_list;
|
||||
this.show_bookmarks_cards = !this.show_bookmarks_list;
|
||||
},
|
||||
|
||||
async startAddingBookmark() {
|
||||
/* Open 'add bookmark' page */
|
||||
console.log('Start adding bookmark');
|
||||
this.bookmark_to_edit = {
|
||||
'url': ''
|
||||
}
|
||||
},
|
||||
async addBookmark() {
|
||||
/* Post new bookmark to the backend */
|
||||
//
|
||||
}
|
||||
})
|
||||
});
|
||||
6
src/digimarks/templates/404.html
Normal file
6
src/digimarks/templates/404.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}404: Page not found{% endblock %}
|
||||
{% block page_header %}404: Page not found{% endblock %}
|
||||
{% block page_content %}
|
||||
The page you requested was not found.
|
||||
{% endblock %}
|
||||
33
src/digimarks/templates/base.html
Normal file
33
src/digimarks/templates/base.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}">
|
||||
<head>
|
||||
<title>{% block title %}{% endblock %} - digimarks</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/digui.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/static/css/digimarks.css?v={{ version }}">
|
||||
<link id="favicon" rel="icon" type="image/x-icon" href="/static/images/favicon.ico">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/images/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon-16x16.png">
|
||||
<link rel="manifest" href="/static/images/site.webmanifest">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-clipboard@2.x.x/dist/alpine-clipboard.js"
|
||||
defer></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
{% block page_content %}
|
||||
<div id="container" x-data="">
|
||||
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/static/js/digimarks.js?v={{ version }}"></script>
|
||||
{% block extrajs %}{% endblock %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
127
src/digimarks/templates/bookmarks.html
Normal file
127
src/digimarks/templates/bookmarks.html
Normal file
@@ -0,0 +1,127 @@
|
||||
{% extends "base.html" %}
|
||||
{% if not action %}
|
||||
{% set action = 'Bookmarks' %}
|
||||
{% endif %}
|
||||
{% block title %}{{ action }}{% endblock %}
|
||||
{% block page_header %}{{ action }}{% endblock %}
|
||||
{% block page_content %}
|
||||
|
||||
{% if tag and not publictag %}
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<a href="{{ url_for('addpublictag', userkey=userkey, tag=tag) }}">Create public page <i
|
||||
class="material-icons right">tag</i></a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if tag and publictag %}
|
||||
<div class="row">
|
||||
<div class="col s12"><a href="{{ url_for('publictag_page', tagkey=publictag.tagkey) }}">Public link</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if message %}
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<div class="card-panel {{ theme.MESSAGE_BACKGROUND }}">
|
||||
<span class="{{ theme.MESSAGE_TEXT }}">
|
||||
{{ message|safe }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<form action="{{ url_for('bookmarks_page', userkey=userkey) }}" name="filterForm" method="POST"
|
||||
autocomplete="off">
|
||||
<div class="input-field col l9 m9 s8">
|
||||
<input placeholder="search text" type="text" name="filter_text" id="filter_text" class="autocomplete"
|
||||
value="{{ filter_text }}" autocomplete="false"/>
|
||||
</div>
|
||||
|
||||
<div class="input-field col l3 m3 s4">
|
||||
<p class="right-align">
|
||||
<button class="btn waves-effect waves-light" type="submit" name="submitBtn" title="Find"><i
|
||||
class="material-icons">search</i></button>
|
||||
{% if show_as and show_as == 'list' %}
|
||||
<a href="{{ url_for('bookmarks_page', userkey=userkey, filtermethod=filtermethod, sortmethod=sortmethod, show_as=None) }}"
|
||||
class="waves-effect waves-light btn" title="Show as cards"><i class="material-icons">apps</i></a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('bookmarks_page', userkey=userkey, filtermethod=filtermethod, sortmethod=sortmethod, show_as='list') }}"
|
||||
class="waves-effect waves-light btn" title="Show as list"><i
|
||||
class="material-icons">reorder</i></a>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if tags %}
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<ul class="collapsible" data-collapsible="expandable">
|
||||
<li>
|
||||
<div class="collapsible-header"><i class="material-icons">label</i>Filter on
|
||||
star/problem/comment/tag
|
||||
</div>
|
||||
<div class="collapsible-body" style="padding: 10px;">
|
||||
<div class="chip">
|
||||
<a href="{{ url_for('bookmarks_page', userkey=userkey, filtermethod='starred') }}"><i
|
||||
class="tiny material-icons {{ theme.STAR }}">star</i></a>
|
||||
</div>
|
||||
<div class="chip">
|
||||
<a href="{{ url_for('bookmarks_page', userkey=userkey, filtermethod='broken') }}"><i
|
||||
class="tiny material-icons {{ theme.PROBLEM }}">report_problem</i></a>
|
||||
</div>
|
||||
<div class="chip">
|
||||
<a href="{{ url_for('bookmarks_page', userkey=userkey, filtermethod='note') }}"><i
|
||||
class="tiny material-icons {{ theme.COMMENT }}">comment</i></a>
|
||||
</div>
|
||||
{% for tag in tags %}
|
||||
<div class="chip">
|
||||
<a href="{{ url_for('tag_page', userkey=userkey, tag=tag) }}">{{ tag }}</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if show_as and show_as == 'list' %}
|
||||
{% include 'list.html' %}
|
||||
{% else %}
|
||||
{% include 'cards.html' %}
|
||||
{% endif %}
|
||||
|
||||
<div class="fixed-action-btn" style="bottom: 20px; right: 20px;">
|
||||
<a class="btn-floating btn-large {{ theme.FAB }}" href="{{ url_for('addbookmark', userkey=userkey) }}">
|
||||
<i class="large material-icons">add</i>
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extrajs %}
|
||||
<script>
|
||||
function submitFilter() {
|
||||
document.filterForm.submit();
|
||||
}
|
||||
|
||||
/* Search filter autocomplete */
|
||||
var options = {
|
||||
onAutocomplete: submitFilter,
|
||||
minLength: 3,
|
||||
limit: 10,
|
||||
data: {},
|
||||
}
|
||||
var elem = document.querySelector('.autocomplete');
|
||||
var instance = M.Autocomplete.init(elem, options);
|
||||
/* TODO: fetch from API
|
||||
instance.updateData({
|
||||
});
|
||||
*/
|
||||
</script>
|
||||
<script src="{{ url_for('bookmarks_js', userkey=userkey) }}"></script>
|
||||
{% endblock %}
|
||||
65
src/digimarks/templates/cards.html
Normal file
65
src/digimarks/templates/cards.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<div class="row">
|
||||
{% for bookmark in bookmarks %}
|
||||
<div class="col s12 m6 l4">
|
||||
<div class="card horizontal tiny {{ theme.CARD_BACKGROUND }}">
|
||||
<div class="card-image">
|
||||
{% if bookmark.favicon %}
|
||||
<div><img src="{{ url_for('static', filename='favicons/' + bookmark.favicon) }}" class="favicon" /></div>
|
||||
{% else %}
|
||||
<div><img src="{{ url_for('static', filename='faviconfallback.png') }}" class="favicon" /></div>
|
||||
{% endif %}
|
||||
{% if bookmark.http_status != 200 and bookmark.http_status != 304 %}
|
||||
<div><i class="small material-icons {{ theme.PROBLEM }}" title="HTTP status {{ bookmark.http_status }}">report_problem</i></div>
|
||||
{% endif %}
|
||||
{% if bookmark.starred == True %}
|
||||
<div><i class="small material-icons {{ theme.STAR }}">star</i></div>
|
||||
{% endif %}
|
||||
{% if bookmark.note %}
|
||||
<div><i class="small material-icons {{ theme.CARD_TEXT }}" title="{{ bookmark.note|truncate(100) }}">comment</i></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-stacked">
|
||||
<div class="card-content {{ theme.CARD_TEXT }}">
|
||||
<span class="digimark-card-header activator">
|
||||
<i class="material-icons right">more_vert</i>
|
||||
</span>
|
||||
<div class="digimark-card-content">
|
||||
<a href="{{ bookmark.url }}" title="{{ bookmark.url }}" rel="noreferrer noopener" target="_blank">
|
||||
{% if bookmark.title %}
|
||||
{{ bookmark.title }}
|
||||
{% else %}
|
||||
{{ bookmark.get_uri_domain() }} (no title)
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-reveal {{ theme.CARD_BACKGROUND }}">
|
||||
<span class="card-title {{ theme.CARD_TEXT }}">Added @ {{ bookmark.created_date.strftime('%Y-%m-%d %H:%M') }}<i class="material-icons right">close</i></span>
|
||||
{% if editable %}
|
||||
<div class="{{ theme.CARD_TEXT }}" style="padding-top: 10px;">
|
||||
<a href="{{ url_for('editbookmark', userkey=userkey, urlhash=bookmark.url_hash) }}" style="padding: 3px"><i class="tiny material-icons">mode_edit</i> EDIT</a>
|
||||
<a href="{{ url_for('deletingbookmark', userkey=userkey, urlhash=bookmark.url_hash) }}" style="padding: 3px" class="red-text"><i class="tiny material-icons">delete</i> DELETE</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if showtags %}
|
||||
<div class="digimark-card-header-tags">
|
||||
{% for tag in bookmark.tags_list %}
|
||||
<div class="chip">
|
||||
<a href="{{ url_for('tag_page', userkey=userkey, tag=tag) }}">{{ tag }}</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{#
|
||||
<div class="pagination">
|
||||
{% if page > 1 %}<a href="./?page={{ page - 1 }}">Previous</a>{% endif %}
|
||||
{% if pagination.get_pages() > page %}<a href="./?page={{ page + 1 }}">Next</a>{% endif %}
|
||||
</div>
|
||||
#}
|
||||
</div>
|
||||
@@ -3,13 +3,15 @@
|
||||
{% block pageheader %}{{ action }}{% endblock %}
|
||||
{% block pagecontent %}
|
||||
|
||||
{% if bookmark.http_status != 200 and bookmark.http_status != 304 %}
|
||||
{% 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 %}
|
||||
@@ -36,46 +38,45 @@
|
||||
{% endif %}
|
||||
|
||||
{% if formaction and formaction == 'edit' %}
|
||||
<form class="digimark" action="{{ url_for('editingbookmark', userkey=userkey, urlhash=bookmark.url_hash) }}" method="POST">
|
||||
<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" action="{{ url_for('addingbookmark', userkey=userkey) }}" method="POST">
|
||||
<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 }}" class="validate" />
|
||||
<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 }}" class="validate" />
|
||||
<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"><i class="material-icons left">turned_in</i>{{ bookmark.get_redirect_uri() }}</a>
|
||||
<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 () {
|
||||
$('#btn_urlupdate').on('click', function () {
|
||||
var text = $('#url');
|
||||
text.val('{{ bookmark.get_redirect_uri() }}');
|
||||
});
|
||||
});
|
||||
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 }}" class="validate" />
|
||||
<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 }}" class="validate" />
|
||||
<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>
|
||||
@@ -87,7 +88,7 @@
|
||||
<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="tag_{{ tag }}">
|
||||
<div class="chip clickable" id="chip_{{ tag }}" onclick="addTag('{{ tag }}');">
|
||||
{{ tag }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -95,29 +96,23 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% for tag in tags %}
|
||||
<script type="text/javascript">
|
||||
$(function () {
|
||||
$('#tag_{{ tag }}').on('click', function () {
|
||||
var text = $('#tags');
|
||||
text.val(text.val() + ', {{ tag }}');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
|
||||
<div class="input-field col s12">
|
||||
<div class="col s12">
|
||||
{#<i class="material-icons prefix">star</i>#}
|
||||
<input type="checkbox" name="starred" id="starred" {% if bookmark.starred == True %}checked{% endif %} />
|
||||
<label for="starred">Starred</label>
|
||||
<label>
|
||||
<input type="checkbox" name="starred" id="starred" {% if bookmark.starred == True %}checked{% endif %} />
|
||||
<span>Starred</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="input-field col s12">
|
||||
<input type="checkbox" name="strip" id="strip" />
|
||||
<label for="strip">Strip parameters from url (like <em>?utm_source=social</em> - can break the link!)</label>
|
||||
<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 %}
|
||||
@@ -152,9 +147,9 @@
|
||||
</div>
|
||||
{% if bookmark.url_hash %}
|
||||
</form>
|
||||
<div class="input-field col l2 m3 s4">
|
||||
<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" type="submit" name="delete">Delete <i class="material-icons right">delete</i></button></p>
|
||||
<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>
|
||||
@@ -164,9 +159,17 @@
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
$(function() {
|
||||
console.log('woei');
|
||||
$('form.digimark').on('submit',function(){$("#submit").prop("disabled", true); return true;})
|
||||
});
|
||||
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 %}
|
||||
30
src/digimarks/templates/index.html
Normal file
30
src/digimarks/templates/index.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}digimarks{% endblock %}
|
||||
{% block page_header %}digimarks{% endblock %}
|
||||
{% block page_content %}
|
||||
<article>
|
||||
|
||||
<header>
|
||||
<nav class="menu">
|
||||
<ul>
|
||||
<li><h1>digimarks</h1></li>
|
||||
<li>
|
||||
<a class="button" href="https://github.com/aquatix/digimarks">digimarks project page</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<h1>Welcome to digimarks, your online bookmarking and notes tool</h1>
|
||||
|
||||
<p>Please visit your personal url, or <a href="https://github.com/aquatix/digimarks">see the digimarks
|
||||
project page</a>.</p>
|
||||
|
||||
<p>If you forgot/lost your personal url, contact your digimarks
|
||||
administrator.{# On startup, the personal codes are printed to the standard output (so should be findable in a log). Of course, bookmarks.db contains the user information too.#}
|
||||
</p>
|
||||
</main>
|
||||
|
||||
</article>
|
||||
{% endblock %}
|
||||
62
src/digimarks/templates/list.html
Normal file
62
src/digimarks/templates/list.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<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>
|
||||
30
src/digimarks/templates/publicbookmarks.html
Normal file
30
src/digimarks/templates/publicbookmarks.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
{% if not action %}
|
||||
{% set action = 'Bookmarks' %}
|
||||
{% endif %}
|
||||
{% block title %}{{ action }}{% endblock %}
|
||||
{% block page_header %}{{ action }}{% endblock %}
|
||||
{% block page_content %}
|
||||
|
||||
{% if message %}
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<div class="card-panel orange lighten-2">
|
||||
<span class="white-text">
|
||||
{{ message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<a href="{{ url_for('publictag_feed', tagkey=tagkey) }}"><i class="material-icons tiny">rss_feed</i>
|
||||
feed</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'cards.html' %}
|
||||
|
||||
{% endblock %}
|
||||
16
src/digimarks/templates/redirect.html
Normal file
16
src/digimarks/templates/redirect.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Redirecting - digimarks</title>
|
||||
<meta name="referrer" content="never">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta http-equiv=refresh content="3; URL={{ url }}">
|
||||
<style>
|
||||
body { background-color: #000; color: #FFF; }
|
||||
a { color: #fb8c00; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>You're being redirected. If nothing happens, <a href="{{ url }}">click here instead</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
68
src/digimarks/templates/tags.html
Normal file
68
src/digimarks/templates/tags.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Tags{% endblock %}
|
||||
{% block page_header %}Tags{% endblock %}
|
||||
{% block page_content %}
|
||||
<div class="row">
|
||||
|
||||
<div class="col s12">
|
||||
<table class="centered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><i class="material-icons" title="Unique labels">label</i></th>
|
||||
<th><i class="material-icons green-text" title="Public tag pages">present_to_all</i></th>
|
||||
<th><i class="material-icons" title="Total bookmarks">turned_in</i></th>
|
||||
<th><i class="material-icons" title="Bookmarks with notes">comment</i></th>
|
||||
<th><i class="material-icons yellow-text" title="Starred bookmarks">star</i></th>
|
||||
<th><i class="material-icons orange-text" title="HTTP status is not 200 OK">warning</i></th>
|
||||
<th><i class="material-icons red-text" title="Deleted bookmarks">delete</i></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ totaltags }}</td>
|
||||
<td>{{ totalpublic }}</td>
|
||||
<td>{{ totalbookmarks }}</td>
|
||||
<td>{{ totalnotes }}</td>
|
||||
<td>{{ totalstarred }}</td>
|
||||
<td>{{ totalhttperrorstatus }}</td>
|
||||
<td>{{ totaldeleted }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<br/><br/>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
<th>Public link</th>
|
||||
<th>Number of bookmarks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tag in tags %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('tag_page', userkey=userkey, tag=tag['tag']) }}">{{ tag['tag'] }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if tag['publictag'] %}
|
||||
<a href="{{ url_for('publictag_page', tagkey=tag['publictag'].tagkey) }}">Public
|
||||
link</a> (
|
||||
<a href="{{ url_for('removepublictag', tag=tag['tag'], tagkey=tag['publictag'].tagkey, userkey=userkey) }}">Delete</a>
|
||||
<i class="tiny material-icons red-text">warning</i>)
|
||||
{% else %}
|
||||
<a href="{{ url_for('addpublictag', userkey=userkey, tag=tag['tag']) }}">Create</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ tag['total'] }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
180
src/digimarks/templates/user_index.html
Normal file
180
src/digimarks/templates/user_index.html
Normal file
@@ -0,0 +1,180 @@
|
||||
{% 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.show_tags && '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.show_bookmarks && !$store.digimarks.bookmark_to_edit"
|
||||
x-transition.opacity>
|
||||
<h1 x-bind:title="$store.digimarks.userKey">Bookmarks</h1>
|
||||
|
||||
<p>
|
||||
<button @click="$store.digimarks.sortAlphabetically()"
|
||||
:class="$store.digimarks.sort_title_asc && 'active'">a-z ↓
|
||||
</button>
|
||||
<button @click="$store.digimarks.sortAlphabetically('desc')"
|
||||
:class="$store.digimarks.sort_title_desc && 'active'">z-a ↑
|
||||
</button>
|
||||
<button @click="$store.digimarks.sortCreated()"
|
||||
:class="$store.digimarks.sort_created_asc && 'active'">date ↓
|
||||
</button>
|
||||
<button @click="$store.digimarks.sortCreated('desc')"
|
||||
:class="$store.digimarks.sort_created_desc && 'active'">date ↑
|
||||
</button>
|
||||
<button @click="$store.digimarks.toggleListOrGrid()"
|
||||
:class="$store.digimarks.show_bookmarks_cards && 'active'">list or grid
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<table x-cloak x-show="$store.digimarks.show_bookmarks_list">
|
||||
<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.show_bookmarks_cards" 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.show_tags && !$store.digimarks.bookmark_to_edit"
|
||||
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>
|
||||
|
||||
<section x-cloak x-show="$store.digimarks.bookmark_to_edit" x-transition.opacity>
|
||||
<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>
|
||||
<input type="text" name="">
|
||||
<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>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* digimarks styling
|
||||
*/
|
||||
|
||||
/** Navigation **/
|
||||
|
||||
nav .button-collapse
|
||||
{
|
||||
/* Fix for misalignment of hamburger icon */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
nav .button-collapse i
|
||||
{
|
||||
/* Make the hamburger icon great again */
|
||||
font-size: 2.7rem;
|
||||
}
|
||||
|
||||
/** Form input fields **/
|
||||
|
||||
/* label underline focus color */
|
||||
.input-field input[type=text]:focus
|
||||
{
|
||||
border-bottom: 1px solid #000;
|
||||
box-shadow: 0 1px 0 0 #000;
|
||||
}
|
||||
/* valid color */
|
||||
.input-field input[type=text].valid
|
||||
{
|
||||
border-bottom: 1px solid #000;
|
||||
box-shadow: 0 1px 0 0 #000;
|
||||
}
|
||||
/* invalid color */
|
||||
.input-field input[type=text].invalid
|
||||
{
|
||||
border-bottom: 1px solid #000;
|
||||
box-shadow: 0 1px 0 0 #000;
|
||||
}
|
||||
/* icon prefix focus color */
|
||||
.input-field .prefix.active
|
||||
{
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/** Cards and tags **/
|
||||
|
||||
/* Card title anchor colour */
|
||||
.white-text .card-title a,
|
||||
.white-text a
|
||||
{
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
.chip a,
|
||||
.white-text .chip a
|
||||
{
|
||||
color: #1b5e20; /* green darken-4 */
|
||||
}
|
||||
|
||||
.card.tiny
|
||||
{
|
||||
height: 140px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card.tiny .card-title
|
||||
{
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.card .card-reveal .digimark-card-header,
|
||||
.card .digimark-card-header.activator,
|
||||
.chip.clickable
|
||||
{
|
||||
cursor: pointer;
|
||||
/*display: block;*/
|
||||
}
|
||||
|
||||
.card .digimark-card-content
|
||||
{
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.card-image i
|
||||
{
|
||||
padding: 5px 0 0 15px;
|
||||
}
|
||||
|
||||
.card.horizontal .card-image img.favicon
|
||||
{
|
||||
height: 60px;
|
||||
width: 60px;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
(function($){
|
||||
$(function(){
|
||||
|
||||
$('.button-collapse').sideNav();
|
||||
|
||||
}); // end of document ready
|
||||
})(jQuery); // end of jQuery name space
|
||||
@@ -1,6 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}404: Page not found{% endblock %}
|
||||
{% block pageheader %}404: Page not found{% endblock %}
|
||||
{% block pagecontent %}
|
||||
The page you requested was not found.
|
||||
{% endblock %}
|
||||
@@ -1,75 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{% block title %}{% endblock %} - digimarks</title>
|
||||
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0"/>
|
||||
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}"/>
|
||||
|
||||
<!-- Chrome, Firefox OS and Opera -->
|
||||
<meta name="theme-color" content="#2e7d32" />
|
||||
<!-- Windows Phone -->
|
||||
<meta name="msapplication-navbutton-color" content="#2e7d32">
|
||||
<!-- iOS Safari -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
<link href='https://fonts.googleapis.com/css?family=Roboto+Mono&subset=latin,latin-ext' rel='stylesheet' type='text/css'>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/0.100.1/css/materialize.min.css" type="text/css" rel="stylesheet" media="screen,projection"/>
|
||||
<style>
|
||||
/* label color */
|
||||
.input-field label
|
||||
{
|
||||
color: {{ theme.TEXTHEX }};
|
||||
}
|
||||
/* label focus color */
|
||||
.input-field input[type=text]:focus + label
|
||||
{
|
||||
color: {{ theme.TEXTHEX }};
|
||||
}
|
||||
</style>
|
||||
<link href="{{ url_for('static', filename='css/digimarks.css') }}?20170722" type="text/css" rel="stylesheet" media="screen,projection"/>
|
||||
<script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
|
||||
</head>
|
||||
<body class="{{ theme.BODY }} {{ theme.TEXT }}">
|
||||
<nav class="{{ theme.NAV }}" role="navigation">
|
||||
<div class="nav-wrapper container"><a id="logo-container" href="{% if userkey %}{{ url_for('bookmarks', userkey=userkey) }}{% else %}{{ url_for('index') }}{% endif %}" class="brand-logo">digimarks</a>
|
||||
<ul class="right hide-on-med-and-down">
|
||||
{% if userkey %}
|
||||
<li><a href="{{ url_for('tags', userkey=userkey) }}">Tags</a></li>
|
||||
<li><a href="{{ url_for('addbookmark', userkey=userkey) }}">Add bookmark</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
{% if userkey %}
|
||||
<ul id="nav-mobile" class="side-nav">
|
||||
<li><a class="waves-effect" href="{{ url_for('bookmarks', userkey=userkey) }}"><i class="material-icons">turned_in</i>Home</a></li>
|
||||
<li><a class="waves-effect" href="{{ url_for('tags', userkey=userkey) }}"><i class="material-icons">label</i>Tags</a></li>
|
||||
<li><a class="waves-effect" href="{{ url_for('addbookmark', userkey=userkey) }}"><i class="material-icons">add</i>Add bookmark</a></li>
|
||||
</ul>
|
||||
<a href="#" data-activates="nav-mobile" class="button-collapse"><i class="material-icons">menu</i></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
<div class="section no-pad-bot" id="index-banner">
|
||||
<div class="container">
|
||||
<div class="header {{ theme.PAGEHEADER }}">
|
||||
<h1>{% block pageheader %}Bookmarks{% endblock %}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="section">
|
||||
{% block pagecontent %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/0.100.1/js/materialize.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/init.js') }}"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,148 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% if not action %}
|
||||
{% set action = 'Bookmarks' %}
|
||||
{% endif %}
|
||||
{% block title %}{{ action }}{% endblock %}
|
||||
{% block pageheader %}{{ action }}{% endblock %}
|
||||
{% block pagecontent %}
|
||||
|
||||
{% 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', 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', userkey=userkey) }}" method="POST">
|
||||
<div class="input-field col l10 m10 s8">
|
||||
<input placeholder="search text" type="text" name="filter_text" id="filter_text" value="{{ filter_text }}" class="validate" />
|
||||
</div>
|
||||
|
||||
<div class="input-field col l2 m2 s4">
|
||||
<p class="left-align"><button class="btn waves-effect waves-light" type="submit" name="submit">Filter</button></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', userkey=userkey, filtermethod='starred') }}"><i class="tiny material-icons {{ theme.STAR }}">star</i></a>
|
||||
</div>
|
||||
<div class="chip">
|
||||
<a href="{{ url_for('bookmarks', userkey=userkey, filtermethod='broken') }}"><i class="tiny material-icons {{ theme.PROBLEM }}">report_problem</i></a>
|
||||
</div>
|
||||
<div class="chip">
|
||||
<a href="{{ url_for('bookmarks', 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', userkey=userkey, tag=tag) }}">{{ tag }}</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
{% for bookmark in bookmarks %}
|
||||
<div class="col s12 m6 l4">
|
||||
{#
|
||||
<div class="thumbnail">
|
||||
<a href="{{ bookmark.url }}" title="{{ bookmark.url }}">
|
||||
<img style="width:450px;" src="{{ bookmark.image }}" />
|
||||
</a>
|
||||
<p><a href="{{ bookmark.url }}">{{ bookmark.url|urlize(25) }}</a></p>
|
||||
<p>{{ bookmark.created_date.strftime("%m/%d/%Y %H:%M") }}</p>
|
||||
</div>
|
||||
#}
|
||||
<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>
|
||||
{% 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">
|
||||
<span class="digimark-card-header-tags">
|
||||
{% for tag in bookmark.tags_list %}
|
||||
<div class="chip">
|
||||
<a href="{{ url_for('tag', userkey=userkey, tag=tag) }}">{{ tag }}</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</span>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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 %}
|
||||
@@ -1,17 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}digimarks{% endblock %}
|
||||
{% block pageheader %}digimarks{% endblock %}
|
||||
{% block pagecontent %}
|
||||
<p>Please visit your personal url, or <a href="https://github.com/aquatix/digimarks">see the digimarks project page</a>.</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col s12">
|
||||
<div class="card-panel orange lighten-2">
|
||||
<span class="black-text">
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,68 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% if not action %}
|
||||
{% set action = 'Bookmarks' %}
|
||||
{% endif %}
|
||||
{% block title %}{{ action }}{% endblock %}
|
||||
{% block pageheader %}{{ action }}{% endblock %}
|
||||
{% block pagecontent %}
|
||||
|
||||
{% 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('publictagfeed', tagkey=tagkey) }}"><i class="material-icons tiny">rss_feed</i> feed</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
{% for bookmark in bookmarks %}
|
||||
<div class="col s12 m6 l4">
|
||||
<div class="card horizontal tiny green darken-3">
|
||||
<div class="card-image">
|
||||
{% if bookmark.favicon %}
|
||||
<div><img src="{{ url_for('static', filename='favicons/' + bookmark.favicon) }}" class="favicon" /></div>
|
||||
{% endif %}
|
||||
{% if bookmark.http_status != 200 %}
|
||||
<i class="small material-icons red-text" title="HTTP status {{ bookmark.http_status }}">report_problem</i><br />
|
||||
{% endif %}
|
||||
{% if bookmark.starred == True %}
|
||||
<i class="small material-icons yellow-text">star</i>
|
||||
{% endif %}
|
||||
{% if bookmark.note %}
|
||||
<div><i class="small material-icons white-text" title="{{ bookmark.note|truncate(100) }}">comment</i></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-stacked">
|
||||
<div class="card-content white-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 green darken-3">
|
||||
<span class="card-title white-text">Added @ {{ bookmark.created_date.strftime('%Y-%m-%d %H:%M') }}<i class="material-icons right">close</i></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,65 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Tags{% endblock %}
|
||||
{% block pageheader %}Tags{% endblock %}
|
||||
{% block pagecontent %}
|
||||
<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', userkey=userkey, tag=tag['tag']) }}">{{ tag['tag'] }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if tag['publictag'] %}
|
||||
<a href="{{ url_for('publictag', 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 %}
|
||||
21
tox.ini
Normal file
21
tox.ini
Normal file
@@ -0,0 +1,21 @@
|
||||
[flake8]
|
||||
ignore = D203, W503
|
||||
exclude =
|
||||
.git,
|
||||
__pycache__,
|
||||
docs/source/conf.py,
|
||||
build,
|
||||
dist,
|
||||
example_config/gunicorn_webhaak_conf.py,
|
||||
example_config/rq_settings.example.py,
|
||||
example_config/settings.py,
|
||||
max-line-length = 120
|
||||
max-complexity = 10
|
||||
|
||||
[pycodestyle]
|
||||
max_line_length = 120
|
||||
ignore = E501, W503
|
||||
|
||||
[isort]
|
||||
line_length = 120
|
||||
multi_line_output = 3
|
||||
11
wsgi.py
11
wsgi.py
@@ -1,11 +0,0 @@
|
||||
# Activate virtualenv
|
||||
import settings
|
||||
activate_this = getattr(settings, 'VENV', None)
|
||||
if activate_this:
|
||||
execfile(activate_this, 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