1
0
mirror of https://github.com/aquatix/digimarks.git synced 2025-12-06 22:05:09 +01:00

260 Commits

Author SHA1 Message Date
da28f2f781 Ensure empty form data 2025-09-23 15:36:08 +02:00
987a030c4f venv should be active for this command 2025-09-23 15:35:45 +02:00
bf6cd081f9 Typing and docstring improvements 2025-09-22 12:26:57 +02:00
651a7e4ece Fixed imports from same dir/module 2025-09-21 22:54:44 +02:00
63ebc33b04 Implementing add/edit bookmark form with auto-complete 2025-09-21 22:32:14 +02:00
5f2e2c37fa Be more explicit about some dependencies 2025-09-21 21:24:50 +02:00
21306f030e More uv usage 2025-09-21 18:31:40 +02:00
f05525a9cd Made .txt the same as .in for now 2025-09-20 22:09:54 +02:00
ac4ae2edd0 Refactoring tags endpoints to use tags_service 2025-09-14 22:10:40 +02:00
425b9441ed Moved Bookmark operations to service, added logging 2025-09-13 22:11:30 +02:00
6047302e09 Better module naming for the model functions 2025-09-13 20:35:05 +02:00
8234b8c603 Added requirements to project file 2025-09-12 23:05:33 +02:00
a887d93c8f Cleanups 2025-09-12 23:01:35 +02:00
3cf322ac29 Cleanup and fixed the down migration 2025-09-12 22:51:28 +02:00
8b4ee37fec Fix migration to actually rename sqlite columns 2025-09-12 22:49:34 +02:00
0cdd2fbb93 Renamed key properties 2025-09-12 20:06:09 +02:00
99d883d0e9 Moved more functionality to modules, away from main app file 2025-09-12 19:59:07 +02:00
7facbeb149 'hidden' visibility option 2025-09-12 19:59:06 +02:00
9890eafb69 Migrating DB to something usable with sqlmodel 2025-09-12 17:37:58 +02:00
fd2708247d Merge pull request #50 from aquatix/alembic
Implement alembic migrations and better DB models module, including some async rework
2025-09-12 17:22:33 +02:00
ad7f7df21c Migration to sqlmodel way of doing things 2025-09-12 17:19:11 +02:00
ad92e23804 Initial migration with original state from peewee ORM 2025-09-12 16:29:17 +02:00
f4afa34f69 Point alembic config to the new models module 2025-09-12 12:30:14 +02:00
59205166cb Moved DB models to their own module 2025-09-12 12:26:59 +02:00
b6a81fded4 Make alembic comprehend/use sqlmodel 2025-09-12 12:15:25 +02:00
3a87485b9a Application now uses async DB session everywhere 2025-09-12 12:03:12 +02:00
1219371185 async sqlite migrations config 2025-09-12 12:03:07 +02:00
80f585487a Introducing alembic migrations 2025-09-11 16:50:26 +02:00
e55b3c0ea1 Only show dropshadow on nav on the correct themes 2025-07-07 21:54:54 +02:00
6ed1269711 Better size and umwelt for modal dialog 2025-06-17 22:30:43 +02:00
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
2615089acd Updates 2018-09-11 13:10:48 +02:00
14bf22f3e5 Updated to 1.0.0 release of MaterializeCSS 2018-09-11 13:10:06 +02:00
e55fb7bd5f Updated some urls 2018-08-29 16:36:23 +02:00
4e1261857d Updates 2018-08-13 10:32:16 +02:00
f9861c1491 Dependency bumps 2018-07-10 22:12:57 +02:00
836077ad12 New MaterializeCSS release 2018-07-10 22:07:45 +02:00
0913ffca2c Minor version bumps 2018-06-13 09:40:13 +02:00
834f95c34b Do not import reserved name 2018-05-25 16:21:53 +02:00
c82e3a02d4 ESLint hint about 'M' being global (MaterializeCSS) 2018-05-25 16:17:29 +02:00
7874002fef pypi downloads is not available any longer 2018-05-25 16:06:36 +02:00
6db0355cc7 Move badge to the same line as the others 2018-05-25 13:10:54 +02:00
Michiel Scholten
0495296f0f Merge pull request #15 from codacy-badger/codacy-badge
Add a Codacy badge to README.rst
2018-05-25 13:09:10 +02:00
The Codacy Badger
a5d225fb56 Add Codacy badge 2018-05-25 10:15:26 +00:00
f6401a3e9f Problems found by Codacy 2018-05-25 12:11:17 +02:00
46e7fc9899 Updates 2018-05-25 12:10:07 +02:00
f6befd0700 Note about themed links 2018-05-22 09:54:09 +02:00
95ff9c01ca Properly theme regular links 2018-05-15 11:51:46 +02:00
b36cd8db6b Also strip enter characters 2018-05-15 11:44:53 +02:00
2b19b770dc Regular links (in tables) should be themed too 2018-05-15 10:58:42 +02:00
6dc74c7102 Autocomplete has been implemented; fix globbering of <systemkey> 2018-05-09 16:07:10 +02:00
ac7906d781 New version of peewee ORM 2018-05-09 16:03:19 +02:00
5d2329ff90 Release candidate of MaterializeCSS 2018-05-09 16:01:37 +02:00
a29e14b7a7 Upgrades 2018-05-03 09:19:33 +02:00
5e4a35527b Fix for bytes/str discrepancy 2018-04-12 15:58:32 +02:00
55609aa353 Dependency updates 2018-04-12 15:52:58 +02:00
0d86c2608a MaterializeCSS update 2018-04-12 15:33:35 +02:00
dd1e3a19ff Run app on 0.0.0.0 instead of 127.0.0.1 (e.g., in termux) 2018-03-30 16:01:50 +02:00
9694ca566b Nicer fallback favicon image 2018-03-30 15:40:29 +02:00
17caef1aed Fix 'home button' link in digimarks title 2018-03-30 09:41:08 +02:00
52a01794f6 Strip unnecessary whitespace 2018-03-30 09:40:53 +02:00
8148a79d28 Fix for narrow favicons if title contains long words 2018-03-30 09:40:19 +02:00
d6e74ff328 'Home' link now takes view type into account 2018-03-28 15:33:51 +02:00
540fd6ba91 Find button now is icon, form size tweaking 2018-03-27 11:15:30 +02:00
3becf27b42 Styling fixes: nice buttons for edit/delete bookmark, button for
list/cards
2018-03-27 09:59:39 +02:00
fe990ecf63 Implemented listing of bookmarks 2018-03-27 09:17:48 +02:00
75080579fd Fix some url lookups 2018-03-26 16:18:59 +02:00
94eb42a882 Note removal of jQuery as a Removed thing 2018-03-26 16:18:52 +02:00
d501c6b4db De-jQuery'd a forgetten piece of Javascript 2018-03-26 15:53:34 +02:00
11e159db8d Re-enable the automatic redirect; some dark styling 2018-03-26 15:42:14 +02:00
6516c4af1d Moved autocompletion items to seperate javascript call 2018-03-26 15:33:07 +02:00
63636d3355 More goodness 2018-03-26 15:18:39 +02:00
52cc93d4c3 Switch default theme to the fresher green variant 2018-03-26 15:11:24 +02:00
6f9d44ce86 Fixed API endpoint for single bookmark lookup 2018-03-26 15:11:07 +02:00
718b39a267 Reminder 2018-03-26 15:00:09 +02:00
bfc4fb702a Some more security against referrers from digimarks 2018-03-26 14:59:49 +02:00
7e2f2f6f6e Support for redirecting a bookmark to its url 2018-03-26 14:58:21 +02:00
29c8c875be More room for delete button (icon stays shown), red colour 2018-03-26 14:25:48 +02:00
0f0caed748 Also theme (autocomplete) search inputfield 2018-03-25 22:22:23 +02:00
971590196e Label, icon and underline are now themed in edit bookmark form 2018-03-25 22:08:37 +02:00
3653b5e424 Disable browser autocomplete for forms, as it interferes 2018-03-25 16:23:56 +02:00
330523ba3f Submit search on autocomplete, limit results in autocomplete 2018-03-24 21:58:53 +01:00
199b641a38 Removed limit for feeds 2018-03-24 21:39:53 +01:00
c0c8e35246 Fix for case where a bookmark has no title 2018-03-24 21:33:15 +01:00
6de9ba2642 Lots of styling fixes, name clash resolutions 2018-03-24 21:31:17 +01:00
e48f2c98c3 Search API endpoint 2018-03-24 21:06:30 +01:00
554f651ec8 Filter autocomplete 2018-03-24 20:48:41 +01:00
6def8d60a5 Buttons and the intention to make collections more accessible 2018-03-24 11:37:35 +01:00
1d531989bb Main nav items to buttons, theme support for buttons 2018-03-24 11:17:26 +01:00
fc2712f5e3 Fix for Python 3 and encoding 2018-03-23 15:10:13 +01:00
76ef520815 Favicon changes and fixes 2018-03-23 14:40:37 +01:00
ea4a7bdcd7 Crash fix 2018-03-23 14:27:47 +01:00
aee0515eae Support for fallback icon, clear empty icon; don't re-download existing 2018-03-23 14:21:15 +01:00
3835497918 Really skip 2018-03-23 13:32:59 +01:00
d7b2c28c96 Case where no icon is supplied 2018-03-23 13:17:21 +01:00
fac3a4f747 Catch case where favicon property is empty 2018-03-23 13:11:06 +01:00
0548f35b39 Endpoint to download all missing icons 2018-03-23 13:03:08 +01:00
8372d6e2a5 Implemented realfavicongenerator API through Mashape 2018-03-23 12:58:27 +01:00
cd2911e7f0 Better description, added missing bs4 dependency 2018-03-23 11:17:16 +01:00
127d99b1e0 We're getting somewhere 2018-03-23 11:08:39 +01:00
bb4f81262e Add icons to the top navbar too 2018-03-23 11:06:02 +01:00
3c697d3162 Find text in url and note too, apart from title 2018-03-23 10:52:09 +01:00
fefb317ddf Use a custom User Agent string to prevent server blockage 2018-03-23 10:44:23 +01:00
37ebdda933 Reference fix 2018-03-17 18:41:27 +01:00
3516fbfbb2 Serialisation fix 2018-03-17 18:41:24 +01:00
c4f921ac68 Refactored the public tag overviews 2018-03-17 18:34:58 +01:00
fec54c51f7 Show tags and edit buttons when on a tag overview page 2018-03-17 18:19:47 +01:00
9f467f8a09 Some more readability fixes 2018-03-17 16:21:31 +01:00
cac31e40c9 Cleanup and better API endpoint 2018-03-17 16:18:53 +01:00
1e14163d42 Lots of indentation and other readability & lintian fixes 2018-03-17 16:14:52 +01:00
88eee28b88 Latest changes 2018-03-17 15:50:49 +01:00
45c95f5f17 Properly serialise bookmarks 2018-03-17 15:46:54 +01:00
9539c48b7b Split fetching of bookmarks into generic and view-specific functions 2018-03-17 15:42:39 +01:00
46aa230fae Support read-only/editable and show/hide tags per view 2018-03-17 15:01:44 +01:00
c6089f1caa Moved tags to the metadata card reveal 2018-03-17 14:56:38 +01:00
45d44d3bdf Moved the cards to their own template 2018-03-17 14:38:49 +01:00
e4662351c2 Non-validating input field 2018-03-17 13:58:58 +01:00
62f3ddf654 Fix for dark-on-dark text in filter input 2018-03-17 13:58:56 +01:00
072ec6c426 Updated dependencies 2018-03-17 13:49:20 +01:00
5055947351 Indentation fix 2018-03-09 15:25:35 +01:00
5f131b15ef Bye bye flask-peewee 2018-03-02 21:10:24 +01:00
bd808a9e1d Reverting to older peewee's, as peewee-flask is hosed 2018-03-02 20:48:23 +01:00
3b019e4368 Updated to the latest MaterializeCSS alpha 2018-03-02 20:24:32 +01:00
3af1239326 Updated dependencies 2018-03-02 20:21:28 +01:00
e8ea948566 Improved ignores 2018-01-12 12:23:58 +01:00
1c2090f300 Cleanups, clarification 2018-01-12 12:21:09 +01:00
cdfd0341f0 Non-validating text inputs 2018-01-03 14:51:54 +01:00
01d6525861 Fixed dark text on dark background 2018-01-03 14:51:35 +01:00
c70e53a658 MaterializeCSS 1.0-alpha-3 2018-01-03 14:40:08 +01:00
dc76a592e0 Updated dependencies 2018-01-03 14:40:03 +01:00
a4aae2d6c4 Note about jQuery being booted 2017-12-25 13:43:24 +01:00
389e63bdbb Removed jQuery code, replaced with pure JavaScript 2017-12-25 11:39:59 +01:00
6ba4803ed2 jQuery -> new MaterializeCSS way 2017-12-24 20:58:15 +01:00
71756f9ea0 Make the Hamburger great again 2017-12-24 14:28:06 +01:00
67635c199a Fixed favicon type sniffing and writing to file in Python 3 2017-12-20 21:01:53 +01:00
7b2a861652 Ch-ch-ch-changes 2017-12-20 20:46:14 +01:00
53887c8ece Also accept http202; try fixing encoding issue 2017-12-19 22:03:51 +01:00
2cc2d382c1 Merge branch 'master' of github.com:aquatix/digimarks 2017-12-19 22:03:16 +01:00
fa033452f1 Fixed checkboxes 2017-12-19 14:58:45 +01:00
c14e24430b Initialise collapsible block so it actually unfolds/folds 2017-12-19 14:55:36 +01:00
cca4504fd7 Updates after Python 3 and latest pip-tools 2017-12-17 15:15:56 +01:00
7f6dc3f3df Python 2+3 2017-12-17 13:52:00 +01:00
4170a7818b Needed for importing settings.py 2017-12-17 13:44:41 +01:00
Michiel Scholten
48e77f551d Merge pull request #13 from aquatix/materialize1.0
Materialize1.0 and Python3
2017-12-17 11:51:24 +01:00
e35d9952bd Not needed with newer pip-tools 2017-12-13 11:47:38 +01:00
6933357a61 Python 3 compatibility fix 2017-12-13 11:47:36 +01:00
9578ee624b Silence 2017-12-13 11:37:26 +01:00
ded047d749 Fixes for MaterializeCSS 1.0.0 changes (based on alpha 2) 2017-12-13 11:37:01 +01:00
57226b88f5 Updates 2017-12-13 11:36:58 +01:00
de5d4d30ef Updated dependencies 2017-11-21 16:18:16 +01:00
60f5a48d89 Black amoled theme 2017-11-21 16:15:50 +01:00
47f6e36e4b Fixed order of imports 2017-11-03 16:55:40 +01:00
5402dfc320 Some updates 2017-11-03 16:53:15 +01:00
90f1322c48 Development requirements 2017-11-03 16:52:48 +01:00
d1aef0284f MaterializeCSS update 2017-08-27 13:49:46 +02:00
d7a5bd921f Recent updates 2017-08-15 10:46:27 +02:00
b85ee43cc7 Better error message on 301, with hint on using the button 2017-08-14 14:24:12 +02:00
8077499eae Something with name clashes 2017-08-14 13:41:58 +02:00
ab06f7e583 Recent changes 2017-07-31 16:34:34 +02:00
f309d4acf2 Make 404 page theme aware (falls back to default) 2017-07-31 16:33:07 +02:00
88a9806d44 Fix clickability of checkboxes 2017-07-31 16:29:27 +02:00
6f4d270858 Don't override the colour of form widgets 2017-07-28 13:49:24 +02:00
c354613b60 Changes 2017-07-26 08:25:47 +02:00
383c77ee8b Fixed padding for card reveal 2017-07-23 14:30:50 +02:00
0e77afd000 Modified card padding so it fits more content 2017-07-23 14:28:20 +02:00
f617fb8190 Moved chip link colour to theme 2017-07-23 13:34:34 +02:00
34af0e9ab7 Added lightblue theme and changed link colour 2017-07-23 13:11:27 +02:00
7f866658e3 (mobile) browser chrome theming 2017-07-23 08:30:26 +02:00
56 changed files with 3113 additions and 1364 deletions

