1
0
mirror of https://github.com/aquatix/digimarks.git synced 2025-12-07 00:15:10 +01:00

93 Commits

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

4
.codacy.yaml Normal file
View File

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

3
.gitignore vendored
View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

22
pylintrc Normal file
View File

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

74
pyproject.toml Normal file
View File

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

View File

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

View File

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

3
requirements-server.in Normal file
View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

Before

Width:  |  Height:  |  Size: 254 B

After

Width:  |  Height:  |  Size: 254 B

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,67 +0,0 @@
/**
* digimarks styling
*/
/** Navigation **/
nav .sidenav-trigger
{
/* Fix for misalignment of hamburger icon */
margin: 0;
}
nav .sidenav-trigger i
{
/* Make the hamburger icon great again */
font-size: 2.7rem;
}
/** Cards and tags **/
.card .card-content,
.card .card-reveal
{
padding: 12px;
}
.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-header-tags
{
padding-top: 10px;
}
.card-image
{
min-width: 60px;
}
.card-image i,
.list-image i
{
padding: 5px 0 0 15px;
}
.card.horizontal .card-image img.favicon,
.list-image img.favicon
{
height: 60px;
width: 60px;
}

View File

@@ -1,11 +0,0 @@
/* global M */
var options = {};
var elem = document.querySelector(".sidenav");
var instance = M.Sidenav.init(elem, options);
elem = document.querySelector(".collapsible");
instance = M.Collapsible.init(elem, {
// inDuration: 1000,
// outDuration: 1000
});

View File

@@ -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 %}

View File

@@ -1,126 +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="{{ theme.BROWSERCHROME }}" />
<!-- Windows Phone -->
<meta name="msapplication-navbutton-color" content="{{ theme.BROWSERCHROME }}">
<!-- iOS Safari -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="referrer" content="never">
<meta name="referrer" content="no-referrer">
<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/1.0.0/css/materialize.min.css" type="text/css" rel="stylesheet" media="screen,projection"/>
<style>
a
{
color: {{ theme.LINK_TEXT }};
}
.card-content a
{
color: {{ theme.CARD_LINK }};
}
.chip a
{
color: {{ theme.CHIP_TEXT }};
}
/* label color */
.input-field .prefix ~ input, .input-field .prefix ~ textarea, .input-field .prefix ~ label, .input-field .prefix ~ .validate ~ label, .input-field .prefix ~ .autocomplete-content, .input-field input[type=text]
{
color: {{ theme.TEXTHEX }};
}
/* label focus color */
.input-field input[type=text]:focus + label,
.input-field .prefix ~ input[type=text]:focus + label
{
color: {{ theme.BUTTON }};
}
/* label underline focus color */
.input-field input[type=text]:focus,
.input-field .prefix ~ input[type=text]:focus,
.input-field input[type=text].autocomplete:focus
{
border-bottom: 1px solid {{ theme.BUTTON }};
box-shadow: 0 1px 0 0 {{ theme.BUTTON }};
}
/* icon prefix focus color */
.input-field .prefix.active
{
color: {{ theme.BUTTON }};
}
.btn, .btn:visited
{
background-color: {{ theme.BUTTON }};
}
.btn:hover, .btn:active
{
background-color: {{ theme.BUTTON_ACTIVE }};
}
.deletebtn
{
background-color: red;
}
.deletebtn:hover
{
background-color: #ef5350; /* red lighten-1 */
}
</style>
<link href="{{ url_for('static', filename='css/digimarks.css') }}?20180330" type="text/css" rel="stylesheet" media="screen,projection"/>
{% if not sortmethod %}
{% set sortmethod = None %}
{% endif %}
{% if not show_as %}
{% set show_as = None %}
{% endif %}
</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_page', userkey=userkey, sortmethod=sortmethod, show_as=show_as) }}{% 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_page', userkey=userkey) }}" class="waves-effect waves-light btn"><i class="material-icons left">label</i>Tags</a></li>
<li><a href="{{ url_for('addbookmark', userkey=userkey) }}" class="waves-effect waves-light btn"><i class="material-icons left">add</i>Add bookmark</a></li>
{% endif %}
</ul>
{% if userkey %}
<ul id="nav-mobile" class="sidenav">
<li><a class="waves-effect" href="{{ url_for('bookmarks_page', userkey=userkey) }}"><i class="material-icons left">turned_in</i>Home</a></li>
<li><a class="waves-effect" href="{{ url_for('tags_page', userkey=userkey) }}"><i class="material-icons left">label</i>Tags</a></li>
<li><a class="waves-effect" href="{{ url_for('addbookmark', userkey=userkey) }}"><i class="material-icons left">add</i>Add bookmark</a></li>
</ul>
<a href="#" data-target="nav-mobile" class="sidenav-trigger"><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/1.0.0/js/materialize.min.js"></script>
<script src="{{ url_for('static', filename='js/init.js') }}?20180309"></script>
{% block extrajs %}{% endblock %}
</body>
</html>

View File

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

View File

@@ -1,11 +0,0 @@
var elem = document.querySelector('.autocomplete');
var instance = M.Autocomplete.getInstance(elem);
instance.updateData({
{% for bookmark in bookmarks %}
{% if bookmark.favicon %}
"{{ bookmark.title | replace('"', '\\"') | replace('\n', '') | replace('\r', '') }}": "{{ url_for('static', filename='favicons/' + bookmark.favicon) }}",
{% else %}
"{{ bookmark.title | replace('"', '\\"') | replace('\n', '') | replace('\r', '') }}": null,
{% endif %}
{% endfor %}
});

View File

@@ -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 %}

View File

@@ -1,29 +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('publictag_feed', tagkey=tagkey) }}"><i class="material-icons tiny">rss_feed</i> feed</a>
</div>
</div>
{% include 'cards.html' %}
{% endblock %}

View File

@@ -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_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 %}

21
tox.ini Normal file
View File

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

16
wsgi.py
View File

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