1
0
mirror of https://codeberg.org/diginaut/digimarks.git synced 2026-02-04 18:30:26 +01:00

16 Commits

12 changed files with 161 additions and 57 deletions

1
.envrc.example Normal file
View File

@@ -0,0 +1 @@
layout uv

9
.gitignore vendored
View File

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

View File

@@ -27,9 +27,10 @@ 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 in the active virtualenv
@@ -124,7 +125,7 @@ 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 +136,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

View File

@@ -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,6 +30,11 @@ dependencies = [
"feedgen",
]
[project.optional-dependencies]
server = [
"uvicorn",
]
[dependency-groups]
dev = [
{ include-group = "lint" },
@@ -50,7 +55,7 @@ pub = [
"twine"
]
server = [
"uvicorn",
"gunicorn>=23.0.0",
]
# dynamic = ["version"]

View File

@@ -1,7 +1,7 @@
# Core application
fastapi[all]
sqlmodel
sqlalchemy
sqlalchemy[asyncio]
pydantic
pydantic_settings
alembic

View File

@@ -10,7 +10,8 @@ import bs4
import httpx
from extract_favicon import from_html
from fastapi import Query, Request
from pydantic import AnyUrl
from fastapi.exceptions import HTTPException
from pydantic import AnyUrl, ValidationError
from sqlmodel import select
from digimarks import tags_service, utils
@@ -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
@@ -129,7 +138,12 @@ async def autocomplete_bookmark(
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(
@@ -157,7 +171,7 @@ async def add_bookmark(
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)
@@ -193,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()

6
src/digimarks/extract.py Normal file
View File

@@ -0,0 +1,6 @@
from pydantic import AnyUrl
def extract_contents(title: str, url: AnyUrl, note: str):
"""Extract contents from a URL."""
return

View File

@@ -4,7 +4,7 @@ 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 httpx
from fastapi import Depends, FastAPI, HTTPException, Query, Request
@@ -36,8 +36,8 @@ class Settings(BaseSettings):
# inside the codebase
# static_dir: DirectoryPath = Path('digimarks/static')
# template_dir: DirectoryPath = Path('digimarks/templates')
static_dir: DirectoryPath = 'digimarks/static'
template_dir: DirectoryPath = 'digimarks/templates'
static_dir: DirectoryPath = DirectoryPath('digimarks/static')
template_dir: DirectoryPath = DirectoryPath('digimarks/templates')
media_url: str = '/static/'
@@ -54,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')
@@ -127,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:
@@ -275,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,
}

View File

@@ -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 */

View File

@@ -105,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;
},
@@ -214,15 +214,18 @@ document.addEventListener('alpine:init', () => {
resetEditBookmark() {
this.bookmarkToEdit = {
'url_hash': '',
'url': '',
'title': '',
'note': '',
'tags': ''
'tags': '',
'http_status': 0,
'strip_params': false
}
},
async startAddingBookmark() {
/* Open 'add bookmark' page */
console.log('Start adding bookmark');
console.log('Open "add bookmark" modal');
this.resetEditBookmark();
// this.show_bookmark_details = true;
const editFormDialog = document.getElementById("editFormDialog");
@@ -233,7 +236,54 @@ document.addEventListener('alpine:init', () => {
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'
@@ -249,21 +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.bookmarkToEditVisible = false;
// this.show_bookmark_details = false;
},
async addBookmark() {
/* Post new bookmark to the backend */
//
}
})
});

View File

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

View File

@@ -192,6 +192,10 @@
<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>
@@ -212,16 +216,17 @@
x-model="$store.digimarks.bookmarkToEdit.tags">
</fieldset>
<p x-show="$store.digimarks.bookmarkToEditError"
x-data="$store.digimarks.bookmarkToEditError"></p>
x-text="$store.digimarks.bookmarkToEditError" x-cloak class="error"></p>
<p>
<label>
<input type="checkbox" name="strip" id="strip"/>
<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>