4
.codacy.yaml Normal file
View File

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

13
.gitignore vendored
View File

@@ -87,3 +87,16 @@ ENV/
# Rope project settings
.ropeproject
# JetBrains PyCharm/Idea
.idea
# vim
*.swp
# digimarks
static/favicons
tags
*.db_*
*.db
settings.py

View File

@@ -1,8 +1,8 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## TODO
@@ -11,14 +11,73 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- Sort by title
- Sort by date
- Logging of actions
- Change tags to the MaterializeCSS tags: http://materializecss.com/chips.html
- Add new way of authentication and editing bookmark collections:
https://github.com/aquatix/digimarks/issues/8 and https://github.com/aquatix/digimarks/issues/9
- Change adding tags to use the MaterializeCSS tags: https://materializecss.com/chips.html
- Do calls to the API endpoint of an existing bookmark when editing properties
(for example to update tags, title and such, also to already suggest title)
- Look into compatibility with del.icio.us, so we can make use of existing browser integration
- Add unit tests
## [Unreleased]
### Added
- Settings through Pydantic Settings
### Changed
- Moved from Flask to FastAPI
- Moved from Peewee ORM to SQLAlchemy
### Removed
- Jinja2 templates
### Fixed
## [1.2.0] - Flask is Fine (2023-07-30)
### Added
- 'lightblue' theme
- 'black amoled' theme
- Python 3 compatibility (tested with Python 3.5 and 3.6)
- Accept 'HTTP 202' responses as 'OK'
- API: Added endpoint for 'bookmarks', returning JSON
- Top navigation items now have icons too, like the sidebar in mobile view
- Download favicons from RealFaviconGenerator: https://realfavicongenerator.net/api/download_website_favicon
- Added `/<systemkey>/findmissingfavicons` endpoint to fill in the blanks in the favicon collection
- Added fallback favicon image (semitransparent digimarks 'M' logo) for bookmarks without a favicon. No more broken images.
- Added theme support for buttons.
- Autocompletion in bookmark search field
- API: search endpoint
- Redirect endpoint for a bookmark, de-referring to its url (`/r/<userkey>/<urlhash>`)
### Changed
- Fixed theming of browser chrome in mobile browsers
- Changed link colour of 'dark' theme from blue to orange
- Modified card padding so it fits more content
- Fixed ability to select a checkbox in the add/edit bookmark form
- Made the 404 page theme aware, falls back to default (green) theme
- Fixed admin pages not working anymore due to `settings` object name clash
- On Add/Edit bookmark and encountering a 301, show a better message about automatically changing the URL with the provided button
- Switched to 1.0 (alpha 4) version of MaterializeCSS
- jQuery-b-gone: changed all jQuery code to regular JavaScript code/MaterializeCSS framework
- Fixed colour of filter text in search field for dark themes
- Unified rendering of 'private' and 'public' views of bookmark cards
- Code cleanups, readability fixes
- digimarks User Agent string to correctly identify ourselves, also preventing servers blocking 'bots'
- Text search now also finds matches in the 'note' and 'url' of a bookmark, aside from its title
- Main navigation items ('tags' and 'add bookmark') are now buttons, better visible as action items.
- Removed item limit for feeds
- Form fields are now themed
- Disabled browser autocomplete for forms, which generally interfered with editing bookmarks (e.g., tag field) and the search field,
which has its own autocomplete now
- Changed default theme to the 'freshgreen' variant
- Links are now themed in the proper colours everywhere
### Removed
- Removed dependency on jQuery
## [1.1.0] - 2017-07-22
@@ -27,6 +86,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- Cache buster to force loading of the latest styling
- Theming support, default is 'green'
- Themes need an extra `theme` field in the User table
- Added 'freshgreen' and 'dark' themes
### Changed
- Make running in a virtualenv optional

View File

@@ -1,7 +1,7 @@
digimarks
=========
|PyPI version| |PyPI downloads| |PyPI license| |Code health|
|PyPI version| |PyPI license| |Code health| |Codacy|
Simple bookmarking service, using a SQLite database to store bookmarks, supporting tags and automatic title fetching.
@@ -30,19 +30,54 @@ necessary packages:
git clone https://github.com/aquatix/digimarks.git
cd digimarks
mkvirtualenv digimarks # or whatever project you are working on
pip install -r requirements.txt
# 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:
.. code-block:: bash
alembic stamp 115bcd2e1a38
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>
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 +107,9 @@ If you for whatever reason would lose this user key, just either look on the con
Server configuration
~~~~~~~~~~~~~~~~~~~~
* `vhost for Apache2.4`_
* `uwsgi.ini`_
* `systemd for digimarks API`_ which uses the `gunicorn config`_
* `nginx for digimarks API`_
* `more config`_
What's new?
@@ -89,18 +125,21 @@ Attributions
.. _digimarks: https://github.com/aquatix/digimarks
.. _webhook: https://en.wikipedia.org/wiki/Webhook
.. |PyPI version| image:: https://img.shields.io/pypi/v/digimarks.svg
:target: https://pypi.python.org/pypi/digimarks/
.. |PyPI downloads| image:: https://img.shields.io/pypi/dm/digimarks.svg
:target: https://pypi.python.org/pypi/digimarks/
.. |PyPI license| image:: https://img.shields.io/github/license/aquatix/digimarks.svg
:target: https://pypi.python.org/pypi/digimarks/
.. |Code health| image:: https://landscape.io/github/aquatix/digimarks/master/landscape.svg?style=flat
:target: https://landscape.io/github/aquatix/digimarks/master
:alt: Code Health
.. |Codacy| image:: https://api.codacy.com/project/badge/Grade/9a34319d917b43219a29e59e9ac75e3b
:alt: Codacy Badge
:target: https://app.codacy.com/app/aquatix/digimarks?utm_source=github.com&utm_medium=referral&utm_content=aquatix/digimarks&utm_campaign=badger
.. _hook settings: https://github.com/aquatix/digimarks/blob/master/example_config/examples.yaml
.. _vhost for Apache2.4: https://github.com/aquatix/digimarks/blob/master/example_config/apache_vhost.conf
.. _uwsgi.ini: https://github.com/aquatix/digimarks/blob/master/example_config/uwsgi.ini
.. _Changelog: https://github.com/aquatix/digimarks/blob/master/CHANGELOG.md
.. _Freepik: http://www.flaticon.com/free-icon/letter-m_2041
.. _systemd for digimarks API: https://github.com/aquatix/digimarks/blob/master/example_config/systemd/digimarks.service
.. _gunicorn config: https://github.com/aquatix/digimarks/blob/master/example_config/gunicorn_digimarks_conf.py
.. _more config: https://github.com/aquatix/digimarks/tree/master/example_config

147
alembic.ini Normal file
View File

