mirror of
https://codeberg.org/diginaut/digimarks.git
synced 2026-03-22 23:10:49 +01:00
Compare commits
41 Commits
651a7e4ece
...
fastapi
| Author | SHA1 | Date | |
|---|---|---|---|
| 99d2011e65 | |||
| bade114b40 | |||
| 8558b518f8 | |||
| 9524fec672 | |||
| 157303aba2 | |||
| a961d90bda | |||
| 7fc7fdb171 | |||
| 1d4bc73ece | |||
| 71d5c6533d | |||
| dae6c5da18 | |||
| f68daf4ac0 | |||
| be34c6e88f | |||
| 47a0f31ec3 | |||
| 05fa94ef41 | |||
| b4aff120c8 | |||
| 82e4202482 | |||
| 9b03d51276 | |||
| fe734d6dd8 | |||
| 2936a4815a | |||
| 09c685f2aa | |||
| 0b08f0fa81 | |||
| 77dd621280 | |||
| a9f8236ee6 | |||
| ac9e010808 | |||
| 21f5f34e4f | |||
| 971ede6067 | |||
| 96a8946a9a | |||
| 14f09a2dfb | |||
| 9d813b7ea6 | |||
| 79be98abea | |||
| a7498a2fba | |||
| 8810a47faa | |||
| cae9ebf3ef | |||
| 5eb9c606f0 | |||
| 894f97a25e | |||
| 8ccb18839f | |||
| dda209fa96 | |||
| dcac963fa6 | |||
| da28f2f781 | |||
| 987a030c4f | |||
| bf6cd081f9 |
1
.envrc.example
Normal file
1
.envrc.example
Normal file
@@ -0,0 +1 @@
|
||||
layout uv
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -77,10 +77,15 @@ celerybeat-schedule
|
||||
|
||||
# dotenv
|
||||
.env
|
||||
*.env
|
||||
|
||||
# direnv
|
||||
.envrc
|
||||
|
||||
# virtualenv
|
||||
venv/
|
||||
ENV/
|
||||
.venv
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
@@ -93,6 +98,10 @@ ENV/
|
||||
|
||||
# vim
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Zed editor
|
||||
.zed
|
||||
|
||||
# digimarks
|
||||
static/favicons
|
||||
|
||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -7,13 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## TODO
|
||||
|
||||
- Sorting of bookmarks
|
||||
- Sort by title
|
||||
- Sort by date
|
||||
- Logging of actions
|
||||
- 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
|
||||
- Change adding tags to use ~~the MaterializeCSS tags: https://materializecss.com/chips.html~~ a nice tags lib/styling
|
||||
- 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
|
||||
@@ -23,7 +19,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Settings through Pydantic Settings
|
||||
- Settings now work through Pydantic Settings
|
||||
- New UI theme(s) through digui
|
||||
- Caching of the bookmarks, tags and more in the browser, for fast filtering and lookups
|
||||
- Sorting of bookmarks
|
||||
- Sort by title
|
||||
- Sort by date
|
||||
- Logging of actions
|
||||
- Recognise when a url already is in the list of known bookmarks and fill in the form with already-known data
|
||||
|
||||
### Changed
|
||||
- Moved from Flask to FastAPI
|
||||
|
||||
130
README.md
Normal file
130
README.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# 🔖 digimarks
|
||||
|
||||
[](https://pypi.python.org/pypi/digimarks/)
|
||||
[](https://pypi.python.org/pypi/digimarks/)
|
||||
[](https://app.codacy.com/app/aquatix/digimarks?utm_source=github.com&utm_medium=referral&utm_content=aquatix/digimarks&utm_campaign=badger)
|
||||
|
||||
|
||||
## 📚 Overview
|
||||
|
||||
Simple bookmarking service, using a SQLite database to store bookmarks, supporting tags and automatic title fetching. Notes can be added, the items are cached locally in the browser, and the API is documented so everything can be accessed through that too.
|
||||
|
||||
[digimarks source](https://codeberg.org/diginaut/digimarks)
|
||||
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
There are a few ways to install digimarks to your computer or server.
|
||||
|
||||
### From PyPI
|
||||
|
||||
Assuming you already are inside a virtualenv:
|
||||
|
||||
```bash
|
||||
# Using the wonderfully fast uv
|
||||
uv pip install digimarks
|
||||
|
||||
# Alternatively, use Python pip
|
||||
pip install digimarks
|
||||
```
|
||||
|
||||
|
||||
### From Git
|
||||
|
||||
Create a new virtualenv (if you are not already in one) and install the
|
||||
necessary packages:
|
||||
|
||||
```bash
|
||||
git clone https://codeberg.org/diginaut/digimarks.git
|
||||
cd digimarks
|
||||
# direnv will now create or activate a virtualenv
|
||||
# See https://codeberg.org/diginaut/dotfiles/src/branch/master/.config/direnv/direnvrc for direnv uv config
|
||||
# If you just want to run it, no need for development dependencies
|
||||
uv sync --active --no-dev
|
||||
# Otherwise, install everything in the active virtualenv
|
||||
uv sync --active
|
||||
```
|
||||
|
||||
|
||||
## ⚙️ Migrating from version 1
|
||||
|
||||
To be able to use the new database schema's, you will need to migrate your existing `bookmarks.db` to one under the control of the `alembic` migrations tool.
|
||||
|
||||
To do so, start with making a backup of this `bookmarks.db` file to a safe place.
|
||||
|
||||
Then, stamp the initial migration into the database, and migrate to the latest version:
|
||||
|
||||
```bash
|
||||
# Initiate migrations with the first one (only needs to be done once!)
|
||||
alembic stamp 115bcd2e1a38
|
||||
|
||||
# Apply all migrations to get up-to-date
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
|
||||
## 🛠️ Usage / example configuration
|
||||
|
||||
⚠️ OUT OF DATE! ⚠️
|
||||
|
||||
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
|
||||
|
||||
To easily save a link from your browser, open its bookmark manager and create a new bookmark with as url:
|
||||
|
||||
```javascript
|
||||
javascript:location.href='http://marks.example.com/1234567890abcdef/add?url='+encodeURIComponent(location.href);
|
||||
```
|
||||
|
||||
|
||||
## Creating a new user
|
||||
|
||||
After having set up the `settings.py` as under Usage, you can add a new user, by going to this path on your digimarks server:
|
||||
|
||||
```
|
||||
/<secretkey>/adduser
|
||||
```
|
||||
|
||||
where `secretkey` is the value set in settings.SYSTEMKEY
|
||||
|
||||
digimarks will then redirect to the bookmarks overview page of the new user. Please remember the user key (the hash in the url), as it will not be visible otherwise in the interface.
|
||||
|
||||
If you for whatever reason would lose this user key, just either look on the console (or webserver logs) where the list of available user keys is printed on digimarks startup, or open bookmarks.db with a SQLite editor.
|
||||
|
||||
|
||||
## 🔧 Server configuration
|
||||
|
||||
- [systemd for digimarks API](https://codeberg.org/diginaut/digimarks/src/branch/fastapi/example_config/systemd/digimarks.service) which uses the [gunicorn config](https://codeberg.org/diginaut/digimarks/src/branch/fastapi/example_config/gunicorn_digimarks_conf.py)
|
||||
- [nginx for digimarks API](https://codeberg.org/diginaut/digimarks/src/branch/fastapi/example_config/nginx_digimarks.conf)
|
||||
- [more config](https://codeberg.org/diginaut/digimarks/src/branch/fastapi/example_config)
|
||||
|
||||
|
||||
## ✨ What's new?
|
||||
|
||||
See the [Changelog](https://codeberg.org/diginaut/digimarks/src/branch/fastapi/CHANGELOG.md)
|
||||
|
||||
|
||||
## 🙏 Attributions
|
||||
|
||||
'M' favicon by [Freepik](http://www.flaticon.com/free-icon/letter-m_2041)
|
||||
72
README.rst
72
README.rst
@@ -1,13 +1,17 @@
|
||||
digimarks
|
||||
=========
|
||||
🔖 digimarks
|
||||
===========
|
||||
|
||||
|PyPI version| |PyPI license| |Code health| |Codacy|
|
||||
|
||||
Simple bookmarking service, using a SQLite database to store bookmarks, supporting tags and automatic title fetching.
|
||||
|
||||
📚 Overview
|
||||
----------
|
||||
|
||||
Simple bookmarking service, using a SQLite database to store bookmarks, supporting tags and automatic title fetching. Notes can be added, the items are cached locally in the browser, and the API is documented so everything can be accessed through that too.
|
||||
|
||||
|
||||
Installation
|
||||
------------
|
||||
📥 Installation
|
||||
--------------
|
||||
|
||||
From PyPI
|
||||
~~~~~~~~~
|
||||
@@ -16,6 +20,10 @@ Assuming you already are inside a virtualenv:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# Using the wonderfully fast uv
|
||||
uv pip install digimarks
|
||||
|
||||
# Alternatively, use Python pip
|
||||
pip install digimarks
|
||||
|
||||
|
||||
@@ -27,17 +35,18 @@ necessary packages:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
git clone https://github.com/aquatix/digimarks.git
|
||||
git clone https://codeberg.org/diginaut/digimarks.git
|
||||
cd digimarks
|
||||
mkvirtualenv digimarks # or whatever project you are working on
|
||||
# direnv will now create or activate a virtualenv
|
||||
# See https://codeberg.org/diginaut/dotfiles/src/branch/master/.config/direnv/direnvrc for direnv uv config
|
||||
# If you just want to run it, no need for development dependencies
|
||||
uv sync --active --no-dev
|
||||
# Otherwise, install everything
|
||||
# Otherwise, install everything in the active virtualenv
|
||||
uv sync --active
|
||||
|
||||
|
||||
Migrating from version 1
|
||||
------------------------
|
||||
⚙️ Migrating from version 1
|
||||
--------------------------
|
||||
|
||||
To be able to use the new database schema's, you will need to migrate your existing ``bookmarks.db`` to one under the control of the ``alembic`` migrations tool.
|
||||
|
||||
@@ -47,23 +56,24 @@ Then, stamp the initial migration into the database, and migrate to the latest v
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# Initiate migrations with the first one (only needs to be done once!)
|
||||
alembic stamp 115bcd2e1a38
|
||||
|
||||
# Apply all migrations to get up-to-date
|
||||
alembic upgrade head
|
||||
|
||||
|
||||
Usage / example configuration
|
||||
-----------------------------
|
||||
🛠️ Usage / example configuration
|
||||
-------------------------------
|
||||
|
||||
OUT OF DATE!
|
||||
⚠️ OUT OF DATE! ⚠️
|
||||
|
||||
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>`_.
|
||||
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.
|
||||
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>``
|
||||
|
||||
@@ -93,7 +103,7 @@ To easily save a link from your browser, open its bookmark manager and create a
|
||||
Creating a new user
|
||||
-------------------
|
||||
|
||||
After having set up the ```settings.py``` as under Usage, you can add a new user, by going to this path on your digimarks server:
|
||||
After having set up the ``settings.py`` as under Usage, you can add a new user, by going to this path on your digimarks server:
|
||||
|
||||
/<secretkey>/adduser
|
||||
|
||||
@@ -104,27 +114,27 @@ digimarks will then redirect to the bookmarks overview page of the new user. Ple
|
||||
If you for whatever reason would lose this user key, just either look on the console (or webserver logs) where the list of available user keys is printed on digimarks startup, or open bookmarks.db with a SQLite editor.
|
||||
|
||||
|
||||
Server configuration
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
🔧 Server configuration
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
* `systemd for digimarks API`_ which uses the `gunicorn config`_
|
||||
* `nginx for digimarks API`_
|
||||
* `more config`_
|
||||
|
||||
|
||||
What's new?
|
||||
-----------
|
||||
✨ What's new?
|
||||
-------------
|
||||
|
||||
See the `Changelog`_.
|
||||
|
||||
|
||||
Attributions
|
||||
------------
|
||||
🙏 Attributions
|
||||
--------------
|
||||
|
||||
'M' favicon by `Freepik`_.
|
||||
|
||||
|
||||
.. _digimarks: https://github.com/aquatix/digimarks
|
||||
.. _digimarks: https://codeberg.org/diginaut/digimarks
|
||||
.. |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
|
||||
@@ -135,11 +145,11 @@ Attributions
|
||||
.. |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
|
||||
.. _hook settings: https://codeberg.org/diginaut/digimarks/blob/master/example_config/examples.yaml
|
||||
.. _vhost for Apache2.4: https://codeberg.org/diginaut/digimarks/blob/master/example_config/apache_vhost.conf
|
||||
.. _uwsgi.ini: https://codeberg.org/diginaut/digimarks/blob/master/example_config/uwsgi.ini
|
||||
.. _Changelog: https://codeberg.org/diginaut/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
|
||||
.. _systemd for digimarks API: https://codeberg.org/diginaut/digimarks/blob/master/example_config/systemd/digimarks.service
|
||||
.. _gunicorn config: https://codeberg.org/diginaut/digimarks/src/branch/master/example_config/uwsgi.ini
|
||||
.. _more config: https://codeberg.org/diginaut/digimarks/src/branch/master/example_config
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Digimarks project."""
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<VirtualHost *:80>
|
||||
ServerAdmin webmaster@example.com
|
||||
ServerName marks.example.com
|
||||
|
||||
WSGIDaemonProcess digimarks user=youruser group=youruser threads=5 python-path=/srv/marks.example.com/digimarks/
|
||||
WSGIScriptAlias / /srv/marks.example.com/digimarks/wsgi.py
|
||||
|
||||
<Directory /srv/marks.example.com/digimarks>
|
||||
WSGIProcessGroup digimarks
|
||||
WSGIApplicationGroup %{GLOBAL}
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
<Directory /srv/marks.example.com/digimarks>
|
||||
<Files wsgi.py>
|
||||
Require all granted
|
||||
</Files>
|
||||
</Directory>
|
||||
|
||||
ErrorLog /var/log/apache2/error_marks.example.com.log
|
||||
|
||||
# Possible values include: debug, info, notice, warn, error, crit,
|
||||
# alert, emerg.
|
||||
LogLevel warn
|
||||
|
||||
CustomLog /var/log/apache2/access_marks.example.com.log combined
|
||||
ServerSignature On
|
||||
|
||||
</VirtualHost>
|
||||
14
example_config/gunicorn_digimarks_conf.py
Normal file
14
example_config/gunicorn_digimarks_conf.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# gunicorn_conf.py
|
||||
from multiprocessing import cpu_count
|
||||
|
||||
bind = "127.0.0.1:8890"
|
||||
|
||||
# Worker Options
|
||||
#workers = cpu_count() + 1
|
||||
workers = 1
|
||||
worker_class = 'uvicorn.workers.UvicornWorker'
|
||||
|
||||
# Logging Options
|
||||
loglevel = 'debug'
|
||||
accesslog = '/var/log/digimarks/access_log'
|
||||
errorlog = '/var/log/digimarks/error_log'
|
||||
73
example_config/nginx_digimarks.conf
Normal file
73
example_config/nginx_digimarks.conf
Normal file
@@ -0,0 +1,73 @@
|
||||
server {
|
||||
server_name marks.example.org;
|
||||
listen [::]:443 ssl; # managed by Certbot
|
||||
listen 443 ssl; # managed by Certbot
|
||||
|
||||
real_ip_header X-Forwarded-For;
|
||||
|
||||
access_log /var/log/nginx/access_marks.example.org.log;
|
||||
error_log /var/log/nginx/error_marks.example.org.log warn;
|
||||
|
||||
# Media: images, icons, video, audio, HTC
|
||||
#location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|mp3|ogg|ogv|webm|htc|woff2|woff)$ {
|
||||
# expires 1M;
|
||||
# access_log off;
|
||||
# # max-age must be in seconds
|
||||
# add_header Cache-Control "max-age=2629746, public";
|
||||
#}
|
||||
|
||||
# CSS and Javascript
|
||||
#location ~* \.(?:css|js)$ {
|
||||
# expires 1M;
|
||||
# access_log off;
|
||||
# add_header Cache-Control "max-age=31556952, public";
|
||||
#}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8890;
|
||||
proxy_read_timeout 60;
|
||||
proxy_connect_timeout 60;
|
||||
proxy_redirect off;
|
||||
|
||||
# Allow the use of websockets
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
location /content/favicons/ {
|
||||
alias /srv/www/marks.example.org/favicons/;
|
||||
# This can certainly be cached, so do so for a month
|
||||
expires 1M;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /srv/www/marks.example.org/digimarks/src/digimarks/static/;
|
||||
# This can certainly be cached, so do so for a month
|
||||
#expires 1M;
|
||||
#add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location /favicon.ico {
|
||||
# Favicon for the webapp, shown in the browser
|
||||
alias /srv/www/marks.example.org/digimarks/src/digimarks/static/favicon.ico;
|
||||
}
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/marks.example.org/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/marks.example.org/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
}
|
||||
server {
|
||||
if ($host = marks.example.org) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
listen [::]:80 ;
|
||||
listen 80;
|
||||
server_name marks.example.org;
|
||||
return 404; # managed by Certbot
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
# Virtualenv to use with the wsgi file (optional)
|
||||
VENV = '/srv/marks.example.com/venv/bin/activate_this.py'
|
||||
|
||||
PORT = 8086
|
||||
|
||||
DEBUG = False
|
||||
|
||||
# Password/url key to do admin stuff with, like adding a user
|
||||
# NB: change this to something else! For example, in bash:
|
||||
# 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:
|
||||
LOG_BACKUP_COUNT = 10
|
||||
22
example_config/systemd/digimarks.service
Normal file
22
example_config/systemd/digimarks.service
Normal file
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Gunicorn Daemon for digimarks FastAPI
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=YOUR_USER
|
||||
Group=YOUR_USER
|
||||
WorkingDirectory=/srv/www/marks.example.org/digimarks/src
|
||||
|
||||
Environment="SYSTEM_KEY=RanDomSeCretKeyFoRAdmin"
|
||||
Environment="FAVICONS_DIR=/srv/www/marks.example.org/favicons"
|
||||
Environment="DATABASE_FILE=/srv/www/marks.example.org/bookmarks.db"
|
||||
Environment="STATIC_DIR=digimarks/static"
|
||||
Environment="TEMPLATE_DIR=digimarks/templates"
|
||||
|
||||
ExecStart=/srv/www/marks.example.org/venv/bin/gunicorn -c /srv/www/marks.example.org/gunicorn_digimarks_conf.py digimarks.main:app
|
||||
|
||||
StandardOutput=file:///var/log/digimarks/stdout.log
|
||||
StandardError=file:///var/log/digimarks/stderr.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,15 +0,0 @@
|
||||
# Example supervisord configuration
|
||||
# Run with /srv/venv/bin/uwsgi --ini /srv/digimarks/uwsgi.ini:digimarks
|
||||
|
||||
[digimarks]
|
||||
chdir = /srv/digimarks
|
||||
socket = /tmp/uwsgi_digimarks.sock
|
||||
module = wsgi
|
||||
threads = 4
|
||||
master = true
|
||||
processes = 5
|
||||
vacuum = true
|
||||
no-orphans = true
|
||||
chmod-socket = 666
|
||||
logger = main file:/var/log/webapps/digimarks.log
|
||||
logger = file:/var/log/webapps/digimarks_debug.log
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Alembic environment file for SQLAlchemy."""
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
@@ -7,7 +9,7 @@ from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from src.digimarks.models import Bookmark, PublicTag, User
|
||||
from src.digimarks.models import Bookmark, PublicTag, User # noqa
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
@@ -56,6 +58,7 @@ def run_migrations_offline() -> None:
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
"""Run the migrations."""
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""Initial migration
|
||||
"""Initial migration.
|
||||
|
||||
Revision ID: 115bcd2e1a38
|
||||
Revises:
|
||||
Create Date: 2025-09-12 16:06:16.479075
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '115bcd2e1a38'
|
||||
@@ -21,38 +20,41 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('bookmark',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('userkey', sa.String(length=255), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=False),
|
||||
sa.Column('url', sa.String(length=255), nullable=False),
|
||||
sa.Column('created_date', sa.DateTime(), nullable=False),
|
||||
sa.Column('url_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('tags', sa.String(length=255), nullable=False),
|
||||
sa.Column('http_status', sa.Integer(), nullable=False),
|
||||
sa.Column('modified_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('favicon', sa.String(length=255), nullable=True),
|
||||
sa.Column('starred', sa.Boolean(), server_default=sa.text('0'), nullable=True),
|
||||
sa.Column('deleted_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True),
|
||||
sa.Column('status', sa.Integer(), server_default=sa.text('0'), nullable=True),
|
||||
sa.Column('note', sa.Text(), server_default=sa.text('(null)'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_table(
|
||||
'bookmark',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('userkey', sa.String(length=255), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=False),
|
||||
sa.Column('url', sa.String(length=255), nullable=False),
|
||||
sa.Column('created_date', sa.DateTime(), nullable=False),
|
||||
sa.Column('url_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('tags', sa.String(length=255), nullable=False),
|
||||
sa.Column('http_status', sa.Integer(), nullable=False),
|
||||
sa.Column('modified_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('favicon', sa.String(length=255), nullable=True),
|
||||
sa.Column('starred', sa.Boolean(), server_default=sa.text('0'), nullable=True),
|
||||
sa.Column('deleted_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True),
|
||||
sa.Column('status', sa.Integer(), server_default=sa.text('0'), nullable=True),
|
||||
sa.Column('note', sa.Text(), server_default=sa.text('(null)'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_table('publictag',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tagkey', sa.String(length=255), nullable=False),
|
||||
sa.Column('userkey', sa.String(length=255), nullable=False),
|
||||
sa.Column('tag', sa.String(length=255), nullable=False),
|
||||
sa.Column('created_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_table(
|
||||
'publictag',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tagkey', sa.String(length=255), nullable=False),
|
||||
sa.Column('userkey', sa.String(length=255), nullable=False),
|
||||
sa.Column('tag', sa.String(length=255), nullable=False),
|
||||
sa.Column('created_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_table('user',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('username', sa.String(length=255), nullable=False),
|
||||
sa.Column('key', sa.String(length=255), nullable=False),
|
||||
sa.Column('created_date', sa.DateTime(), nullable=False),
|
||||
sa.Column('theme', sa.String(length=20), server_default=sa.text("'green'"), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_table(
|
||||
'user',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('username', sa.String(length=255), nullable=False),
|
||||
sa.Column('key', sa.String(length=255), nullable=False),
|
||||
sa.Column('created_date', sa.DateTime(), nullable=False),
|
||||
sa.Column('theme', sa.String(length=20), server_default=sa.text("'green'"), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
Revision ID: a8d8e45f60a1
|
||||
Revises: 115bcd2e1a38
|
||||
Create Date: 2025-09-12 16:10:41.378716
|
||||
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from datetime import UTC, datetime
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a8d8e45f60a1'
|
||||
@@ -24,72 +23,74 @@ def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('bookmark', schema=None) as batch_op:
|
||||
batch_op.alter_column('note',
|
||||
existing_type=sa.TEXT(),
|
||||
type_=sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('(null)'))
|
||||
batch_op.alter_column('starred',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('0'))
|
||||
batch_op.alter_column('modified_date',
|
||||
existing_type=sa.DATETIME(),
|
||||
nullable=True)
|
||||
batch_op.alter_column('deleted_date',
|
||||
existing_type=sa.DATETIME(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('(null)'))
|
||||
batch_op.alter_column('status',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('0'))
|
||||
batch_op.alter_column(
|
||||
'note',
|
||||
existing_type=sa.TEXT(),
|
||||
type_=sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('(null)'),
|
||||
)
|
||||
batch_op.alter_column(
|
||||
'starred', existing_type=sa.BOOLEAN(), nullable=False, existing_server_default=sa.text('0')
|
||||
)
|
||||
batch_op.alter_column('modified_date', existing_type=sa.DATETIME(), nullable=True)
|
||||
batch_op.alter_column(
|
||||
'deleted_date', existing_type=sa.DATETIME(), nullable=True, existing_server_default=sa.text('(null)')
|
||||
)
|
||||
batch_op.alter_column(
|
||||
'status', existing_type=sa.INTEGER(), nullable=False, existing_server_default=sa.text('0')
|
||||
)
|
||||
batch_op.create_foreign_key('bookmark_user', 'user', ['userkey'], ['key'])
|
||||
with op.batch_alter_table('publictag', schema=None) as batch_op:
|
||||
batch_op.alter_column('created_date',
|
||||
existing_type=sa.DATETIME(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text(str(datetime.now(UTC))))
|
||||
batch_op.alter_column(
|
||||
'created_date',
|
||||
existing_type=sa.DATETIME(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text(str(datetime.now(UTC))),
|
||||
)
|
||||
batch_op.create_foreign_key('publictag_user', 'user', ['userkey'], ['key'])
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.alter_column('theme',
|
||||
existing_type=sa.VARCHAR(length=20),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text("'green'"))
|
||||
batch_op.alter_column(
|
||||
'theme', existing_type=sa.VARCHAR(length=20), nullable=False, existing_server_default=sa.text("'green'")
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('user', 'theme',
|
||||
existing_type=sa.VARCHAR(length=20),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text("'green'"))
|
||||
op.alter_column(
|
||||
'user', 'theme', existing_type=sa.VARCHAR(length=20), nullable=True, existing_server_default=sa.text("'green'")
|
||||
)
|
||||
op.drop_constraint(None, 'publictag', type_='foreignkey')
|
||||
op.alter_column('publictag', 'created_date',
|
||||
existing_type=sa.DATETIME(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('(null)'))
|
||||
op.alter_column(
|
||||
'publictag',
|
||||
'created_date',
|
||||
existing_type=sa.DATETIME(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('(null)'),
|
||||
)
|
||||
op.drop_constraint(None, 'bookmark', type_='foreignkey')
|
||||
op.alter_column('bookmark', 'status',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('0'))
|
||||
op.alter_column('bookmark', 'deleted_date',
|
||||
existing_type=sa.DATETIME(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('(null)'))
|
||||
op.alter_column('bookmark', 'modified_date',
|
||||
existing_type=sa.DATETIME(),
|
||||
nullable=True)
|
||||
op.alter_column('bookmark', 'starred',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('0'))
|
||||
op.alter_column('bookmark', 'note',
|
||||
existing_type=sqlmodel.sql.sqltypes.AutoString(),
|
||||
type_=sa.TEXT(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('(null)'))
|
||||
op.alter_column(
|
||||
'bookmark', 'status', existing_type=sa.INTEGER(), nullable=True, existing_server_default=sa.text('0')
|
||||
)
|
||||
op.alter_column(
|
||||
'bookmark',
|
||||
'deleted_date',
|
||||
existing_type=sa.DATETIME(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('(null)'),
|
||||
)
|
||||
op.alter_column('bookmark', 'modified_date', existing_type=sa.DATETIME(), nullable=True)
|
||||
op.alter_column(
|
||||
'bookmark', 'starred', existing_type=sa.BOOLEAN(), nullable=True, existing_server_default=sa.text('0')
|
||||
)
|
||||
op.alter_column(
|
||||
'bookmark',
|
||||
'note',
|
||||
existing_type=sqlmodel.sql.sqltypes.AutoString(),
|
||||
type_=sa.TEXT(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('(null)'),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
"""Renamed keys
|
||||
"""Renamed keys.
|
||||
|
||||
Revision ID: b8cbc6957df5
|
||||
Revises: a8d8e45f60a1
|
||||
Create Date: 2025-09-12 22:26:38.684120
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b8cbc6957df5'
|
||||
|
||||
@@ -10,7 +10,7 @@ authors = [
|
||||
]
|
||||
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.10"
|
||||
requires-python = ">=3.11"
|
||||
keywords = ["bookmarks", "api"]
|
||||
license = { text = "Apache" }
|
||||
classifiers = [
|
||||
@@ -30,11 +30,16 @@ dependencies = [
|
||||
"feedgen",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
server = [
|
||||
"uvicorn",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
{include-group = "lint"},
|
||||
{include-group = "pub"},
|
||||
{include-group = "test"}
|
||||
{ include-group = "lint" },
|
||||
{ include-group = "pub" },
|
||||
{ include-group = "test" }
|
||||
]
|
||||
test = [
|
||||
"pytest>=7.0.0",
|
||||
@@ -42,7 +47,7 @@ test = [
|
||||
]
|
||||
lint = [
|
||||
"ruff>=0.1.0",
|
||||
"mypy>=1.0.0",
|
||||
"pyrefly",
|
||||
]
|
||||
# Publishing on PyPI
|
||||
pub = [
|
||||
@@ -58,8 +63,8 @@ server = [
|
||||
my-script = "digimarks:app"
|
||||
|
||||
[project.urls]
|
||||
"Homepage" = "https://github.com/aquatix/digimarks"
|
||||
"Bug Tracker" = "https://github.com/aquatix/digimarks/issues"
|
||||
"Homepage" = "https://codeberg.org/diginaut/digimarks"
|
||||
"Bug Tracker" = "https://codeberg.org/diginaut/digimarks/issues"
|
||||
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
# Linting and fixing, including isort
|
||||
ruff
|
||||
# Typing
|
||||
pyrefly
|
||||
|
||||
# Test suite
|
||||
pytest
|
||||
pytest-cov
|
||||
|
||||
# Publishing on PyPI
|
||||
build
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Core application
|
||||
fastapi[all]
|
||||
sqlmodel
|
||||
sqlalchemy
|
||||
sqlalchemy[asyncio]
|
||||
pydantic
|
||||
pydantic_settings
|
||||
alembic
|
||||
|
||||
1
src/__init__.py
Normal file
1
src/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""digimarks main module."""
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Top-level package for Digimarks."""
|
||||
|
||||
__author__ = """Michiel Scholten"""
|
||||
|
||||
@@ -8,15 +8,16 @@ from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import bs4
|
||||
import httpx
|
||||
import tags_service
|
||||
import utils
|
||||
from exceptions import BookmarkNotFound
|
||||
from extract_favicon import from_html
|
||||
from fastapi import Query, Request
|
||||
from models import Bookmark, Visibility
|
||||
from pydantic import AnyUrl
|
||||
from fastapi.exceptions import HTTPException
|
||||
from pydantic import AnyUrl, ValidationError
|
||||
from sqlmodel import select
|
||||
|
||||
from digimarks import tags_service, utils
|
||||
from digimarks.exceptions import BookmarkNotFound
|
||||
from digimarks.models import Bookmark, Visibility
|
||||
|
||||
DIGIMARKS_USER_AGENT = 'digimarks/2.0.0-dev'
|
||||
|
||||
logger = logging.getLogger('digimarks')
|
||||
@@ -34,8 +35,11 @@ async def set_information_from_source(bookmark: Bookmark, request: Request) -> B
|
||||
"""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})
|
||||
result = await request.app.state.requests_client.get(
|
||||
str(bookmark.url), headers={'User-Agent': DIGIMARKS_USER_AGENT}
|
||||
)
|
||||
bookmark.http_status = result.status_code
|
||||
logger.info('HTTP status code %s for %s', bookmark.http_status, bookmark.url)
|
||||
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))
|
||||
@@ -43,11 +47,12 @@ async def set_information_from_source(bookmark: Bookmark, request: Request) -> B
|
||||
bookmark.title = ''
|
||||
return bookmark
|
||||
if bookmark.http_status == 200 or bookmark.http_status == 202:
|
||||
html = bs4.BeautifulSoup(result.text, 'html.parser')
|
||||
html_content = bs4.BeautifulSoup(result.text, 'html.parser')
|
||||
try:
|
||||
bookmark.title = html.title.text.strip()
|
||||
except AttributeError:
|
||||
bookmark.title = ''
|
||||
bookmark.title = html_content.title.text.strip()
|
||||
except AttributeError as exc:
|
||||
logger.error('Error while trying to extract title from URL %s: %s', str(bookmark.url), str(exc))
|
||||
raise HTTPException(status_code=400, detail='Error while trying to extract title')
|
||||
|
||||
url_parts = urlparse(str(bookmark.url))
|
||||
root_url = url_parts.scheme + '://' + url_parts.netloc
|
||||
@@ -56,8 +61,8 @@ async def set_information_from_source(bookmark: Bookmark, request: Request) -> B
|
||||
# with open(filename, 'wb') as out_file:
|
||||
# shutil.copyfileobj(response.raw, out_file)
|
||||
|
||||
# Extraction was successful
|
||||
logger.info('Extracting information was successful')
|
||||
# Extraction was successful
|
||||
logger.info('Extracting information was successful')
|
||||
return bookmark
|
||||
|
||||
|
||||
@@ -72,11 +77,15 @@ def strip_url_params(url: str) -> str:
|
||||
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):
|
||||
async def update_bookmark_with_info(bookmark: Bookmark, request: Request, strip_params: bool = False):
|
||||
"""Automatically update title, favicon, etc."""
|
||||
if isinstance(bookmark.url, str):
|
||||
# If type of the url is a 'simple' string, ensure it to be an AnyUrl
|
||||
bookmark.url = AnyUrl(bookmark.url)
|
||||
|
||||
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)
|
||||
await set_information_from_source(bookmark, request)
|
||||
|
||||
if strip_params:
|
||||
# Strip URL parameters, e.g., tracking params
|
||||
@@ -92,7 +101,10 @@ async def list_bookmarks_for_user(
|
||||
offset: int = 0,
|
||||
limit: Annotated[int, Query(le=10000)] = 100,
|
||||
) -> Sequence[Bookmark]:
|
||||
"""List all bookmarks in the database. By default, 100 items are returned."""
|
||||
"""List all bookmarks in the database. By default, 100 items are returned.
|
||||
|
||||
There is a limit of 10000 items.
|
||||
"""
|
||||
result = await session.exec(
|
||||
select(Bookmark)
|
||||
.where(Bookmark.user_key == user_key, Bookmark.status != Visibility.DELETED)
|
||||
@@ -121,12 +133,17 @@ async def autocomplete_bookmark(
|
||||
user_key: str,
|
||||
bookmark: Bookmark,
|
||||
strip_params: bool = False,
|
||||
):
|
||||
) -> Bookmark:
|
||||
"""Autofill some fields for this (new) bookmark for user `user_key`."""
|
||||
bookmark.user_key = user_key
|
||||
|
||||
# Auto-fill title, fix tags etc.
|
||||
update_bookmark_with_info(bookmark, request, strip_params)
|
||||
try:
|
||||
await update_bookmark_with_info(bookmark, request, strip_params)
|
||||
except ValidationError as exc:
|
||||
logger.error('ValidationError while autocompleting bookmark with URL %s', bookmark.url)
|
||||
logger.error('Error was: %s', str(exc))
|
||||
raise HTTPException(status_code=400, detail='Error while autocompleting, likely the URL contained an error')
|
||||
|
||||
url_hash = utils.generate_hash(str(bookmark.url))
|
||||
result = await session.exec(
|
||||
@@ -149,12 +166,12 @@ async def add_bookmark(
|
||||
user_key: str,
|
||||
bookmark: Bookmark,
|
||||
strip_params: bool = False,
|
||||
):
|
||||
) -> Bookmark:
|
||||
"""Add new bookmark for user `user_key`."""
|
||||
bookmark.user_key = user_key
|
||||
|
||||
# Auto-fill title, fix tags etc.
|
||||
update_bookmark_with_info(bookmark, request, strip_params)
|
||||
await update_bookmark_with_info(bookmark, request, strip_params)
|
||||
bookmark.url_hash = utils.generate_hash(str(bookmark.url))
|
||||
logger.info('Adding bookmark %s for user %s', bookmark.url_hash, user_key)
|
||||
|
||||
@@ -171,7 +188,7 @@ async def update_bookmark(
|
||||
bookmark: Bookmark,
|
||||
url_hash: str,
|
||||
strip_params: bool = False,
|
||||
):
|
||||
) -> Bookmark:
|
||||
"""Update existing bookmark `bookmark_key` for user `user_key`."""
|
||||
result = await session.exec(
|
||||
select(Bookmark).where(
|
||||
@@ -190,7 +207,7 @@ async def update_bookmark(
|
||||
bookmark_db.sqlmodel_update(bookmark_data)
|
||||
|
||||
# Autofill title, fix tags, etc. where (still) needed
|
||||
update_bookmark_with_info(bookmark, request, strip_params)
|
||||
await update_bookmark_with_info(bookmark, request, strip_params)
|
||||
|
||||
session.add(bookmark_db)
|
||||
await session.commit()
|
||||
@@ -202,7 +219,7 @@ async def delete_bookmark(
|
||||
session,
|
||||
user_key: str,
|
||||
url_hash: str,
|
||||
):
|
||||
) -> None:
|
||||
"""(Soft)Delete bookmark `bookmark_key` for user `user_key`."""
|
||||
result = await session.get(Bookmark, {'url_hash': url_hash, 'user_key': user_key})
|
||||
bookmark = result
|
||||
|
||||
@@ -2,20 +2,24 @@
|
||||
|
||||
|
||||
class BookmarkNotFound(Exception):
|
||||
def __init__(self, message='Bookmark not found'):
|
||||
"""A bookmark was not found."""
|
||||
|
||||
def __init__(self, message: str ='Bookmark not found'):
|
||||
"""Initialise the exception.
|
||||
|
||||
:param str message: The message for the exception
|
||||
"""
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.message: str = message
|
||||
|
||||
|
||||
class BookmarkAlreadyExists(Exception):
|
||||
def __init__(self, message='Bookmark already exists'):
|
||||
"""A bookmark already exists for this URL and this user."""
|
||||
|
||||
def __init__(self, message: str ='Bookmark already exists'):
|
||||
"""Initialise the exception.
|
||||
|
||||
:param str message: The message for the exception
|
||||
"""
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.message: str = message
|
||||
|
||||
6
src/digimarks/extract.py
Normal file
6
src/digimarks/extract.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from pydantic import AnyUrl
|
||||
|
||||
|
||||
def extract_contents(title: str, url: AnyUrl, note: str):
|
||||
"""Extract contents from a URL."""
|
||||
return
|
||||
@@ -4,18 +4,14 @@ import logging
|
||||
from collections.abc import Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated
|
||||
from typing import Annotated, AsyncGenerator, cast
|
||||
|
||||
import bookmarks_service
|
||||
import httpx
|
||||
import tags_service
|
||||
from exceptions import BookmarkNotFound
|
||||
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 models import DEFAULT_THEME, Bookmark, User, Visibility
|
||||
from pydantic import DirectoryPath, FilePath
|
||||
from pydantic_settings import BaseSettings
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
@@ -23,6 +19,10 @@ from sqlalchemy.orm import sessionmaker
|
||||
from sqlmodel import desc, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from digimarks import bookmarks_service, tags_service
|
||||
from digimarks.exceptions import BookmarkNotFound
|
||||
from digimarks.models import DEFAULT_THEME, Bookmark, User, Visibility
|
||||
|
||||
DIGIMARKS_VERSION = '2.0.0a1'
|
||||
|
||||
|
||||
@@ -34,8 +34,10 @@ class Settings(BaseSettings):
|
||||
favicons_dir: DirectoryPath
|
||||
|
||||
# inside the codebase
|
||||
static_dir: DirectoryPath = 'static'
|
||||
template_dir: DirectoryPath = 'templates'
|
||||
# static_dir: DirectoryPath = Path('digimarks/static')
|
||||
# template_dir: DirectoryPath = Path('digimarks/templates')
|
||||
static_dir: DirectoryPath = DirectoryPath('digimarks/static')
|
||||
template_dir: DirectoryPath = DirectoryPath('digimarks/templates')
|
||||
|
||||
media_url: str = '/static/'
|
||||
|
||||
@@ -52,22 +54,32 @@ engine = create_async_engine(f'sqlite+aiosqlite:///{settings.database_file}', co
|
||||
|
||||
async def get_session() -> AsyncSession:
|
||||
"""SQLAlchemy session factory."""
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async_session = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
# Shorter way of getting the DB session in an endpoint
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(the_app: FastAPI):
|
||||
async def lifespan(the_app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""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()
|
||||
async with httpx.AsyncClient() as requests_client:
|
||||
the_app.state.requests_client = requests_client
|
||||
yield
|
||||
await the_app.state.requests_client.aclose()
|
||||
|
||||
|
||||
async def get_requests_client(request: Request) -> httpx.AsyncClient:
|
||||
"""Get the httpx client from the application object."""
|
||||
return cast(httpx.AsyncClient, request.app.state.requests_client)
|
||||
|
||||
|
||||
# Shorter way of getting the httpx client in an endpoint
|
||||
RequestsDep = Annotated[AsyncSession, Depends(get_requests_client)]
|
||||
|
||||
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')
|
||||
@@ -125,7 +137,7 @@ def index(request: Request):
|
||||
|
||||
|
||||
@app.get('/api/v1/admin/{system_key}/users/{user_id}', response_model=User)
|
||||
async def get_user(session: SessionDep, system_key: str, user_id: int) -> type[User]:
|
||||
async def get_user(session: SessionDep, system_key: str, user_id: int) -> User:
|
||||
"""Show user information."""
|
||||
logger.info('User %d requested', user_id)
|
||||
if system_key != settings.system_key:
|
||||
@@ -200,7 +212,7 @@ async def autocomplete_bookmark(
|
||||
user_key: str,
|
||||
bookmark: Bookmark,
|
||||
strip_params: bool = False,
|
||||
):
|
||||
) -> Bookmark:
|
||||
"""Autofill some fields for this (new) bookmark for user `user_key`."""
|
||||
logger.info('Autocompleting bookmark %s for user %s', bookmark.url_hash, user_key)
|
||||
return await bookmarks_service.autocomplete_bookmark(session, request, user_key, bookmark, strip_params)
|
||||
@@ -246,7 +258,7 @@ async def delete_bookmark(
|
||||
"""(Soft)Delete bookmark `bookmark_key` for user `user_key`."""
|
||||
logger.info('Deleting bookmark %s for user %s', url_hash, user_key)
|
||||
try:
|
||||
result = await bookmarks_service.delete_bookmark(session, user_key, url_hash)
|
||||
_ = await bookmarks_service.delete_bookmark(session, user_key, url_hash)
|
||||
return {'ok': True}
|
||||
except Exception:
|
||||
logger.exception('Failed to delete bookmark %s', url_hash)
|
||||
@@ -273,12 +285,24 @@ async def bookmarks_changed_since(
|
||||
)
|
||||
latest_created_bookmark = result.first()
|
||||
|
||||
latest_modification = max(latest_modified_bookmark.modified_date, latest_created_bookmark.created_date)
|
||||
# There needs to be at least one bookmark of course
|
||||
if latest_created_bookmark:
|
||||
latest_created_datetime = latest_created_bookmark.created_date
|
||||
else:
|
||||
latest_created_datetime = datetime.min
|
||||
|
||||
# We only have a modified datetime when at least one has been edited
|
||||
if latest_modified_bookmark:
|
||||
latest_modified_datetime = latest_modified_bookmark.modified_date
|
||||
else:
|
||||
latest_modified_datetime = datetime.min
|
||||
|
||||
latest_modification = max(latest_modified_datetime, latest_created_datetime)
|
||||
|
||||
return {
|
||||
'current_time': datetime.now(UTC),
|
||||
'latest_change': latest_modified_bookmark.modified_date,
|
||||
'latest_created': latest_created_bookmark.created_date,
|
||||
'latest_change': latest_modified_datetime,
|
||||
'latest_created': latest_created_datetime,
|
||||
'latest_modification': latest_modification,
|
||||
}
|
||||
|
||||
@@ -299,7 +323,7 @@ async def list_bookmarks_for_tag_for_user(
|
||||
tag_key: str,
|
||||
) -> list[str]:
|
||||
"""List all tags in use by the user."""
|
||||
logger.info('List bookmarks for tag %s user %s', user_key)
|
||||
logger.info('List bookmarks for tag "%s" by user %s', tag_key, user_key)
|
||||
return await tags_service.list_bookmarks_for_tag_for_user(session, user_key, tag_key)
|
||||
|
||||
|
||||
@@ -308,7 +332,7 @@ async def page_user_landing(
|
||||
session: SessionDep,
|
||||
request: Request,
|
||||
user_key: str,
|
||||
):
|
||||
) -> HTMLResponse:
|
||||
"""HTML page with the main view for the user."""
|
||||
result = await session.exec(select(User).where(User.key == user_key))
|
||||
user = result.first()
|
||||
|
||||
@@ -5,7 +5,7 @@ Contains the bookmarks administration, users, tags, public tags and more.
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from http import HTTPStatus
|
||||
from typing import Optional, Type, TypeVar
|
||||
from typing import TypeVar
|
||||
|
||||
from pydantic import AnyUrl, computed_field
|
||||
from sqlmodel import AutoString, Field, SQLModel
|
||||
@@ -16,8 +16,6 @@ DEFAULT_THEME = 'freshgreen'
|
||||
class User(SQLModel, table=True):
|
||||
"""User account."""
|
||||
|
||||
__tablename__ = 'user'
|
||||
|
||||
id: int = Field(primary_key=True)
|
||||
username: str
|
||||
key: str
|
||||
@@ -28,23 +26,23 @@ class User(SQLModel, table=True):
|
||||
class Visibility:
|
||||
"""Options for visibility of an object."""
|
||||
|
||||
VISIBLE = 0
|
||||
DELETED = 1
|
||||
HIDDEN = 2
|
||||
VISIBLE: int = 0
|
||||
DELETED: int = 1
|
||||
HIDDEN: int = 2
|
||||
|
||||
|
||||
# Type var used for building custom types for the DB
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
def build_custom_type(internal_type: Type[T]) -> Type[AutoString]:
|
||||
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]:
|
||||
def process_bind_param(self, value, dialect) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
@@ -57,7 +55,7 @@ def build_custom_type(internal_type: Type[T]) -> Type[AutoString]:
|
||||
|
||||
return str(value)
|
||||
|
||||
def process_result_value(self, value, dialect) -> Optional[T]:
|
||||
def process_result_value(self, value, dialect) -> T | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
@@ -69,8 +67,6 @@ def build_custom_type(internal_type: Type[T]) -> Type[AutoString]:
|
||||
class Bookmark(SQLModel, table=True):
|
||||
"""Bookmark object."""
|
||||
|
||||
__tablename__ = 'bookmark'
|
||||
|
||||
id: int = Field(primary_key=True)
|
||||
user_key: str = Field(foreign_key='user.key', nullable=False)
|
||||
title: str = Field(default='', nullable=False)
|
||||
@@ -93,7 +89,7 @@ class Bookmark(SQLModel, table=True):
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def tag_list(self) -> list:
|
||||
def tag_list(self) -> list[str]:
|
||||
"""The tags but as a proper list."""
|
||||
if self.tags:
|
||||
return self.tags.split(',')
|
||||
@@ -104,8 +100,6 @@ class Bookmark(SQLModel, table=True):
|
||||
class PublicTag(SQLModel, table=True):
|
||||
"""Public tag object."""
|
||||
|
||||
__tablename__ = 'publictag'
|
||||
|
||||
id: int = Field(primary_key=True)
|
||||
tag_key: str
|
||||
user_key: str = Field(foreign_key='user.key')
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
.thumbnail {
|
||||
/*width: 80px;*/
|
||||
width: 66;
|
||||
width: 66px;
|
||||
}
|
||||
|
||||
.thumbnail img {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* v0.0.2
|
||||
*
|
||||
* Created by: Michiel Scholten
|
||||
* Source: https://github.com/aquatix/digui
|
||||
* Source: https://codeberg.org/diginaut/digui
|
||||
*/
|
||||
|
||||
/** Colours and themes */
|
||||
@@ -30,6 +30,7 @@
|
||||
--border-color: #d5d9d9;
|
||||
--border-width: 1px;
|
||||
--border-radius: 8px;
|
||||
--chip-border-radius: 2rem;
|
||||
--shadow-color: rgba(213, 217, 217, .5);
|
||||
--global-theme-toggle-content: ' 🌞';
|
||||
|
||||
@@ -77,6 +78,7 @@ html[data-theme='nebula-dark'] {
|
||||
--border-color: #333;
|
||||
--border-width: 1px;
|
||||
--border-radius: 8px;
|
||||
--chip-border-radius: 2rem;
|
||||
--shadow-color: rgba(3, 3, 3, .5);
|
||||
--global-theme-toggle-content: ' 🌝';
|
||||
}
|
||||
@@ -99,6 +101,7 @@ html[data-theme='bbs'] {
|
||||
--border-color: #333;
|
||||
--border-width: 2px;
|
||||
--border-radius: 0;
|
||||
--chip-border-radius: 0;
|
||||
--global-theme-toggle-content: ' 🖥️';
|
||||
}
|
||||
|
||||
@@ -124,6 +127,7 @@ html[data-theme='silo'] {
|
||||
/*--border-color: #003eaa;*/
|
||||
--border-width: 2px;
|
||||
--border-radius: 0;
|
||||
--chip-border-radius: 0;
|
||||
--global-theme-toggle-content: ' ⌨️';
|
||||
}
|
||||
|
||||
@@ -229,6 +233,7 @@ ol li::marker, ul li::marker {
|
||||
.active {
|
||||
background-color: var(--color-highlight);
|
||||
color: var(--text-color);
|
||||
transition-duration: 0.2s;
|
||||
}
|
||||
|
||||
/* Special button */
|
||||
@@ -254,6 +259,7 @@ button, .button, input, select, textarea {
|
||||
-webkit-user-select: none;
|
||||
touch-action: manipulation;
|
||||
vertical-align: middle;
|
||||
transition-duration: 0.2s;
|
||||
}
|
||||
|
||||
button, .button, input, select, textarea, table {
|
||||
@@ -267,6 +273,7 @@ button:hover, .button:hover {
|
||||
/*background-color: #d57803;*/
|
||||
background-color: var(--color-highlight);
|
||||
filter: brightness(80%);
|
||||
transition-duration: 0.2s;
|
||||
}
|
||||
|
||||
button:focus, .button:focus {
|
||||
@@ -303,6 +310,22 @@ button:focus, .button:focus {
|
||||
filter: brightness(80%);
|
||||
}
|
||||
|
||||
/* Toggle buttons */
|
||||
|
||||
.button-group {
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
/* Reset borders because the buttons are mashed together and the group has its own border */
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
|
||||
th {
|
||||
@@ -334,6 +357,7 @@ th, td {
|
||||
[data-theme='nebula'] .card,
|
||||
[data-theme='nebula'] button,
|
||||
[data-theme='nebula'] .button,
|
||||
[data-theme='nebula'] .button-group,
|
||||
[data-theme='nebula'] input,
|
||||
[data-theme='nebula'] select,
|
||||
[data-theme='nebula'] textarea,
|
||||
@@ -342,6 +366,7 @@ th, td {
|
||||
[data-theme='nebula-dark'] .card,
|
||||
[data-theme='nebula-dark'] button,
|
||||
[data-theme='nebula-dark'] .button,
|
||||
[data-theme='nebula-dark'] .button-group,
|
||||
[data-theme='nebula-dark'] input,
|
||||
[data-theme='nebula-dark'] select,
|
||||
[data-theme='nebula-dark'] textarea,
|
||||
@@ -419,7 +444,7 @@ th, td {
|
||||
|
||||
.chip {
|
||||
font-size: .8rem;
|
||||
border-radius: var(--border-radius);
|
||||
border-radius: var(--chip-border-radius);
|
||||
background-color: var(--background-color-secondary);
|
||||
color: var(--text-color-secondary);
|
||||
/*color: var(--text-color);*/
|
||||
@@ -428,7 +453,7 @@ th, td {
|
||||
}
|
||||
|
||||
.chip .button {
|
||||
border-radius: var(--border-radius);
|
||||
border-radius: var(--chip-border-radius);
|
||||
}
|
||||
|
||||
/* Status */
|
||||
|
||||
@@ -16,8 +16,9 @@ document.addEventListener('alpine:init', () => {
|
||||
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'),
|
||||
bookmarkToEdit: Alpine.$persist({}).as('bookmarkToEdit'),
|
||||
bookmarkToEditError: null,
|
||||
bookmarkToEditVisible: false,
|
||||
|
||||
/* Loading indicator */
|
||||
loading: false,
|
||||
@@ -39,6 +40,9 @@ document.addEventListener('alpine:init', () => {
|
||||
/** Initialise the application after loading */
|
||||
document.documentElement.setAttribute('data-theme', this.theme);
|
||||
console.log('Set theme', this.theme);
|
||||
|
||||
/* Make sure the edit/add bookmark form has a fresh empty object */
|
||||
this.resetEditBookmark();
|
||||
/* Bookmarks are refreshed through the getBookmarks() call in the HTML page */
|
||||
/* await this.getBookmarks(); */
|
||||
setInterval(() => {
|
||||
@@ -101,7 +105,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.cache[this.userKey]['tags'] = await tagsResponse.json();
|
||||
|
||||
/* Filter bookmarks by (blacklisted) tags */
|
||||
await this.filterBookmarksByTags();
|
||||
this.filterBookmarksByTags();
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
@@ -144,6 +148,10 @@ document.addEventListener('alpine:init', () => {
|
||||
)
|
||||
},
|
||||
get filteredTags() {
|
||||
if (this.cache[this.userKey].tags === undefined) {
|
||||
console.log('Tags not yet cached');
|
||||
return [];
|
||||
}
|
||||
/* Search in the list of all tags */
|
||||
return this.cache[this.userKey].tags.filter(
|
||||
i => i.match(new RegExp(this.search, "i"))
|
||||
@@ -204,24 +212,78 @@ document.addEventListener('alpine:init', () => {
|
||||
this.showBookmarksCards = !this.showBookmarksList;
|
||||
},
|
||||
|
||||
async startAddingBookmark() {
|
||||
/* Open 'add bookmark' page */
|
||||
console.log('Start adding bookmark');
|
||||
resetEditBookmark() {
|
||||
this.bookmarkToEdit = {
|
||||
'url_hash': '',
|
||||
'url': '',
|
||||
'title': '',
|
||||
'note': '',
|
||||
'tags': ''
|
||||
'tags': '',
|
||||
'http_status': 0,
|
||||
'strip_params': false
|
||||
}
|
||||
},
|
||||
async startAddingBookmark() {
|
||||
/* Open 'add bookmark' page */
|
||||
console.log('Open "add bookmark" modal');
|
||||
this.resetEditBookmark();
|
||||
// this.show_bookmark_details = true;
|
||||
const editFormDialog = document.getElementById("editFormDialog");
|
||||
this.bookmarkToEditVisible = true;
|
||||
editFormDialog.showModal();
|
||||
},
|
||||
async bookmarkURLChanged() {
|
||||
console.log('Bookmark URL changed');
|
||||
// let response = await fetch('/api/v1/' + this.userKey + '/autocomplete_bookmark/');
|
||||
try {
|
||||
const response = await fetch('/api/v1/' + this.userKey + '/autocomplete_bookmark/', {
|
||||
const response = await fetch('/api/v1/' + this.userKey + '/autocomplete_bookmark/?strip_params=' + this.bookmarkToEdit.strip_params, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// Bookmark form data
|
||||
url_hash: this.bookmarkToEdit.url_hash,
|
||||
url: this.bookmarkToEdit.url,
|
||||
title: this.bookmarkToEdit.title,
|
||||
note: this.bookmarkToEdit.note,
|
||||
tags: this.bookmarkToEdit.tags
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
// TODO: update form fields if needed (auto-fetched title for example
|
||||
console.log('Got response');
|
||||
console.log(response);
|
||||
console.log(data);
|
||||
if (response.ok) {
|
||||
this.bookmarkToEdit.url_hash = data.url_hash;
|
||||
this.bookmarkToEdit.url = data.url;
|
||||
this.bookmarkToEdit.title = data.title;
|
||||
this.bookmarkToEdit.note = data.note;
|
||||
this.bookmarkToEdit.tags = data.tags;
|
||||
this.bookmarkToEdit.http_status = data.http_status;
|
||||
} else {
|
||||
console.log('Error occurred');
|
||||
this.bookmarkToEditError = data.detail;
|
||||
}
|
||||
} catch (error) {
|
||||
// enter logic for when there is an error (ex. error toast)
|
||||
console.log('error occurred');
|
||||
console.log(error);
|
||||
this.bookmarkToEditError = error.detail;
|
||||
console.log('yesssh?');
|
||||
}
|
||||
},
|
||||
async saveBookmark() {
|
||||
console.log('Saving bookmark');
|
||||
// this.bookmarkToEditVisible = false;
|
||||
// this.show_bookmark_details = false;
|
||||
},
|
||||
async addBookmark() {
|
||||
/* Post new bookmark to the backend */
|
||||
console.log('Adding bookmark');
|
||||
try {
|
||||
const response = await fetch('/api/v1/' + this.userKey + '/add_bookmark/?strip_params=' + this.bookmarkToEdit.strip_params, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -237,20 +299,12 @@ document.addEventListener('alpine:init', () => {
|
||||
const data = await response.json();
|
||||
// TODO: update form fields if needed (auto-fetched title for example
|
||||
console.log(data);
|
||||
this.bookmarkToEditError = 'lolwut';
|
||||
// this.bookmarkToEditError = 'lolwut';
|
||||
} catch (error) {
|
||||
// enter your logic for when there is an error (ex. error toast)
|
||||
|
||||
console.log(error)
|
||||
}
|
||||
},
|
||||
async saveBookmark() {
|
||||
console.log('Saving bookmark');
|
||||
// this.show_bookmark_details = false;
|
||||
},
|
||||
async addBookmark() {
|
||||
/* Post new bookmark to the backend */
|
||||
//
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Helper functions for tags used with Bookmark models."""
|
||||
|
||||
from models import Bookmark, Visibility
|
||||
from sqlalchemy import Sequence
|
||||
from sqlmodel import select
|
||||
|
||||
from digimarks.models import Bookmark, Visibility
|
||||
|
||||
|
||||
def i_filter_false(predicate, iterable):
|
||||
"""Filter an iterable if predicate returns True.
|
||||
@@ -53,7 +54,10 @@ def clean_tags(tags_list: list) -> list[str]:
|
||||
|
||||
|
||||
def list_tags_for_bookmarks(bookmarks: Sequence[Bookmark]) -> list[str]:
|
||||
"""Generate a unique list of the tags from the list of bookmarks."""
|
||||
"""Generate a unique list of the tags from the list of bookmarks.
|
||||
|
||||
:param Sequence[Bookmark] bookmarks: List of bookmarks to create the list of tags from
|
||||
"""
|
||||
tags = []
|
||||
for bookmark in bookmarks:
|
||||
tags += bookmark.tag_list
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
<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>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<ul>
|
||||
<li><h1>digimarks</h1></li>
|
||||
<li>
|
||||
<a class="button" href="https://github.com/aquatix/digimarks">digimarks project page</a>
|
||||
<a class="button" href="https://codeberg.org/diginaut/digimarks">digimarks project page</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -18,7 +18,7 @@
|
||||
<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
|
||||
<p>Please visit your personal url, or <a href="https://codeberg.org/diginaut/digimarks">see the digimarks
|
||||
project page</a>.</p>
|
||||
|
||||
<p>If you forgot/lost your personal url, contact your digimarks
|
||||
|
||||
@@ -11,9 +11,14 @@
|
||||
<ul>
|
||||
<li><h1>digimarks</h1></li>
|
||||
<li>
|
||||
<button x-data @click="$store.digimarks.toggleTagPage()"
|
||||
:class="$store.digimarks.showTags && 'active'">tags
|
||||
</button>
|
||||
<div class="button-group">
|
||||
<button x-data @click="$store.digimarks.toggleTagPage()"
|
||||
:class="!$store.digimarks.showTags && 'active'">bookmarks
|
||||
</button>
|
||||
<button x-data @click="$store.digimarks.toggleTagPage()"
|
||||
:class="$store.digimarks.showTags && 'active'">tags
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="$store.digimarks.startAddingBookmark()">add bookmark</button>
|
||||
@@ -32,6 +37,7 @@
|
||||
<h1 x-bind:title="$store.digimarks.userKey">Bookmarks</h1>
|
||||
|
||||
<p>
|
||||
<div class="button-group">
|
||||
<button @click="$store.digimarks.sortAlphabetically()"
|
||||
:class="$store.digimarks.sortTitleAsc && 'active'">a-z ↓
|
||||
</button>
|
||||
@@ -44,9 +50,15 @@
|
||||
<button @click="$store.digimarks.sortCreated('desc')"
|
||||
:class="$store.digimarks.sortCreatedDesc && 'active'">date ↑
|
||||
</button>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button @click="$store.digimarks.toggleListOrGrid()"
|
||||
:class="$store.digimarks.showBookmarksCards && 'active'">list or grid
|
||||
:class="$store.digimarks.showBookmarksCards && 'active'">grid
|
||||
</button>
|
||||
<button @click="$store.digimarks.toggleListOrGrid()"
|
||||
:class="!$store.digimarks.showBookmarksCards && 'active'">list
|
||||
</button>
|
||||
</div>
|
||||
</p>
|
||||
|
||||
<table x-cloak x-show="$store.digimarks.showBookmarksList">
|
||||
@@ -98,15 +110,18 @@
|
||||
<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 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 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><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>
|
||||
@@ -170,43 +185,51 @@
|
||||
</span>
|
||||
</div>
|
||||
#}
|
||||
<form method="dialog" id="bookmarkEditForm">
|
||||
<fieldset class="form-group">
|
||||
<label for="bookmark_url">URL</label>
|
||||
<input id="bookmark_url" type="text" name="bookmark_url" placeholder="url"
|
||||
x-on:change.debounce="$store.digimarks.bookmarkURLChanged()"
|
||||
x-model="$store.digimarks.bookmarkToEdit.url">
|
||||
</fieldset>
|
||||
<fieldset class="form-group">
|
||||
<label for="bookmark_title">Title</label>
|
||||
<input id="bookmark_title" type="text" name="bookmark_title"
|
||||
placeholder="title (leave empty for autofetch)"
|
||||
x-model="$store.digimarks.bookmarkToEdit.title">
|
||||
</fieldset>
|
||||
<fieldset class="form-group">
|
||||
<label for="bookmark_note">Note</label>
|
||||
<textarea id="bookmark_note" type="text" name="bookmark_note"
|
||||
x-model="$store.digimarks.bookmarkToEdit.note">
|
||||
<template x-if="$store.digimarks.bookmarkToEditVisible">
|
||||
<form method="dialog" id="bookmarkEditForm">
|
||||
<fieldset class="form-group">
|
||||
<label for="bookmark_url">URL</label>
|
||||
<input id="bookmark_url" type="text" name="bookmark_url" placeholder="url"
|
||||
x-on:change.debounce="$store.digimarks.bookmarkURLChanged()"
|
||||
x-model="$store.digimarks.bookmarkToEdit.url">
|
||||
<p x-show="$store.digimarks.bookmarkToEdit.http_status > 202"
|
||||
x-text="'HTTP statuscode: ' + $store.digimarks.bookmarkToEdit.http_status" x-cloak
|
||||
class="error"></p>
|
||||
<p>
|
||||
</fieldset>
|
||||
<fieldset class="form-group">
|
||||
<label for="bookmark_title">Title</label>
|
||||
<input id="bookmark_title" type="text" name="bookmark_title"
|
||||
placeholder="title (leave empty for autofetch)"
|
||||
x-model="$store.digimarks.bookmarkToEdit.title">
|
||||
</fieldset>
|
||||
<fieldset class="form-group">
|
||||
<label for="bookmark_note">Note</label>
|
||||
<textarea id="bookmark_note" type="text" name="bookmark_note"
|
||||
x-model="$store.digimarks.bookmarkToEdit.note">
|
||||
</textarea>
|
||||
</fieldset>
|
||||
<fieldset class="form-group">
|
||||
<label for="bookmark_tags">Tags</label>
|
||||
<input id="bookmark_tags" type="text" name="bookmark_tags"
|
||||
placeholder="tags, divided bij comma's"
|
||||
x-model="$store.digimarks.bookmarkToEdit.tags">
|
||||
</fieldset>
|
||||
<p x-show="$store.digimarks.bookmarkToEditError" x-data="$store.digimarks.bookmarkToEditError"></p>
|
||||
<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>
|
||||
</fieldset>
|
||||
<fieldset class="form-group">
|
||||
<label for="bookmark_tags">Tags</label>
|
||||
<input id="bookmark_tags" type="text" name="bookmark_tags"
|
||||
placeholder="tags, divided bij comma's"
|
||||
x-model="$store.digimarks.bookmarkToEdit.tags">
|
||||
</fieldset>
|
||||
<p x-show="$store.digimarks.bookmarkToEditError"
|
||||
x-text="$store.digimarks.bookmarkToEditError" x-cloak class="error"></p>
|
||||
<p>
|
||||
<label>
|
||||
<input type="checkbox" x-model="$store.digimarks.bookmarkToEdit.strip_params"/>
|
||||
<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>
|
||||
<button @click="$store.digimarks.addBookmark()">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</dialog>
|
||||
</main>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user