@@ -0,0 +1,147 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = sqlite+aiosqlite:///bookmarks.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -1,755 +0,0 @@
from __future__ import print_function
import datetime
import gzip
import hashlib
import os
import sys
import requests
import shutil
import bs4
from urlparse import urlparse, urlunparse, urljoin
from flask import Flask, abort, redirect, render_template, request, url_for, jsonify
from werkzeug.contrib.atom import AtomFeed
from flask_peewee.db import Database
#from flask_peewee.utils import get_object_or_404
from peewee import * # noqa
DEFAULT_THEME = 'green'
themes = {
'green': {
'BODY': 'grey lighten-4',
'TEXT': 'black-text',
'TEXTHEX': '#000',
'NAV': 'green darken-3',
'PAGEHEADER': 'grey-text lighten-5',
'MESSAGE_BACKGROUND': 'orange lighten-2',
'MESSAGE_TEXT': 'white-text',
'ERRORMESSAGE_BACKGROUND': 'red darken-1',
'ERRORMESSAGE_TEXT': 'white-text',
'CARD_BACKGROUND': 'green darken-3',
'CARD_TEXT': 'white-text',
'FAB': 'red',
'STAR': 'yellow-text',
'PROBLEM': 'red-text',
'COMMENT': '',
},
'freshgreen': {
'BODY': 'grey lighten-5',
'TEXT': 'black-text',
'TEXTHEX': '#000',
'NAV': 'green darken-1',
'PAGEHEADER': 'grey-text lighten-5',
'MESSAGE_BACKGROUND': 'orange lighten-2',
'MESSAGE_TEXT': 'white-text',
'ERRORMESSAGE_BACKGROUND': 'red darken-1',
'ERRORMESSAGE_TEXT': 'white-text',
'CARD_BACKGROUND': 'green darken-1',
'CARD_TEXT': 'white-text',
'FAB': 'red',
'STAR': 'yellow-text',
'PROBLEM': 'red-text',
'COMMENT': '',
},
'dark': {
'BODY': 'grey darken-4',
'TEXT': 'grey-text lighten-1',
'TEXTHEX': '#bdbdbd',
'NAV': 'grey darken-3',
'PAGEHEADER': 'grey-text lighten-1',
'MESSAGE_BACKGROUND': 'orange lighten-2',
'MESSAGE_TEXT': 'white-text',
'ERRORMESSAGE_BACKGROUND': 'red darken-1',
'ERRORMESSAGE_TEXT': 'white-text',
'CARD_BACKGROUND': 'grey darken-3',
'CARD_TEXT': 'grey-text lighten-1',
'FAB': 'red',
'STAR': 'yellow-text',
'PROBLEM': 'red-text',
'COMMENT': '',
}
}
try:
import settings
except ImportError:
print('Copy settings_example.py to settings.py and set the configuration to your own preferences')
sys.exit(1)
# app configuration
APP_ROOT = os.path.dirname(os.path.realpath(__file__))
MEDIA_ROOT = os.path.join(APP_ROOT, 'static')
MEDIA_URL = '/static/'
DATABASE = {
'name': os.path.join(APP_ROOT, 'bookmarks.db'),
'engine': 'peewee.SqliteDatabase',
}
#PHANTOM = '/usr/local/bin/phantomjs'
#SCRIPT = os.path.join(APP_ROOT, 'screenshot.js')
# create our flask app and a database wrapper
app = Flask(__name__)
app.config.from_object(__name__)
db = Database(app)
# set custom url for the app, for example '/bookmarks'
try:
app.config['APPLICATION_ROOT'] = settings.APPLICATION_ROOT
except AttributeError:
pass
# Cache the tags
all_tags = {}
settings = {}
def ifilterfalse(predicate, iterable):
# ifilterfalse(lambda x: x%2, range(10)) --> 0 2 4 6 8
if predicate is None:
predicate = bool
for x in iterable:
if not predicate(x):
yield x
def unique_everseen(iterable, key=None):
"List unique elements, preserving order. Remember all elements ever seen."
# unique_everseen('AAAABBBCCDAABBB') --> A B C D
# unique_everseen('ABBCcAD', str.lower) --> A B C D
seen = set()
seen_add = seen.add
if key is None:
for element in ifilterfalse(seen.__contains__, iterable):
seen_add(element)
yield element
else:
for element in iterable:
k = key(element)
if k not in seen:
seen_add(k)
yield element
def clean_tags(tags_list):
tags_res = [x.strip() for x in tags_list]
tags_res = list(unique_everseen(tags_res))
tags_res.sort()
if tags_res and tags_res[0] == '':
del tags_res[0]
return tags_res
magic_dict = {
"\x1f\x8b\x08": "gz",
"\x42\x5a\x68": "bz2",
"\x50\x4b\x03\x04": "zip"
}
max_len = max(len(x) for x in magic_dict)
def file_type(filename):
with open(filename) as f:
file_start = f.read(max_len)
for magic, filetype in magic_dict.items():
if file_start.startswith(magic):
return filetype
return "no match"
class User(db.Model):
""" User account """
username = CharField()
key = CharField()
theme = CharField(default=DEFAULT_THEME)
created_date = DateTimeField(default=datetime.datetime.now)
def generate_key(self):
""" Generate userkey """
self.key = os.urandom(24).encode('hex')
return self.key
class Bookmark(db.Model):
""" Bookmark instance, connected to User """
# Foreign key to User
userkey = CharField()
title = CharField(default='')
url = CharField()
note = TextField(default='')
#image = CharField(default='')
url_hash = CharField(default='')
tags = CharField(default='')
starred = BooleanField(default=False)
# Website (domain) favicon
favicon = CharField(null=True)
# Status code: 200 is OK, 404 is not found, for example (showing an error)
HTTP_CONNECTIONERROR = 0
HTTP_OK = 200
HTTP_MOVEDTEMPORARILY = 304
HTTP_NOTFOUND = 404
http_status = IntegerField(default=200)
redirect_uri = None
created_date = DateTimeField(default=datetime.datetime.now)
modified_date = DateTimeField(null=True)
deleted_date = DateTimeField(null=True)
# Bookmark status; deleting doesn't remove from DB
VISIBLE = 0
DELETED = 1
status = IntegerField(default=VISIBLE)
class Meta:
ordering = (('created_date', 'desc'),)
#def fetch_image(self):
# url_hash = hashlib.md5(self.url).hexdigest()
# filename = 'bookmark-%s.png' % url_hash
# outfile = os.path.join(MEDIA_ROOT, filename)
# params = [PHANTOM, SCRIPT, self.url, outfile]
# exitcode = subprocess.call(params)
# if exitcode == 0:
# self.image = os.path.join(MEDIA_URL, filename)
def set_hash(self):
""" Generate hash """
self.url_hash = hashlib.md5(self.url).hexdigest()
def set_title_from_source(self):
""" Request the title by requesting the source url """
try:
result = requests.get(self.url)
self.http_status = result.status_code
except:
# For example 'MissingSchema: Invalid URL 'abc': No schema supplied. Perhaps you meant http://abc?'
self.http_status = 404
if self.http_status == 200:
html = bs4.BeautifulSoup(result.text, 'html.parser')
try:
self.title = html.title.text.strip()
except AttributeError:
self.title = ''
return self.title
def set_status_code(self):
""" Check the HTTP status of the url, as it might not exist for example """
try:
result = requests.head(self.url)
self.http_status = result.status_code
except requests.ConnectionError:
self.http_status = self.HTTP_CONNECTIONERROR
return self.http_status
def set_favicon(self):
""" Fetch favicon for the domain """
# http://codingclues.eu/2009/retrieve-the-favicon-for-any-url-thanks-to-google/
u = urlparse(self.url)
domain = u.netloc
# if file exists, don't re-download it
#response = requests.get('http://www.google.com/s2/favicons?domain=' + domain, stream=True)
fileextension = '.png'
meta = requests.head('http://icons.better-idea.org/icon?size=60&url=' + domain, allow_redirects=True)
if meta.url[-3:].lower() == 'ico':
fileextension = '.ico'
response = requests.get('http://icons.better-idea.org/icon?size=60&url=' + domain, stream=True)
filename = os.path.join(MEDIA_ROOT, 'favicons/' + domain + fileextension)
with open(filename, 'wb') as out_file:
shutil.copyfileobj(response.raw, out_file)
del response
filetype = file_type(filename)
if filetype == 'gz':
# decompress
orig = gzip.GzipFile(filename, 'rb')
origcontent = orig.read()
orig.close()
os.remove(filename)
new = file(filename, 'wb')
new.write(origcontent)
new.close()
self.favicon = domain + fileextension
def set_tags(self, tags):
""" Set tags from `tags`, strip and sort them """
tags_split = tags.split(',')
tags_clean = clean_tags(tags_split)
self.tags = ','.join(tags_clean)
def get_redirect_uri(self):
if self.redirect_uri:
return self.redirect_uri
if self.http_status == 301 or self.http_status == 302:
result = requests.head(self.url, allow_redirects=True)
self.http_status = result.status_code
self.redirect_uri = result.url
return result.url
else:
return None
def get_uri_domain(self):
parsed = urlparse(self.url)
return parsed.hostname
@classmethod
def strip_url_params(cls, url):
parsed = urlparse(url)
return urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, '', parsed.fragment))
@property
def tags_list(self):
""" Get the tags as a list, iterable in template """
if self.tags:
return self.tags.split(',')
else:
return []
def to_dict(self):
result = {
'title': self.title,
'url': self.url,
'created': self.created_date.strftime('%Y-%m-%d %H:%M:%S'),
'url_hash': self.url_hash,
'tags': self.tags,
}
return result
class PublicTag(db.Model):
""" Publicly shared tag """
tagkey = CharField()
userkey = CharField()
tag = CharField()
created_date = DateTimeField(default=datetime.datetime.now)
def generate_key(self):
""" Generate hash-based key for publicly shared tag """
self.tagkey = os.urandom(16).encode('hex')
def get_tags_for_user(userkey):
""" Extract all tags from the bookmarks """
bookmarks = Bookmark.select().filter(Bookmark.userkey == userkey, Bookmark.status == Bookmark.VISIBLE)
tags = []
for bookmark in bookmarks:
tags += bookmark.tags_list
return clean_tags(tags)
def get_cached_tags(userkey):
""" Fail-safe way to get the cached tags for `userkey` """
try:
return all_tags[userkey]
except KeyError:
return []
def get_theme(userkey):
try:
usertheme = settings[userkey]['theme']
return themes[usertheme]
except KeyError:
return themes[DEFAULT_THEME] # default
def make_external(url):
return urljoin(request.url_root, url)
@app.errorhandler(404)
def page_not_found(e):
return render_template('404.html', error=e), 404
@app.route('/')
def index():
""" Homepage, point visitors to project page """
theme = themes[DEFAULT_THEME]
return render_template('index.html', theme=theme)
@app.route('/<userkey>', methods=['GET', 'POST'])
@app.route('/<userkey>/filter/<filtermethod>', methods=['GET', 'POST'])
@app.route('/<userkey>/sort/<sortmethod>', methods=['GET', 'POST'])
def bookmarks(userkey, filtermethod = None, sortmethod = None):
""" User homepage, list their bookmarks, optionally filtered and/or sorted """
#return object_list('bookmarks.html', Bookmark.select())
#user = User.select(key=userkey)
#if user:
# bookmarks = Bookmark.select(User=user)
# return render_template('bookmarks.html', bookmarks)
#else:
# abort(404)
message = request.args.get('message')
tags = get_cached_tags(userkey)
filter_text = ''
if request.form:
filter_text = request.form['filter_text']
filter_starred = False
if filtermethod and filtermethod.lower() == 'starred':
filter_starred = True
filter_broken = False
if filtermethod and filtermethod.lower() == 'broken':
filter_broken = True
filter_note = False
if filtermethod and filtermethod.lower() == 'note':
filter_note = True
if filter_text:
bookmarks = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.title.contains(filter_text),
Bookmark.status == Bookmark.VISIBLE).order_by(Bookmark.created_date.desc())
elif filter_starred:
bookmarks = Bookmark.select().where(Bookmark.userkey == userkey,
Bookmark.starred == True).order_by(Bookmark.created_date.desc())
elif filter_broken:
bookmarks = Bookmark.select().where(Bookmark.userkey == userkey,
Bookmark.http_status != 200).order_by(Bookmark.created_date.desc())
elif filter_note:
bookmarks = Bookmark.select().where(Bookmark.userkey == userkey,
Bookmark.note != '').order_by(Bookmark.created_date.desc())
else:
bookmarks = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.status == Bookmark.VISIBLE).order_by(Bookmark.created_date.desc())
theme = get_theme(userkey)
return render_template('bookmarks.html', bookmarks=bookmarks, userkey=userkey, tags=tags, filter_text=filter_text, message=message, theme=theme)
#@app.route('/<userkey>/<urlhash>')
#def viewbookmark(userkey, urlhash):
# """ Bookmark detail view """
# bookmark = Bookmark.select(Bookmark.url_hash == urlhash, Bookmark.userkey == userkey)
# return render_template('viewbookmark.html', userkey=userkey, bookmark=bookmark)
@app.route('/<userkey>/<urlhash>/json')
def viewbookmarkjson(userkey, urlhash):
""" Serialise bookmark to json """
bookmark = Bookmark.select(Bookmark.url_hash == urlhash, Bookmark.userkey == userkey, Bookmark.status == Bookmark.VISIBLE)[0]
return jsonify(bookmark.to_dict())
@app.route('/<userkey>/<urlhash>')
@app.route('/<userkey>/<urlhash>/edit')
def editbookmark(userkey, urlhash):
""" Bookmark edit form """
# bookmark = getbyurlhash()
try:
bookmark = Bookmark.get(Bookmark.url_hash == urlhash, Bookmark.userkey == userkey)
except Bookmark.DoesNotExist:
abort(404)
message = request.args.get('message')
tags = get_cached_tags(userkey)
if not bookmark.note:
# Workaround for when an existing bookmark has a null note
bookmark.note = ''
theme = get_theme(userkey)
return render_template('edit.html', action='Edit bookmark', userkey=userkey, bookmark=bookmark, message=message, formaction='edit', tags=tags, theme=theme)
@app.route('/<userkey>/add')
def addbookmark(userkey):
""" Bookmark add form """
url = request.args.get('url')
if not url:
url = ''
if request.args.get('referrer'):
url = request.referrer
bookmark = Bookmark(title='', url=url, tags='')
message = request.args.get('message')
tags = get_cached_tags(userkey)
theme = get_theme(userkey)
return render_template('edit.html', action='Add bookmark', userkey=userkey, bookmark=bookmark, tags=tags, message=message, theme=theme)
def updatebookmark(userkey, request, urlhash = None):
""" Add (no urlhash) or edit (urlhash is set) a bookmark """
title = request.form.get('title')
url = request.form.get('url')
tags = request.form.get('tags')
note = request.form.get('note')
starred = False
if request.form.get('starred'):
starred = True
strip_params = False
if request.form.get('strip'):
strip_params = True
if url and not urlhash:
# New bookmark
bookmark, created = Bookmark.get_or_create(url=url, userkey=userkey)
if not created:
message = 'Existing bookmark, did not overwrite with new values'
return redirect(url_for('editbookmark', userkey=userkey, urlhash=bookmark.url_hash, message=message))
elif url:
# Existing bookmark, get from DB
bookmark = Bookmark.get(Bookmark.userkey == userkey, Bookmark.url_hash == urlhash)
# Editing this bookmark, set modified_date to now
bookmark.modified_date = datetime.datetime.now()
else:
# No url was supplied, abort. @TODO: raise exception?
return None
bookmark.title = title
if strip_params:
url = Bookmark.strip_url_params(url)
bookmark.url = url
bookmark.starred = starred
bookmark.set_tags(tags)
bookmark.note = note
bookmark.set_hash()
#bookmark.fetch_image()
if not title:
# Title was empty, automatically fetch it from the url, will also update the status code
bookmark.set_title_from_source()
else:
bookmark.set_status_code()
if bookmark.http_status == 200:
try:
bookmark.set_favicon()
except IOError:
# Icon file could not be saved possibly, don't bail completely
pass
bookmark.save()
return bookmark
@app.route('/<userkey>/adding', methods=['GET', 'POST'])
#@app.route('/<userkey>/adding')
def addingbookmark(userkey):
""" Add the bookmark from form submit by /add """
tags = get_cached_tags(userkey)
if request.method == 'POST':
bookmark = updatebookmark(userkey, request)
if not bookmark:
return redirect(url_for('addbookmark', userkey=userkey, message='No url provided', tags=tags))
if type(bookmark).__name__ == 'Response':
return bookmark
all_tags[userkey] = get_tags_for_user(userkey)
return redirect(url_for('editbookmark', userkey=userkey, urlhash=bookmark.url_hash))
return redirect(url_for('addbookmark', userkey=userkey, tags=tags))
@app.route('/<userkey>/<urlhash>/editing', methods=['GET', 'POST'])
def editingbookmark(userkey, urlhash):
""" Edit the bookmark from form submit """
if request.method == 'POST':
bookmark = updatebookmark(userkey, request, urlhash=urlhash)
all_tags[userkey] = get_tags_for_user(userkey)
return redirect(url_for('editbookmark', userkey=userkey, urlhash=bookmark.url_hash))
return redirect(url_for('editbookmark', userkey=userkey, urlhash=urlhash))
@app.route('/<userkey>/<urlhash>/delete', methods=['GET', 'POST'])
def deletingbookmark(userkey, urlhash):
""" Delete the bookmark from form submit by <urlhash>/delete """
query = Bookmark.update(status=Bookmark.DELETED).where(Bookmark.userkey==userkey, Bookmark.url_hash==urlhash)
query.execute()
query = Bookmark.update(deleted_date = datetime.datetime.now()).where(Bookmark.userkey==userkey, Bookmark.url_hash==urlhash)
query.execute()
message = 'Bookmark deleted. <a href="{}">Undo deletion</a>'.format(url_for('undeletebookmark', userkey=userkey, urlhash=urlhash))
all_tags[userkey] = get_tags_for_user(userkey)
return redirect(url_for('bookmarks', userkey=userkey, message=message))
@app.route('/<userkey>/<urlhash>/undelete')
def undeletebookmark(userkey, urlhash):
""" Undo deletion of the bookmark identified by urlhash """
query = Bookmark.update(status=Bookmark.VISIBLE).where(Bookmark.userkey==userkey, Bookmark.url_hash==urlhash)
query.execute()
message = 'Bookmark restored'
all_tags[userkey] = get_tags_for_user(userkey)
return redirect(url_for('bookmarks', userkey=userkey, message=message))
@app.route('/<userkey>/tags')
def tags(userkey):
""" Overview of all tags used by user """
tags = get_cached_tags(userkey)
#publictags = PublicTag.select().where(Bookmark.userkey == userkey)
alltags = []
for tag in tags:
try:
publictag = PublicTag.get(PublicTag.userkey == userkey, PublicTag.tag == tag)
except PublicTag.DoesNotExist:
publictag = None
total = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.tags.contains(tag), Bookmark.status == Bookmark.VISIBLE).count()
alltags.append({'tag': tag, 'publictag': publictag, 'total': total})
totaltags = len(alltags)
totalbookmarks = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.status == Bookmark.VISIBLE).count()
totalpublic = PublicTag.select().where(PublicTag.userkey == userkey).count()
totalstarred = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.starred == True).count()
totaldeleted = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.status == Bookmark.DELETED).count()
totalnotes = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.note != '').count()
totalhttperrorstatus = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.http_status != 200).count()
theme = get_theme(userkey)
return render_template('tags.html', tags=alltags, totaltags=totaltags, totalpublic=totalpublic, totalbookmarks=totalbookmarks,
totaldeleted=totaldeleted, totalstarred=totalstarred, totalhttperrorstatus=totalhttperrorstatus,
totalnotes=totalnotes, userkey=userkey, theme=theme)
@app.route('/<userkey>/tag/<tag>')
def tag(userkey, tag):
""" Overview of all bookmarks with a certain tag """
bookmarks = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.tags.contains(tag), Bookmark.status == Bookmark.VISIBLE).order_by(Bookmark.created_date.desc())
tags = get_cached_tags(userkey)
pageheader = 'tag: ' + tag
message = request.args.get('message')
try:
publictag = PublicTag.get(PublicTag.userkey == userkey, PublicTag.tag == tag)
except PublicTag.DoesNotExist:
publictag = None
theme = get_theme(userkey)
return render_template('bookmarks.html', bookmarks=bookmarks, userkey=userkey, tags=tags, tag=tag, publictag=publictag, action=pageheader,
message=message, theme=theme)
@app.route('/pub/<tagkey>')
def publictag(tagkey):
""" Read-only overview of the bookmarks in the userkey/tag of this PublicTag """
#this_tag = get_object_or_404(PublicTag.select().where(PublicTag.tagkey == tagkey))
try:
this_tag = PublicTag.get(PublicTag.tagkey == tagkey)
bookmarks = Bookmark.select().where(Bookmark.userkey == this_tag.userkey, Bookmark.tags.contains(this_tag.tag), Bookmark.status == Bookmark.VISIBLE).order_by(Bookmark.created_date.desc())
theme = themes[DEFAULT_THEME]
return render_template('publicbookmarks.html', bookmarks=bookmarks, tag=tag, action=this_tag.tag, tagkey=tagkey, theme=theme)
except PublicTag.DoesNotExist:
abort(404)
@app.route('/pub/<tagkey>/json')
def publictagjson(tagkey):
""" json representation of the Read-only overview of the bookmarks in the userkey/tag of this PublicTag """
try:
this_tag = PublicTag.get(PublicTag.tagkey == tagkey)
bookmarks = Bookmark.select().where(Bookmark.userkey == this_tag.userkey, Bookmark.tags.contains(this_tag.tag), Bookmark.status == Bookmark.VISIBLE)
result = {'count': len(bookmarks), 'items': []}
for bookmark in bookmarks:
result['items'].append(bookmark.to_dict())
return jsonify(result)
except PublicTag.DoesNotExist:
abort(404)
@app.route('/pub/<tagkey>/feed')
def publictagfeed(tagkey):
""" rss/atom representation of the Read-only overview of the bookmarks in the userkey/tag of this PublicTag """
try:
this_tag = PublicTag.get(PublicTag.tagkey == tagkey)
bookmarks = Bookmark.select().where(Bookmark.userkey == this_tag.userkey, Bookmark.tags.contains(this_tag.tag), Bookmark.status == Bookmark.VISIBLE).limit(15)
feed = AtomFeed(this_tag.tag, feed_url=request.url, url=make_external(url_for('publictag', tagkey=tagkey)))
for bookmark in bookmarks:
updated_date = bookmark.modified_date
if not bookmark.modified_date:
updated_date = bookmark.created_date
feed.add(bookmark.title,
content_type='html',
author='digimarks',
url=bookmark.url,
updated=updated_date,
published=bookmark.created_date)
return feed.get_response()
except PublicTag.DoesNotExist:
abort(404)
@app.route('/<userkey>/<tag>/makepublic', methods=['GET', 'POST'])
def addpublictag(userkey, tag):
#user = get_object_or_404(User.get(User.key == userkey))
try:
User.get(User.key == userkey)
except User.DoesNotExist:
abort(404)
try:
publictag = PublicTag.get(PublicTag.userkey == userkey, PublicTag.tag == tag)
except PublicTag.DoesNotExist:
publictag = None
if not publictag:
newpublictag = PublicTag()
newpublictag.generate_key()
newpublictag.userkey = userkey
newpublictag.tag = tag
newpublictag.save()
message = 'Public link to this tag created'
return redirect(url_for('tag', userkey=userkey, tag=tag, message=message))
else:
message = 'Public link already existed'
return redirect(url_for('tag', userkey=userkey, tag=tag, message=message))
@app.route('/<userkey>/<tag>/removepublic/<tagkey>', methods=['GET', 'POST'])
def removepublictag(userkey, tag, tagkey):
q = PublicTag.delete().where(PublicTag.userkey == userkey, PublicTag.tag == tag, PublicTag.tagkey == tagkey)
q.execute()
message = 'Public link deleted'
return redirect(url_for('tag', userkey=userkey, tag=tag, message=message))
@app.route('/<systemkey>/adduser')
def adduser(systemkey):
""" Add user endpoint, convenience """
if systemkey == settings.SYSTEMKEY:
newuser = User()
newuser.generate_key()
newuser.username = 'Nomen Nescio'
newuser.save()
all_tags[newuser.key] = []
return redirect('/' + newuser.key, code=302)
else:
abort(404)
@app.route('/<systemkey>/refreshfavicons')
def refreshfavicons(systemkey):
""" Add user endpoint, convenience """
if systemkey == settings.SYSTEMKEY:
bookmarks = Bookmark.select()
for bookmark in bookmarks:
if bookmark.favicon:
try:
filename = os.path.join(MEDIA_ROOT, 'favicons/' + bookmark.favicon)
os.remove(filename)
except OSError as e:
print(e)
bookmark.set_favicon()
return redirect('/')
else:
abort(404)
# Initialisation == create the bookmark, user and public tag tables if they do not exist
Bookmark.create_table(True)
User.create_table(True)
PublicTag.create_table(True)
users = User.select()
print('Current user keys:')
for user in users:
all_tags[user.key] = get_tags_for_user(user.key)
settings[user.key] = {'theme': user.theme}
print(user.key)
# Run when called standalone
if __name__ == '__main__':
# run the application
app.run(port=9999, debug=True)

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:

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.

91
migrations/env.py Normal file
View File

@@ -0,0 +1,91 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from sqlmodel import SQLModel
from src.digimarks.models import Bookmark, PublicTag, User
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = SQLModel.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option('sqlalchemy.url')
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={'paramstyle': 'named'},
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine and associate a connection with the context."""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

29
migrations/script.py.mako Normal file
View File

@@ -0,0 +1,29 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,66 @@
"""Initial migration
Revision ID: 115bcd2e1a38
Revises:
Create Date: 2025-09-12 16:06:16.479075
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '115bcd2e1a38'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('bookmark',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('userkey', sa.String(length=255), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('url', sa.String(length=255), nullable=False),
sa.Column('created_date', sa.DateTime(), nullable=False),
sa.Column('url_hash', sa.String(length=255), nullable=False),
sa.Column('tags', sa.String(length=255), nullable=False),
sa.Column('http_status', sa.Integer(), nullable=False),
sa.Column('modified_date', sa.DateTime(), nullable=True),
sa.Column('favicon', sa.String(length=255), nullable=True),
sa.Column('starred', sa.Boolean(), server_default=sa.text('0'), nullable=True),
sa.Column('deleted_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True),
sa.Column('status', sa.Integer(), server_default=sa.text('0'), nullable=True),
sa.Column('note', sa.Text(), server_default=sa.text('(null)'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('publictag',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tagkey', sa.String(length=255), nullable=False),
sa.Column('userkey', sa.String(length=255), nullable=False),
sa.Column('tag', sa.String(length=255), nullable=False),
sa.Column('created_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=255), nullable=False),
sa.Column('key', sa.String(length=255), nullable=False),
sa.Column('created_date', sa.DateTime(), nullable=False),
sa.Column('theme', sa.String(length=20), server_default=sa.text("'green'"), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user')
op.drop_table('publictag')
op.drop_table('bookmark')
# ### end Alembic commands ###

View File

@@ -0,0 +1,95 @@
"""Migrate to sqlmodel.
Revision ID: a8d8e45f60a1
Revises: 115bcd2e1a38
Create Date: 2025-09-12 16:10:41.378716
"""
from typing import Sequence, Union
from alembic import op
from datetime import UTC, datetime
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = 'a8d8e45f60a1'
down_revision: Union[str, Sequence[str], None] = '115bcd2e1a38'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('bookmark', schema=None) as batch_op:
batch_op.alter_column('note',
existing_type=sa.TEXT(),
type_=sqlmodel.sql.sqltypes.AutoString(),
nullable=True,
existing_server_default=sa.text('(null)'))
batch_op.alter_column('starred',
existing_type=sa.BOOLEAN(),
nullable=False,
existing_server_default=sa.text('0'))
batch_op.alter_column('modified_date',
existing_type=sa.DATETIME(),
nullable=True)
batch_op.alter_column('deleted_date',
existing_type=sa.DATETIME(),
nullable=True,
existing_server_default=sa.text('(null)'))
batch_op.alter_column('status',
existing_type=sa.INTEGER(),
nullable=False,
existing_server_default=sa.text('0'))
batch_op.create_foreign_key('bookmark_user', 'user', ['userkey'], ['key'])
with op.batch_alter_table('publictag', schema=None) as batch_op:
batch_op.alter_column('created_date',
existing_type=sa.DATETIME(),
nullable=True,
existing_server_default=sa.text(str(datetime.now(UTC))))
batch_op.create_foreign_key('publictag_user', 'user', ['userkey'], ['key'])
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.alter_column('theme',
existing_type=sa.VARCHAR(length=20),
nullable=False,
existing_server_default=sa.text("'green'"))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('user', 'theme',
existing_type=sa.VARCHAR(length=20),
nullable=True,
existing_server_default=sa.text("'green'"))
op.drop_constraint(None, 'publictag', type_='foreignkey')
op.alter_column('publictag', 'created_date',
existing_type=sa.DATETIME(),
nullable=True,
existing_server_default=sa.text('(null)'))
op.drop_constraint(None, 'bookmark', type_='foreignkey')
op.alter_column('bookmark', 'status',
existing_type=sa.INTEGER(),
nullable=True,
existing_server_default=sa.text('0'))
op.alter_column('bookmark', 'deleted_date',
existing_type=sa.DATETIME(),
nullable=True,
existing_server_default=sa.text('(null)'))
op.alter_column('bookmark', 'modified_date',
existing_type=sa.DATETIME(),
nullable=True)
op.alter_column('bookmark', 'starred',
existing_type=sa.BOOLEAN(),
nullable=True,
existing_server_default=sa.text('0'))
op.alter_column('bookmark', 'note',
existing_type=sqlmodel.sql.sqltypes.AutoString(),
type_=sa.TEXT(),
nullable=True,
existing_server_default=sa.text('(null)'))
# ### end Alembic commands ###

View File

@@ -0,0 +1,53 @@
"""Renamed keys
Revision ID: b8cbc6957df5
Revises: a8d8e45f60a1
Create Date: 2025-09-12 22:26:38.684120
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = 'b8cbc6957df5'
down_revision: Union[str, Sequence[str], None] = 'a8d8e45f60a1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('bookmark', schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f('bookmark_user'), type_='foreignkey')
batch_op.alter_column('userkey', new_column_name='user_key')
batch_op.create_foreign_key('bookmark_user', 'user', ['user_key'], ['key'])
with op.batch_alter_table('publictag', schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f('publictag_user'), type_='foreignkey')
batch_op.alter_column('userkey', new_column_name='user_key')
batch_op.alter_column('tagkey', new_column_name='tag_key')
batch_op.create_foreign_key('publictag_user', 'user', ['user_key'], ['key'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('publictag', schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f('publictag_user'), type_='foreignkey')
batch_op.alter_column('user_key', new_column_name='userkey')
batch_op.alter_column('tag_key', new_column_name='tagkey')
batch_op.create_foreign_key('publictag_user', 'user', ['userkey'], ['key'])
with op.batch_alter_table('bookmark', schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f('bookmark_user'), type_='foreignkey')
batch_op.alter_column('user_key', new_column_name='userkey')
batch_op.create_foreign_key('bookmark_user', 'user', ['userkey'], ['key'])
# ### end Alembic commands ###

22
pylintrc 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,
_

100
pyproject.toml Normal file
View File

@@ -0,0 +1,100 @@
[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.10"
keywords = ["bookmarks", "api"]
license = { text = "Apache" }
classifiers = [
"Framework :: FastAPI",
"Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache Software License",
]
dependencies = [
"fastapi[all]",
"sqlmodel",
"alembic",
"aiosqlite",
"pydantic>2.0",
"httpx",
"beautifulsoup4",
"extract_favicon",
"feedgen",
]
[dependency-groups]
dev = [
{include-group = "lint"},
{include-group = "pub"},
{include-group = "test"}
]
test = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
]
lint = [
"ruff>=0.1.0",
"mypy>=1.0.0",
]
# Publishing on PyPI
pub = [
"build",
"twine"
]
server = [
"gunicorn>=23.0.0",
]
# dynamic = ["version"]
[project.scripts]
my-script = "digimarks:app"
[project.urls]
"Homepage" = "https://github.com/aquatix/digimarks"
"Bug Tracker" = "https://github.com/aquatix/digimarks/issues"
[tool.black]
line-length = 120
[tool.ruff]
exclude = [
".git",
"__pycache__",
"docs/source/conf.py",
"build",
"dist",
"example_config/gunicorn_digimarks_conf.py",
"example_config/settings.py",
]
line-length = 120
[tool.ruff.format]
# Prefer single quotes over double quotes
quote-style = "single"
[tool.ruff.lint]
ignore = ["D203", "D213"]
select = [
"C9",
"D",
"E",
"F",
"I",
"W",
]
[tool.ruff.lint.flake8-quotes]
docstring-quotes = "double"
inline-quotes = "single"
multiline-quotes = "double"
[tool.ruff.lint.mccabe]
max-complexity = 10

11
requirements-dev.in Normal file
View File

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

11
requirements-dev.txt Normal file
View File

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

3
requirements-server.in Normal file
View File

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

View File

@@ -1,7 +1,20 @@
pkg-resources==0.0.0
# Core application
fastapi[all]
sqlmodel
sqlalchemy
pydantic
pydantic_settings
alembic
aiosqlite
flask
peewee
flask-peewee
bs4
requests
# Fetch external resources
httpx
# Fetch title etc from links
beautifulsoup4
# Fetch favicons
extract_favicon
# Generate (atom) feeds for tags and such
feedgen

View File

@@ -1,24 +1,20 @@
#
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile --output-file requirements.txt requirements.in
#
beautifulsoup4==4.6.0 # via bs4
bs4==0.0.1
certifi==2017.4.17 # via requests
chardet==3.0.4 # via requests
click==6.7 # via flask
flask-peewee==0.6.7
flask==0.12.2
idna==2.5 # via requests
itsdangerous==0.24 # via flask
jinja2==2.9.6 # via flask, flask-peewee
markupsafe==1.0 # via jinja2
peewee==2.10.1
pkg-resources==0.0.0
requests==2.18.1
urllib3==1.21.1 # via requests
werkzeug==0.12.2 # via flask, flask-peewee
wtf-peewee==0.2.6 # via flask-peewee
wtforms==2.1 # via flask-peewee, wtf-peewee
# Core application
fastapi[all]
sqlmodel
sqlalchemy
pydantic
pydantic_settings
alembic
aiosqlite
# Fetch external resources
httpx
# Fetch title etc from links
beautifulsoup4
# Fetch favicons
extract_favicon
# Generate (atom) feeds for tags and such
feedgen

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
from os import path
import setuptools
here = path.abspath(path.dirname(__file__))
# Get the long description from the relevant file
with open(path.join(here, 'README.rst'), encoding='utf-8') as f:
long_description = f.read()
setup(
name='digimarks', # pip install digimarks
description='Simple bookmarking service, using a SQLite database to store bookmarks, supporting tags and automatic title fetching.',
#long_description=open('README.md', 'rt').read(),
long_description=long_description,
# version
# third part for minor release
# second when api changes
# first when it becomes stable someday
version='1.1.0',
author='Michiel Scholten',
author_email='michiel@diginaut.net',
url='https://github.com/aquatix/digimarks',
license='Apache',
# as a practice no need to hard code version unless you know program wont
# work unless the specific versions are used
install_requires=['Flask', 'Peewee', 'Flask-Peewee', 'requests'],
py_modules=['digimarks'],
zip_safe=True,
)
if __name__ == "__main__":
setuptools.setup()

View File

View File

@@ -0,0 +1,214 @@
"""Bookmark helper functions, like content scrapers, favicon extractor, updater functions."""
import logging
from collections.abc import Sequence
from datetime import UTC, datetime
from typing import Annotated
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 sqlmodel import select
DIGIMARKS_USER_AGENT = 'digimarks/2.0.0-dev'
logger = logging.getLogger('digimarks')
def get_favicon(html_content: str, root_url: str) -> str:
"""Fetch the favicon from `html_content` using `root_url`."""
favicons = from_html(html_content, root_url=root_url, include_fallbacks=True)
for favicon in favicons:
print(favicon.url, favicon.width, favicon.height)
# TODO: save the preferred image to file and return
async def set_information_from_source(bookmark: Bookmark, request: Request) -> Bookmark:
"""Request the title by requesting the source url."""
logger.info('Extracting information from url %s', bookmark.url)
try:
result = await request.app.requests_client.get(bookmark.url, headers={'User-Agent': DIGIMARKS_USER_AGENT})
bookmark.http_status = result.status_code
except httpx.HTTPError as err:
# For example, "MissingSchema: Invalid URL 'abc': No schema supplied. Perhaps you meant http://abc?"
logger.error('Exception when trying to retrieve title for %s. Error: %s', bookmark.url, str(err))
bookmark.http_status = 404
bookmark.title = ''
return bookmark
if bookmark.http_status == 200 or bookmark.http_status == 202:
html = bs4.BeautifulSoup(result.text, 'html.parser')
try:
bookmark.title = html.title.text.strip()
except AttributeError:
bookmark.title = ''
url_parts = urlparse(str(bookmark.url))
root_url = url_parts.scheme + '://' + url_parts.netloc
favicon = get_favicon(result.text, root_url)
# filename = os.path.join(settings.media_dir, 'favicons/', domain + file_extension)
# with open(filename, 'wb') as out_file:
# shutil.copyfileobj(response.raw, out_file)
# Extraction was successful
logger.info('Extracting information was successful')
return bookmark
def strip_url_params(url: str) -> str:
"""Strip URL params from URL.
:param url: URL to strip URL params from.
:return: clean URL
:rtype: str
"""
parsed = urlparse(url)
return urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, '', parsed.fragment))
def update_bookmark_with_info(bookmark: Bookmark, request: Request, strip_params: bool = False):
"""Automatically update title, favicon, etc."""
if not bookmark.title:
# Title was empty, automatically fetch it from the url, will also update the status code
set_information_from_source(bookmark, request)
if strip_params:
# Strip URL parameters, e.g., tracking params
bookmark.url = AnyUrl(strip_url_params(str(bookmark.url)))
# Sort and deduplicate tags
tags_service.set_tags(bookmark, bookmark.tags)
async def list_bookmarks_for_user(
session,
user_key: str,
offset: int = 0,
limit: Annotated[int, Query(le=10000)] = 100,
) -> Sequence[Bookmark]:
"""List all bookmarks in the database. By default, 100 items are returned."""
result = await session.exec(
select(Bookmark)
.where(Bookmark.user_key == user_key, Bookmark.status != Visibility.DELETED)
.offset(offset)
.limit(limit)
)
bookmarks = result.all()
return bookmarks
async def get_bookmark_for_user_with_url_hash(session, user_key: str, url_hash: str) -> Bookmark:
"""Get a bookmark from the database by its URL hash."""
result = await session.exec(
select(Bookmark).where(
Bookmark.user_key == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
)
)
if not result.first():
raise BookmarkNotFound(f'url_hash: {url_hash}')
return result.first()
async def autocomplete_bookmark(
session,
request: Request,
user_key: str,
bookmark: Bookmark,
strip_params: bool = False,
):
"""Autofill some fields for this (new) bookmark for user `user_key`."""
bookmark.user_key = user_key
# Auto-fill title, fix tags etc.
update_bookmark_with_info(bookmark, request, strip_params)
url_hash = utils.generate_hash(str(bookmark.url))
result = await session.exec(
select(Bookmark).where(
Bookmark.user_key == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
)
)
bookmark_db = result.first()
if bookmark_db:
# Bookmark with this URL already exists, provide the hash so the frontend can look it up and the user can
# merge them if so wanted
bookmark.url_hash = url_hash
return bookmark
async def add_bookmark(
session,
request: Request,
user_key: str,
bookmark: Bookmark,
strip_params: bool = False,
):
"""Add new bookmark for user `user_key`."""
bookmark.user_key = user_key
# Auto-fill title, fix tags etc.
update_bookmark_with_info(bookmark, request, strip_params)
bookmark.url_hash = utils.generate_hash(str(bookmark.url))
logger.info('Adding bookmark %s for user %s', bookmark.url_hash, user_key)
session.add(bookmark)
await session.commit()
await session.refresh(bookmark)
return bookmark
async def update_bookmark(
session,
request: Request,
user_key: str,
bookmark: Bookmark,
url_hash: str,
strip_params: bool = False,
):
"""Update existing bookmark `bookmark_key` for user `user_key`."""
result = await session.exec(
select(Bookmark).where(
Bookmark.user_key == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
)
)
bookmark_db = result.first()
if not bookmark_db:
raise BookmarkNotFound(message='Bookmark with hash {url_hash} not found')
bookmark.modified_date = datetime.now(UTC)
# 'patch' endpoint, which means that you can send only the data that you want to update, leaving the rest intact
bookmark_data = bookmark.model_dump(exclude_unset=True)
# Merge the changed fields into the existing object
bookmark_db.sqlmodel_update(bookmark_data)
# Autofill title, fix tags, etc. where (still) needed
update_bookmark_with_info(bookmark, request, strip_params)
session.add(bookmark_db)
await session.commit()
await session.refresh(bookmark_db)
return bookmark_db
async def delete_bookmark(
session,
user_key: str,
url_hash: str,
):
"""(Soft)Delete bookmark `bookmark_key` for user `user_key`."""
result = await session.get(Bookmark, {'url_hash': url_hash, 'user_key': user_key})
bookmark = result
if not bookmark:
raise BookmarkNotFound(message='Bookmark with hash {url_hash} not found')
bookmark.deleted_date = datetime.now(UTC)
bookmark.status = Visibility.DELETED
session.add(bookmark)
await session.commit()

View File

@@ -0,0 +1,25 @@
"""Exceptions that could be encountered managing digimarks."""
class BookmarkNotFound(Exception):
"""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: str = message
class BookmarkAlreadyExists(Exception):
"""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: str = message

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

@@ -0,0 +1,361 @@
"""digimarks main module."""
import logging
from collections.abc import Sequence
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from typing import Annotated
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
from sqlalchemy.orm import sessionmaker
from sqlmodel import desc, select
from sqlmodel.ext.asyncio.session import AsyncSession
DIGIMARKS_VERSION = '2.0.0a1'
class Settings(BaseSettings):
"""Configuration needed for digimarks to find its database, favicons, API integrations."""
# outside the codebase
database_file: FilePath
favicons_dir: DirectoryPath
# inside the codebase
static_dir: DirectoryPath = 'static'
template_dir: DirectoryPath = 'templates'
media_url: str = '/static/'
system_key: str
debug: bool = False
settings = Settings()
print(settings.model_dump())
engine = create_async_engine(f'sqlite+aiosqlite:///{settings.database_file}', connect_args={'check_same_thread': False})
async def get_session() -> AsyncSession:
"""SQLAlchemy session factory."""
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
yield session
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@asynccontextmanager
async def lifespan(the_app: FastAPI):
"""Upon start, initialise an AsyncClient and assign it to an attribute named requests_client on the app object."""
the_app.requests_client = httpx.AsyncClient()
yield
await the_app.requests_client.aclose()
app = FastAPI(lifespan=lifespan)
app.mount('/static', StaticFiles(directory=settings.static_dir), name='static')
app.mount('/content/favicons', StaticFiles(directory=settings.favicons_dir), name='favicons')
templates = Jinja2Templates(directory=settings.template_dir)
# Set up logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
)
logger = logging.getLogger('digimarks')
if settings.debug:
logger.setLevel(logging.DEBUG)
# CORS configuration
app.add_middleware(
CORSMiddleware,
allow_origins=['*'], # Allow requests from everywhere
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
def file_type(filename: str) -> str:
"""Try to determine the file type for the file in `filename`.
:param str filename: path to file to check
:return: zip file type
:rtype: str
"""
magic_dict = {b'\x1f\x8b\x08': 'gz', b'\x42\x5a\x68': 'bz2', b'\x50\x4b\x03\x04': 'zip'}
max_len = max(len(x) for x in magic_dict)
with open(filename, 'rb') as f:
file_start = f.read(max_len)
for magic, filetype in magic_dict.items():
if file_start.startswith(magic):
return filetype
return 'no match'
@app.get('/', response_class=HTMLResponse)
@app.head('/', response_class=HTMLResponse)
def index(request: Request):
"""Homepage, point visitors to project page."""
logger.info('Root page requested')
return templates.TemplateResponse(
request=request,
name='index.html',
context={'language': 'en', 'version': DIGIMARKS_VERSION, 'theme': DEFAULT_THEME},
)
@app.get('/api/v1/admin/{system_key}/users/{user_id}', response_model=User)
async def get_user(session: SessionDep, system_key: str, user_id: int) -> type[User]:
"""Show user information."""
logger.info('User %d requested', user_id)
if system_key != settings.system_key:
logger.error('User %s requested but incorrect system key %s provided', user_id, system_key)
raise HTTPException(status_code=404)
result = await session.get(User, user_id)
user = result
if not user:
logger.error('User %s not found', user_id)
raise HTTPException(status_code=404, detail='User not found')
return user
# @app.get('/admin/{system_key}/users/', response_model=list[User])
@app.get('/api/v1/admin/{system_key}/users/')
async def list_users(
session: SessionDep,
system_key: str,
offset: int = 0,
limit: Annotated[int, Query(le=100)] = 100,
) -> Sequence[User]:
"""List all users in the database.
:param SessionDep session:
:param str system_key: secrit key
:param int offset: [Optional] offset of pagination
:param int limit: [Optional] limits the number of users to return, defaults to 100
:return: list of users in the system
:rtype: list[User]
"""
logger.info('User listing requested')
if system_key != settings.system_key:
logger.error('User listing requested but incorrect system key %s provided', system_key)
raise HTTPException(status_code=404)
result = await session.exec(select(User).offset(offset).limit(limit))
return result.all()
@app.get('/api/v1/{user_key}/bookmarks/')
async def list_bookmarks(
session: SessionDep,
user_key: str,
offset: int = 0,
limit: Annotated[int, Query(le=10000)] = 100,
) -> Sequence[Bookmark]:
"""List all bookmarks in the database. By default, 100 items are returned."""
logger.info('List bookmarks for user %s with offset %d, limit %d', user_key, offset, limit)
return await bookmarks_service.list_bookmarks_for_user(session, user_key, offset, limit)
@app.get('/api/v1/{user_key}/bookmarks/{url_hash}')
async def get_bookmark(
session: SessionDep,
user_key: str,
url_hash: str,
) -> Bookmark:
"""Show bookmark details."""
logger.info('Bookmark details for user %s with url_hash %s', user_key, url_hash)
try:
return await bookmarks_service.get_bookmark_for_user_with_url_hash(session, user_key, url_hash)
except BookmarkNotFound as exc:
logger.error('Bookmark not found: %s', exc)
raise HTTPException(status_code=404, detail=f'Bookmark not found: {exc.message}')
@app.post('/api/v1/{user_key}/autocomplete_bookmark/', response_model=Bookmark)
async def autocomplete_bookmark(
session: SessionDep,
request: Request,
user_key: str,
bookmark: Bookmark,
strip_params: bool = False,
):
"""Autofill some fields for this (new) bookmark for user `user_key`."""
logger.info('Autocompleting bookmark %s for user %s', bookmark.url_hash, user_key)
return await bookmarks_service.autocomplete_bookmark(session, request, user_key, bookmark, strip_params)
@app.post('/api/v1/{user_key}/bookmarks/', response_model=Bookmark)
async def add_bookmark(
session: SessionDep,
request: Request,
user_key: str,
bookmark: Bookmark,
strip_params: bool = False,
):
"""Add new bookmark for user `user_key`."""
logger.info('Adding bookmark %s for user %s', bookmark.url, user_key)
return await bookmarks_service.add_bookmark(session, request, user_key, bookmark, strip_params)
@app.patch('/api/v1/{user_key}/bookmarks/{url_hash}', response_model=Bookmark)
async def update_bookmark(
session: SessionDep,
request: Request,
user_key: str,
bookmark: Bookmark,
url_hash: str,
strip_params: bool = False,
):
"""Update existing bookmark `bookmark_key` for user `user_key`."""
logger.info('Updating bookmark %s for user %s', url_hash, user_key)
try:
return await bookmarks_service.update_bookmark(session, request, user_key, bookmark, url_hash, strip_params)
except Exception:
logger.exception('Failed to update bookmark %s', bookmark.id)
raise HTTPException(status_code=404, detail='Bookmark not found')
@app.delete('/api/v1/{user_key}/bookmarks/{url_hash}', response_model=Bookmark)
async def delete_bookmark(
session: SessionDep,
user_key: str,
url_hash: str,
):
"""(Soft)Delete bookmark `bookmark_key` for user `user_key`."""
logger.info('Deleting bookmark %s for user %s', url_hash, user_key)
try:
result = await bookmarks_service.delete_bookmark(session, user_key, url_hash)
return {'ok': True}
except Exception:
logger.exception('Failed to delete bookmark %s', url_hash)
raise HTTPException(status_code=404, detail='Bookmark not found')
@app.get('/api/v1/{user_key}/latest_changes/')
async def bookmarks_changed_since(
session: SessionDep,
user_key: str,
):
"""Last update on server, so the (browser) client knows whether to fetch an update."""
logger.info('Retrieving latest changes for user %s', user_key)
result = await session.exec(
select(Bookmark)
.where(Bookmark.user_key == user_key, Bookmark.status != Visibility.DELETED)
.order_by(desc(Bookmark.modified_date))
)
latest_modified_bookmark = result.first()
result = await session.exec(
select(Bookmark)
.where(Bookmark.user_key == user_key, Bookmark.status != Visibility.DELETED)
.order_by(desc(Bookmark.created_date))
)
latest_created_bookmark = result.first()
latest_modification = max(latest_modified_bookmark.modified_date, latest_created_bookmark.created_date)
return {
'current_time': datetime.now(UTC),
'latest_change': latest_modified_bookmark.modified_date,
'latest_created': latest_created_bookmark.created_date,
'latest_modification': latest_modification,
}
@app.get('/api/v1/{user_key}/tags/')
async def list_tags_for_user(
session: SessionDep,
user_key: str,
) -> list[str]:
"""List all tags in use by the user."""
return await tags_service.list_tags_for_user(session, user_key)
@app.get('/api/v1/{user_key}/tags/{tag_key}')
async def list_bookmarks_for_tag_for_user(
session: SessionDep,
user_key: str,
tag_key: str,
) -> list[str]:
"""List all tags in use by the user."""
logger.info('List bookmarks for tag "%s" by user %s', tag_key, user_key)
return await tags_service.list_bookmarks_for_tag_for_user(session, user_key, tag_key)
@app.get('/{user_key}', response_class=HTMLResponse)
async def page_user_landing(
session: SessionDep,
request: Request,
user_key: str,
):
"""HTML page with the main view for the user."""
result = await session.exec(select(User).where(User.key == user_key))
user = result.first()
if not user:
raise HTTPException(status_code=404, detail='User not found')
language = 'en'
return templates.TemplateResponse(
request=request,
name='user_index.html',
context={'language': language, 'version': DIGIMARKS_VERSION, 'user_key': user_key},
)
# def tags_page(userkey):
# """Overview of all tags used by user"""
# tags = get_cached_tags(userkey)
# alltags = []
# for tag in tags:
# try:
# publictag = PublicTag.get(PublicTag.userkey == userkey, PublicTag.tag == tag)
# except PublicTag.DoesNotExist:
# publictag = None
#
# total = (
# Bookmark.select()
# .where(Bookmark.userkey == userkey, Bookmark.tags.contains(tag), Bookmark.status == Bookmark.VISIBLE)
# .count()
# )
# alltags.append({'tag': tag, 'publictag': publictag, 'total': total})
# totaltags = len(alltags)
# totalbookmarks = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.status == Bookmark.VISIBLE).count()
# totalpublic = PublicTag.select().where(PublicTag.userkey == userkey).count()
# totalstarred = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.starred).count()
# totaldeleted = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.status == Bookmark.DELETED).count()
# totalnotes = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.note != '').count()
# totalhttperrorstatus = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.http_status != 200).count()
# theme = get_theme(userkey)
# return render_template(
# 'tags.html',
# tags=alltags,
# totaltags=totaltags,
# totalpublic=totalpublic,
# totalbookmarks=totalbookmarks,
# totaldeleted=totaldeleted,
# totalstarred=totalstarred,
# totalhttperrorstatus=totalhttperrorstatus,
# totalnotes=totalnotes,
# userkey=userkey,
# theme=theme,
# )

107
src/digimarks/models.py Normal file
View File

@@ -0,0 +1,107 @@
"""Models for digimarks.
Contains the bookmarks administration, users, tags, public tags and more.
"""
from datetime import UTC, datetime
from http import HTTPStatus
from typing import TypeVar
from pydantic import AnyUrl, computed_field
from sqlmodel import AutoString, Field, SQLModel
DEFAULT_THEME = 'freshgreen'
class User(SQLModel, table=True):
"""User account."""
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: 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]:
"""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) -> str | None:
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) -> T | None:
if value is None:
return None
return internal_type(value) # type: ignore[call-arg]
return CustomType
class Bookmark(SQLModel, table=True):
"""Bookmark object."""
id: int = Field(primary_key=True)
user_key: str = Field(foreign_key='user.key', nullable=False)
title: str = Field(default='', nullable=False)
url: AnyUrl = Field(default='', sa_type=build_custom_type(AnyUrl))
note: str = Field(default='', nullable=True)
# image: str = Field(default='')
url_hash: str = Field(default='', nullable=False)
tags: str = Field(default='')
starred: bool = Field(default=False)
favicon: str | None = Field(default=None)
http_status: int = Field(default=HTTPStatus.OK)
created_date: datetime = Field(default=datetime.now(UTC))
modified_date: datetime = Field(default=None, nullable=True)
deleted_date: datetime = Field(default=None, nullable=True)
status: int = Field(default=Visibility.VISIBLE)
@computed_field
@property
def tag_list(self) -> list[str]:
"""The tags but as a proper list."""
if self.tags:
return self.tags.split(',')
# Not tags, return empty list instead of [''] that split returns in that case
return []
class PublicTag(SQLModel, table=True):
"""Public tag object."""
id: int = Field(primary_key=True)
tag_key: str
user_key: str = Field(foreign_key='user.key')
tag: str
created_date: datetime = Field(default=datetime.now(UTC))

View File

@@ -0,0 +1,33 @@
/**
* 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;
}
#bookmarkEditForm fieldset {
border: none;
}
#bookmarkEditForm fieldset input, #bookmarkEditForm textarea, #bookmarkEditForm select, #bookmarkEditForm label {
width: 100%;
}

View File

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

View File

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

View File

View File

@@ -0,0 +1,263 @@
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({}).as('bookmarkToEdit'),
bookmarkToEditError: null,
/* 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);
/* 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(() => {
// 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;
},
resetEditBookmark() {
this.bookmarkToEdit = {
'url': '',
'title': '',
'note': '',
'tags': ''
}
},
async startAddingBookmark() {
/* Open 'add bookmark' page */
console.log('Start adding bookmark');
this.resetEditBookmark();
// this.show_bookmark_details = true;
const editFormDialog = document.getElementById("editFormDialog");
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/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
// Bookmark form data
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(data);
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 */
//
}
})
});

View File

@@ -0,0 +1,101 @@
"""Helper functions for tags used with Bookmark models."""
from models import Bookmark, Visibility
from sqlalchemy import Sequence
from sqlmodel import select
def i_filter_false(predicate, iterable):
"""Filter an iterable if predicate returns True.
i_filter_false(lambda x: x%2, range(10)) --> 0 2 4 6 8
"""
if predicate is None:
predicate = bool
for x in iterable:
if not predicate(x):
yield x
def unique_ever_seen(iterable, key=None):
"""List unique elements, preserving order. Remember all elements ever seen.
unique_ever_seen('AAAABBBCCDAABBB') --> A B C D
unique_ever_seen('ABBCcAD', str.lower) --> A B C D
"""
seen = set()
seen_add = seen.add
if key is None:
for element in i_filter_false(seen.__contains__, iterable):
seen_add(element)
yield element
else:
for element in iterable:
k = key(element)
if k not in seen:
seen_add(k)
yield element
def clean_tags(tags_list: list) -> list[str]:
"""Generate a unique list of the tags.
:param list tags_list: List with all tags
:return: deduplicated list of the tags, without leading or trailing whitespace
:rtype: list
"""
tags_res = [x.strip() for x in tags_list]
tags_res = list(unique_ever_seen(tags_res))
tags_res.sort()
if tags_res and tags_res[0] == '':
del tags_res[0]
return tags_res
def list_tags_for_bookmarks(bookmarks: Sequence[Bookmark]) -> list[str]:
"""Generate a unique list of the tags from the list of bookmarks.
:param Sequence[Bookmark] bookmarks: List of bookmarks to create the list of tags from
"""
tags = []
for bookmark in bookmarks:
tags += bookmark.tag_list
return clean_tags(tags)
def set_tags(bookmark: Bookmark, new_tags: str) -> None:
"""Set tags from `tags`, strip and sort them.
:param Bookmark bookmark: Bookmark to modify
:param str new_tags: New tags to sort and set.
"""
tags_split = new_tags.split(',')
tags_clean = clean_tags(tags_split)
bookmark.tags = ','.join(tags_clean)
async def list_tags_for_user(
session,
user_key: str,
) -> list[str]:
"""List all tags in use by the user."""
result = await session.exec(
select(Bookmark).where(Bookmark.user_key == user_key, Bookmark.status != Visibility.DELETED)
)
bookmarks = result.all()
tags = []
for bookmark in bookmarks:
tags += bookmark.tag_list
return clean_tags(tags)
async def list_bookmarks_for_tag_for_user(
session,
user_key: str,
tag_key: str,
) -> list[str]:
"""List all tags in use by the user."""
result = await session.exec(select(Bookmark).where(Bookmark.user_key == user_key))
# TODO: filter on tag_key
bookmarks = result.all()
return list_tags_for_bookmarks(bookmarks)

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,65 @@
<div class="row">
{% for bookmark in bookmarks %}
<div class="col s12 m6 l4">
<div class="card horizontal tiny {{ theme.CARD_BACKGROUND }}">
<div class="card-image">
{% if bookmark.favicon %}
<div><img src="{{ url_for('static', filename='favicons/' + bookmark.favicon) }}" class="favicon" /></div>
{% else %}
<div><img src="{{ url_for('static', filename='faviconfallback.png') }}" class="favicon" /></div>
{% endif %}
{% if bookmark.http_status != 200 and bookmark.http_status != 304 %}
<div><i class="small material-icons {{ theme.PROBLEM }}" title="HTTP status {{ bookmark.http_status }}">report_problem</i></div>
{% endif %}
{% if bookmark.starred == True %}
<div><i class="small material-icons {{ theme.STAR }}">star</i></div>
{% endif %}
{% if bookmark.note %}
<div><i class="small material-icons {{ theme.CARD_TEXT }}" title="{{ bookmark.note|truncate(100) }}">comment</i></div>
{% endif %}
</div>
<div class="card-stacked">
<div class="card-content {{ theme.CARD_TEXT }}">
<span class="digimark-card-header activator">
<i class="material-icons right">more_vert</i>
</span>
<div class="digimark-card-content">
<a href="{{ bookmark.url }}" title="{{ bookmark.url }}" rel="noreferrer noopener" target="_blank">
{% if bookmark.title %}
{{ bookmark.title }}
{% else %}
{{ bookmark.get_uri_domain() }} (no title)
{% endif %}
</a>
</div>
</div>
</div>
<div class="card-reveal {{ theme.CARD_BACKGROUND }}">
<span class="card-title {{ theme.CARD_TEXT }}">Added @ {{ bookmark.created_date.strftime('%Y-%m-%d %H:%M') }}<i class="material-icons right">close</i></span>
{% if editable %}
<div class="{{ theme.CARD_TEXT }}" style="padding-top: 10px;">
<a href="{{ url_for('editbookmark', userkey=userkey, urlhash=bookmark.url_hash) }}" style="padding: 3px"><i class="tiny material-icons">mode_edit</i> EDIT</a>
<a href="{{ url_for('deletingbookmark', userkey=userkey, urlhash=bookmark.url_hash) }}" style="padding: 3px" class="red-text"><i class="tiny material-icons">delete</i> DELETE</a>
</div>
{% endif %}
{% if showtags %}
<div class="digimark-card-header-tags">
{% for tag in bookmark.tags_list %}
<div class="chip">
<a href="{{ url_for('tag_page', userkey=userkey, tag=tag) }}">{{ tag }}</a>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{#
<div class="pagination">
{% if page > 1 %}<a href="./?page={{ page - 1 }}">Previous</a>{% endif %}
{% if pagination.get_pages() > page %}<a href="./?page={{ page + 1 }}">Next</a>{% endif %}
</div>
#}
</div>

View File

@@ -3,13 +3,15 @@
{% block pageheader %}{{ action }}{% endblock %}
{% block pagecontent %}
{% if bookmark.http_status != 200 and bookmark.http_status != 304 %}
{% if bookmark.http_status != 200 and bookmark.http_status != 202 and bookmark.http_status != 304 %}
<div class="row">
<div class="col s12">
<div class="card-panel {{ theme.ERRORMESSAGE_BACKGROUND }}">
<span class="{{ theme.ERRORMESSAGE_TEXT }}">
{% if bookmark.http_status == 404 %}
<i class="material-icons">report_problem</i>&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 %}
@@ -36,46 +38,45 @@
{% endif %}
{% if formaction and formaction == 'edit' %}
<form class="digimark" action="{{ url_for('editingbookmark', userkey=userkey, urlhash=bookmark.url_hash) }}" method="POST">
<form class="digimark" id="digimark" action="{{ url_for('editingbookmark', userkey=userkey, urlhash=bookmark.url_hash) }}" method="POST" onsubmit="return onSubmitForm();" autocomplete="off">
{% else %}
<form class="digimark" action="{{ url_for('addingbookmark', userkey=userkey) }}" method="POST">
<form class="digimark" id="digimark" action="{{ url_for('addingbookmark', userkey=userkey) }}" method="POST" onsubmit="return onSubmitForm();" autocomplete="off">
{% endif %}
<div class="row">
<div class="input-field col s12">
<i class="material-icons prefix">description</i>
<input placeholder="title (leave empty for autofetch)" type="text" name="title" id="title" value="{{ bookmark.title }}" class="validate" />
<input placeholder="title (leave empty for autofetch)" type="text" name="title" id="title" value="{{ bookmark.title }}" autocomplete="false" />
<label for="title">Title</label>
{# <span class="helper-text">Leave title empty for autofetching from the page</span>#}
</div>
<div class="input-field col s12">
<i class="material-icons prefix">turned_in</i>
<input placeholder="url" type="text" name="url" id="url" value="{{ bookmark.url }}" class="validate" />
<input placeholder="url" type="text" name="url" id="url" value="{{ bookmark.url }}" autocomplete="false" />
<label for="url">URL</label>
{% if bookmark.get_redirect_uri() %}
<div>
<a class="waves-effect waves-light btn" id="btn_urlupdate"><i class="material-icons left">turned_in</i>{{ bookmark.get_redirect_uri() }}</a>
<a class="waves-effect waves-light btn" id="btn_urlupdate" onclick="updateURL()"><i class="material-icons left">turned_in</i>{{ bookmark.get_redirect_uri() }}</a>
</div>
<script type="text/javascript">
$(function () {
$('#btn_urlupdate').on('click', function () {
var text = $('#url');
text.val('{{ bookmark.get_redirect_uri() }}');
});
});
function updateURL() {
var text = document.getElementById('url');
text.value = '{{ bookmark.get_redirect_uri() }}';
}
</script>
{% endif %}
</div>
<div class="input-field col s12">
<i class="material-icons prefix">comment</i>
<input placeholder="note" type="text" name="note" id="note" value="{{ bookmark.note }}" class="validate" />
<input placeholder="note" type="text" name="note" id="note" value="{{ bookmark.note }}" autocomplete="false" />
<label for="note">Note</label>
</div>
<div class="input-field col s12">
<i class="material-icons prefix">label</i>
<input placeholder="tags, divided by comma's" type="text" name="tags" id="tags" value="{{ bookmark.tags }}" class="validate" />
<input placeholder="tags, divided by comma's" type="text" name="tags" id="tags" value="{{ bookmark.tags }}" autocomplete="false" />
<label for="tags">Tags</label>
</div>
</div>
@@ -87,7 +88,7 @@
<div class="collapsible-header"><i class="material-icons">label</i>Existing tags</div>
<div class="collapsible-body" style="padding: 10px;">
{% for tag in tags %}
<div class="chip clickable" id="tag_{{ tag }}">
<div class="chip clickable" id="chip_{{ tag }}" onclick="addTag('{{ tag }}');">
{{ tag }}
</div>
{% endfor %}
@@ -95,29 +96,23 @@
</li>
</ul>
</div>
{% for tag in tags %}
<script type="text/javascript">
$(function () {
$('#tag_{{ tag }}').on('click', function () {
var text = $('#tags');
text.val(text.val() + ', {{ tag }}');
});
});
</script>
{% endfor %}
</div>
{% endif %}
<div class="row">
<div class="input-field col s12">
<div class="col s12">
{#<i class="material-icons prefix">star</i>#}
<input type="checkbox" name="starred" id="starred" {% if bookmark.starred == True %}checked{% endif %} />
<label for="starred">Starred</label>
<label>
<input type="checkbox" name="starred" id="starred" {% if bookmark.starred == True %}checked{% endif %} />
<span>Starred</span>
</label>
</div>
<div class="input-field col s12">
<input type="checkbox" name="strip" id="strip" />
<label for="strip">Strip parameters from url (like <em>?utm_source=social</em> - can break the link!)</label>
<div class="col s12">
<label>
<input type="checkbox" name="strip" id="strip" />
<span>Strip parameters from url (like <em>?utm_source=social</em> - can break the link!)</span>
</label>
</div>
{% if bookmark.url_hash %}
@@ -152,9 +147,9 @@
</div>
{% if bookmark.url_hash %}
</form>
<div class="input-field col l2 m3 s4">
<div class="input-field col l4 m4 s6">
<form action="{{ url_for('deletingbookmark', userkey=userkey, urlhash=bookmark.url_hash) }}" method="POST">
<p class="left-align"><button class="btn waves-effect waves-light" type="submit" name="delete">Delete <i class="material-icons right">delete</i></button></p>
<p class="left-align"><button class="btn waves-effect waves-light deletebtn" type="submit" name="delete">Delete <i class="material-icons right">delete</i></button></p>
</form>
</div>
</div>
@@ -164,9 +159,17 @@
{% endif %}
<script>
$(function() {
console.log('woei');
$('form.digimark').on('submit',function(){$("#submit").prop("disabled", true); return true;})
});
function onSubmitForm()
{
var theForm = document.getElementById('digimark');
var submitButton = document.getElementById('submit');
theForm.onsubmit = submitButton.setAttribute("disabled", true);
return true;
}
function addTag(tagText)
{
var text = document.getElementById('tags');
text.value = text.value + ', ' + tagText;
}
</script>
{% endblock %}

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,62 @@
<div class="row">
<table>
<thead>
<tr>
<th>&nbsp;</th>
<th>Bookmark</th>
<th>Added</th>
{% if showtags %}
<th>Tags</th>
{% endif %}
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
{% for bookmark in bookmarks %}
<tr>
<td class="list-image">
{% if bookmark.favicon %}
<img src="{{ url_for('static', filename='favicons/' + bookmark.favicon) }}" class="favicon" />
{% else %}
<img src="{{ url_for('static', filename='faviconfallback.png') }}" class="favicon" />
{% endif %}
{% if bookmark.http_status != 200 and bookmark.http_status != 304 %}
<i class="small material-icons {{ theme.PROBLEM }}" title="HTTP status {{ bookmark.http_status }}">report_problem</i>
{% endif %}
{% if bookmark.starred == True %}
<i class="small material-icons {{ theme.STAR }}">star</i>
{% endif %}
{% if bookmark.note %}
<i class="small material-icons {{ theme.CARD_TEXT }}" title="{{ bookmark.note|truncate(100) }}">comment</i>
{% endif %}
</td>
<td>
<a href="{{ bookmark.url }}" title="{{ bookmark.url }}" rel="noreferrer noopener" target="_blank">
{% if bookmark.title %}
{{ bookmark.title }}
{% else %}
{{ bookmark.get_uri_domain() }} (no title)
{% endif %}
</a>
</td>
<td>{{ bookmark.created_date.strftime('%Y-%m-%d %H:%M') }}</td>
{% if showtags %}
<td>
{% for tag in bookmark.tags_list %}
<div class="chip">
<a href="{{ url_for('tag_page', userkey=userkey, tag=tag) }}">{{ tag }}</a>
</div>
{% endfor %}
</td>
{% endif %}
<td>
{% if editable %}
<a href="{{ url_for('editbookmark', userkey=userkey, urlhash=bookmark.url_hash) }}" class="waves-effect waves-light btn" title="Edit"><i class="tiny material-icons">mode_edit</i></a>
<a href="{{ url_for('deletingbookmark', userkey=userkey, urlhash=bookmark.url_hash) }}" class="waves-effect waves-light btn red" title="DELETE"><i class="tiny material-icons">delete</i></a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

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,16 @@
<!doctype html>
<html>
<head>
<title>Redirecting - digimarks</title>
<meta name="referrer" content="never">
<meta name="robots" content="noindex, nofollow">
<meta http-equiv=refresh content="3; URL={{ url }}">
<style>
body { background-color: #000; color: #FFF; }
a { color: #fb8c00; }
</style>
</head>
<body>
<p>You're being redirected. If nothing happens, <a href="{{ url }}">click here instead</a>.</p>
</body>
</html>

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,214 @@
{% 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" id="bookmarkEditForm" x-if="$store.digimarks.bookmarkToEdit">
<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">
</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>
</dialog>
</main>
</article>
{% endblock %}

15
src/digimarks/utils.py Normal file
View File

@@ -0,0 +1,15 @@
"""General utility functions."""
import binascii
import hashlib
import os
def generate_hash(input_text: str) -> str:
"""Generate a hash from string `input`, e.g., for a URL."""
return hashlib.md5(input_text.encode('utf-8')).hexdigest()
def generate_key() -> str:
"""Generate a key to be used for a user or tag."""
return str(binascii.hexlify(os.urandom(24)))

View File

@@ -1,93 +0,0 @@
/**
* digimarks styling
*/
/** Navigation **/
nav .button-collapse
{
/* Fix for misalignment of hamburger icon */
margin: 0;
}
nav .button-collapse i
{
/* Make the hamburger icon great again */
font-size: 2.7rem;
}
/** Form input fields **/
/* label underline focus color */
.input-field input[type=text]:focus
{
border-bottom: 1px solid #000;
box-shadow: 0 1px 0 0 #000;
}
/* valid color */
.input-field input[type=text].valid
{
border-bottom: 1px solid #000;
box-shadow: 0 1px 0 0 #000;
}
/* invalid color */
.input-field input[type=text].invalid
{
border-bottom: 1px solid #000;
box-shadow: 0 1px 0 0 #000;
}
/* icon prefix focus color */
.input-field .prefix.active
{
color: #000;
}
/** Cards and tags **/
/* Card title anchor colour */
.white-text .card-title a,
.white-text a
{
color: #FFF;
}
.chip a,
.white-text .chip a
{
color: #1b5e20; /* green darken-4 */
}
.card.tiny
{
height: 140px;
overflow: hidden;
}
.card.tiny .card-title
{
font-size: 18px;
}
.card .card-reveal .digimark-card-header,
.card .digimark-card-header.activator,
.chip.clickable
{
cursor: pointer;
/*display: block;*/
}
.card .digimark-card-content
{
padding-top: 10px;
}
.card-image i
{
padding: 5px 0 0 15px;
}
.card.horizontal .card-image img.favicon
{
height: 60px;
width: 60px;
}

View File

@@ -1,7 +0,0 @@
(function($){
$(function(){
$('.button-collapse').sideNav();
}); // end of document ready
})(jQuery); // end of jQuery name space

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,75 +0,0 @@
<!doctype html>
<html>
<head>
<title>{% block title %}{% endblock %} - digimarks</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0"/>
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}"/>
<!-- Chrome, Firefox OS and Opera -->
<meta name="theme-color" content="#2e7d32" />
<!-- Windows Phone -->
<meta name="msapplication-navbutton-color" content="#2e7d32">
<!-- iOS Safari -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link href='https://fonts.googleapis.com/css?family=Roboto+Mono&subset=latin,latin-ext' rel='stylesheet' type='text/css'>
<link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/0.100.1/css/materialize.min.css" type="text/css" rel="stylesheet" media="screen,projection"/>
<style>
/* label color */
.input-field label
{
color: {{ theme.TEXTHEX }};
}
/* label focus color */
.input-field input[type=text]:focus + label
{
color: {{ theme.TEXTHEX }};
}
</style>
<link href="{{ url_for('static', filename='css/digimarks.css') }}?20170722" type="text/css" rel="stylesheet" media="screen,projection"/>
<script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
</head>
<body class="{{ theme.BODY }} {{ theme.TEXT }}">
<nav class="{{ theme.NAV }}" role="navigation">
<div class="nav-wrapper container"><a id="logo-container" href="{% if userkey %}{{ url_for('bookmarks', userkey=userkey) }}{% else %}{{ url_for('index') }}{% endif %}" class="brand-logo">digimarks</a>
<ul class="right hide-on-med-and-down">
{% if userkey %}
<li><a href="{{ url_for('tags', userkey=userkey) }}">Tags</a></li>
<li><a href="{{ url_for('addbookmark', userkey=userkey) }}">Add bookmark</a></li>
{% endif %}
</ul>
{% if userkey %}
<ul id="nav-mobile" class="side-nav">
<li><a class="waves-effect" href="{{ url_for('bookmarks', userkey=userkey) }}"><i class="material-icons">turned_in</i>Home</a></li>
<li><a class="waves-effect" href="{{ url_for('tags', userkey=userkey) }}"><i class="material-icons">label</i>Tags</a></li>
<li><a class="waves-effect" href="{{ url_for('addbookmark', userkey=userkey) }}"><i class="material-icons">add</i>Add bookmark</a></li>
</ul>
<a href="#" data-activates="nav-mobile" class="button-collapse"><i class="material-icons">menu</i></a>
{% endif %}
</div>
</nav>
<div class="section no-pad-bot" id="index-banner">
<div class="container">
<div class="header {{ theme.PAGEHEADER }}">
<h1>{% block pageheader %}Bookmarks{% endblock %}</h1>
</div>
</div>
</div>
<div class="container">
<div class="section">
{% block pagecontent %}
{% endblock %}
</div>
</div>
<!-- Scripts -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/0.100.1/js/materialize.min.js"></script>
<script src="{{ url_for('static', filename='js/init.js') }}"></script>
</body>
</html>

View File

@@ -1,148 +0,0 @@
{% extends "base.html" %}
{% if not action %}
{% set action = 'Bookmarks' %}
{% endif %}
{% block title %}{{ action }}{% endblock %}
{% block pageheader %}{{ action }}{% endblock %}
{% block pagecontent %}
{% if tag and not publictag %}
<div class="row">
<div class="col s12">
<a href="{{ url_for('addpublictag', userkey=userkey, tag=tag) }}">Create public page <i class="material-icons right">tag</i></a>
</div>
</div>
{% endif %}
{% if tag and publictag %}
<div class="row">
<div class="col s12"><a href="{{ url_for('publictag', tagkey=publictag.tagkey) }}">Public link</a></div>
</div>
{% endif %}
{% if message %}
<div class="row">
<div class="col s12">
<div class="card-panel {{ theme.MESSAGE_BACKGROUND }}">
<span class="{{ theme.MESSAGE_TEXT }}">
{{ message|safe }}
</span>
</div>
</div>
</div>
{% endif %}
<div class="row">
<form action="{{ url_for('bookmarks', userkey=userkey) }}" method="POST">
<div class="input-field col l10 m10 s8">
<input placeholder="search text" type="text" name="filter_text" id="filter_text" value="{{ filter_text }}" class="validate" />
</div>
<div class="input-field col l2 m2 s4">
<p class="left-align"><button class="btn waves-effect waves-light" type="submit" name="submit">Filter</button></p>
</div>
</form>
</div>
{% if tags %}
<div class="row">
<div class="col s12">
<ul class="collapsible" data-collapsible="expandable">
<li>
<div class="collapsible-header"><i class="material-icons">label</i>Filter on star/problem/comment/tag</div>
<div class="collapsible-body" style="padding: 10px;">
<div class="chip">
<a href="{{ url_for('bookmarks', userkey=userkey, filtermethod='starred') }}"><i class="tiny material-icons {{ theme.STAR }}">star</i></a>
</div>
<div class="chip">
<a href="{{ url_for('bookmarks', userkey=userkey, filtermethod='broken') }}"><i class="tiny material-icons {{ theme.PROBLEM }}">report_problem</i></a>
</div>
<div class="chip">
<a href="{{ url_for('bookmarks', userkey=userkey, filtermethod='note') }}"><i class="tiny material-icons {{ theme.COMMENT }}">comment</i></a>
</div>
{% for tag in tags %}
<div class="chip">
<a href="{{ url_for('tag', userkey=userkey, tag=tag) }}">{{ tag }}</a>
</div>
{% endfor %}
</li>
</ul>
</div>
</div>
{% endif %}
<div class="row">
{% for bookmark in bookmarks %}
<div class="col s12 m6 l4">
{#
<div class="thumbnail">
<a href="{{ bookmark.url }}" title="{{ bookmark.url }}">
<img style="width:450px;" src="{{ bookmark.image }}" />
</a>
<p><a href="{{ bookmark.url }}">{{ bookmark.url|urlize(25) }}</a></p>
<p>{{ bookmark.created_date.strftime("%m/%d/%Y %H:%M") }}</p>
</div>
#}
<div class="card horizontal tiny {{ theme.CARD_BACKGROUND }}">
<div class="card-image">
{% if bookmark.favicon %}
<div><img src="{{ url_for('static', filename='favicons/' + bookmark.favicon) }}" class="favicon" /></div>
{% endif %}
{% if bookmark.http_status != 200 and bookmark.http_status != 304 %}
<div><i class="small material-icons {{ theme.PROBLEM }}" title="HTTP status {{ bookmark.http_status }}">report_problem</i></div>
{% endif %}
{% if bookmark.starred == True %}
<div><i class="small material-icons {{ theme.STAR }}">star</i></div>
{% endif %}
{% if bookmark.note %}
<div><i class="small material-icons {{ theme.CARD_TEXT }}" title="{{ bookmark.note|truncate(100) }}">comment</i></div>
{% endif %}
</div>
<div class="card-stacked">
<div class="card-content {{ theme.CARD_TEXT }}">
<span class="digimark-card-header activator">
<span class="digimark-card-header-tags">
{% for tag in bookmark.tags_list %}
<div class="chip">
<a href="{{ url_for('tag', userkey=userkey, tag=tag) }}">{{ tag }}</a>
</div>
{% endfor %}
</span>
<i class="material-icons right">more_vert</i>
</span>
<div class="digimark-card-content">
<a href="{{ bookmark.url }}" title="{{ bookmark.url }}" rel="noreferrer noopener" target="_blank">
{% if bookmark.title %}
{{ bookmark.title }}
{% else %}
{{ bookmark.get_uri_domain() }} (no title)
{% endif %}
</a>
</div>
</div>
</div>
<div class="card-reveal {{ theme.CARD_BACKGROUND }}">
<span class="card-title {{ theme.CARD_TEXT }}">Added @ {{ bookmark.created_date.strftime('%Y-%m-%d %H:%M') }}<i class="material-icons right">close</i></span>
<div class="{{ theme.CARD_TEXT }}" style="padding-top: 10px;">
<a href="{{ url_for('editbookmark', userkey=userkey, urlhash=bookmark.url_hash) }}" style="padding: 3px"><i class="tiny material-icons">mode_edit</i> EDIT</a>
<a href="{{ url_for('deletingbookmark', userkey=userkey, urlhash=bookmark.url_hash) }}" style="padding: 3px" class="red-text"><i class="tiny material-icons">delete</i> DELETE</a>
</div>
</div>
</div>
</div>
{% endfor %}
{#
<div class="pagination">
{% if page > 1 %}<a href="./?page={{ page - 1 }}">Previous</a>{% endif %}
{% if pagination.get_pages() > page %}<a href="./?page={{ page + 1 }}">Next</a>{% endif %}
</div>
#}
</div>
<div class="fixed-action-btn" style="bottom: 20px; right: 20px;">
<a class="btn-floating btn-large {{ theme.FAB }}" href="{{ url_for('addbookmark', userkey=userkey) }}">
<i class="large material-icons">add</i>
</a>
</div>
{% endblock %}

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,68 +0,0 @@
{% extends "base.html" %}
{% if not action %}
{% set action = 'Bookmarks' %}
{% endif %}
{% block title %}{{ action }}{% endblock %}
{% block pageheader %}{{ action }}{% endblock %}
{% block pagecontent %}
{% if message %}
<div class="row">
<div class="col s12">
<div class="card-panel orange lighten-2">
<span class="white-text">
{{ message }}
</span>
</div>
</div>
</div>
{% endif %}
<div class="row">
<div class="col s12">
<a href="{{ url_for('publictagfeed', tagkey=tagkey) }}"><i class="material-icons tiny">rss_feed</i> feed</a>
</div>
</div>
<div class="row">
{% for bookmark in bookmarks %}
<div class="col s12 m6 l4">
<div class="card horizontal tiny green darken-3">
<div class="card-image">
{% if bookmark.favicon %}
<div><img src="{{ url_for('static', filename='favicons/' + bookmark.favicon) }}" class="favicon" /></div>
{% endif %}
{% if bookmark.http_status != 200 %}
<i class="small material-icons red-text" title="HTTP status {{ bookmark.http_status }}">report_problem</i><br />
{% endif %}
{% if bookmark.starred == True %}
<i class="small material-icons yellow-text">star</i>
{% endif %}
{% if bookmark.note %}
<div><i class="small material-icons white-text" title="{{ bookmark.note|truncate(100) }}">comment</i></div>
{% endif %}
</div>
<div class="card-stacked">
<div class="card-content white-text">
<span class="digimark-card-header activator">
<i class="material-icons right">more_vert</i>
</span>
<div class="digimark-card-content">
<a href="{{ bookmark.url }}" title="{{ bookmark.url }}" rel="noreferrer noopener" target="_blank">
{% if bookmark.title %}
{{ bookmark.title }}
{% else %}
{{ bookmark.get_uri_domain() }} (no title)
{% endif %}
</a>
</div>
</div>
</div>
<div class="card-reveal green darken-3">
<span class="card-title white-text">Added @ {{ bookmark.created_date.strftime('%Y-%m-%d %H:%M') }}<i class="material-icons right">close</i></span>
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

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', userkey=userkey, tag=tag['tag']) }}">{{ tag['tag'] }}</a>
</td>
<td>
{% if tag['publictag'] %}
<a href="{{ url_for('publictag', tagkey=tag['publictag'].tagkey) }}">Public link</a> (<a href="{{ url_for('removepublictag', tag=tag['tag'], tagkey=tag['publictag'].tagkey, userkey=userkey) }}">Delete</a> <i class="tiny material-icons red-text">warning</i>)
{% else %}
<a href="{{ url_for('addpublictag', userkey=userkey, tag=tag['tag']) }}">Create</a>
{% endif %}
</td>
<td>
{{ tag['total'] }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

21
tox.ini Normal file
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

11
wsgi.py
View File

@@ -1,11 +0,0 @@
# Activate virtualenv
import settings
activate_this = getattr(settings, 'VENV', None)
if activate_this:
execfile(activate_this, dict(__file__=activate_this))
from digimarks import app as application
if __name__ == "__main__":
# application is ran standalone
application.run(debug=settings.DEBUG)