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

293 Commits

Author SHA1 Message Date
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
abf9141019 Release 1.1.0 2017-07-22 22:09:42 +02:00
Michiel Scholten
8ca54bf364 Merge pull request #7 from aquatix/theme
Theme
2017-07-22 21:55:03 +02:00
0be05d07e9 Default theme is now a constant; linting fixes 2017-07-22 21:51:07 +02:00
a66360a0e3 Note on the added DB field 2017-07-22 21:44:06 +02:00
1545c17472 Moved label colours to template/theme; added freshgreen and dark 2017-07-22 21:42:12 +02:00
dd0a9d8283 Typo 2017-07-22 21:16:33 +02:00
652e3a89af Theme support, with default 'green' 2017-07-22 21:11:09 +02:00
133a139b79 Merge branch 'master' into theme 2017-07-22 20:36:05 +02:00
b556978640 Updated MaterializeCSS 2017-07-22 19:39:52 +02:00
8bb9e11088 More changes 2017-07-05 12:45:09 +02:00
85e94b71e5 Some reformatting according to keepachangelog.com 2017-07-05 12:32:00 +02:00
a895774001 Changes 2017-07-03 15:08:50 +02:00
7598ba6a61 Updated requirements 2017-07-03 15:07:45 +02:00
c5dcf579d3 Updated MaterializeCSS 2017-07-03 15:07:19 +02:00
590095659e 20170519: default to 'green' 2017-06-15 08:41:01 +02:00
d5d71b4f51 Cache User settings 2017-04-28 16:33:42 +02:00
7f2da49d26 Update Materialize to 0.98.2 2017-04-26 15:22:20 +02:00
0f0e4e03c3 Small requirements updates 2017-04-26 15:20:23 +02:00
044f507aa2 Started with theming support 2017-04-15 09:01:49 +02:00
b0da3a4454 Updated Materialize and jQuery 2017-03-22 12:34:33 +01:00
17e1db12bf Minor requirements updates 2017-03-22 12:32:57 +01:00
480f27797f requirements updates 2017-03-11 21:32:32 +01:00
1729ecd540 Add pkg-resources==0.0.0 as otherwise venv will break 2017-03-01 21:06:59 +01:00
d54b26dafb Updated requirements 2017-03-01 21:03:16 +01:00
2c76ffaf12 Cache buster to force loading of the latest styling 2017-02-04 10:28:07 +01:00
e54a033984 Indentation and styling fixes, comments 2017-02-04 10:27:05 +01:00
4e0d89c25e Better size for the hamburger icon 2017-02-04 10:24:39 +01:00
30e4f74ed9 Fix for misalignment of hamburger icon 2017-02-04 09:20:42 +01:00
1beadb676c Updated MaterializeCSS 2017-02-04 09:20:40 +01:00
eea63398ba Try-out of scrolling tags at 2016-11-07, did not work out yet 2017-02-04 09:18:32 +01:00
d5021fd7c4 Try-out of scrolling tags at 2016-11-07, did not work out yet 2017-02-04 09:18:10 +01:00
c03d0c2458 Recompiled requirements, lowercase package names 2017-01-24 09:48:09 +01:00
ff08ea4930 Explicit requirements through pip-compile from pip-tools 2017-01-16 12:01:08 +01:00
2e9457ee1a Catch case where favicon could not be saved 2017-01-16 11:38:43 +01:00
63dd636c25 Show 404 page if bookmark is not found when editing 2017-01-16 11:22:56 +01:00
412b4a93c7 Fixed indention 2016-12-29 20:59:45 +01:00
abdc11361a Added missing itertools function 2016-12-29 20:59:04 +01:00
Michiel Scholten
7a98de4b3f Merge pull request #3 from jelmer/optional-venv
Make running in a virtualenv optional.
2016-12-29 20:49:16 +01:00
Michiel Scholten
59f5365edc Merge pull request #2 from jelmer/utilkit
Drop dependency on utilkit.
2016-12-29 20:48:42 +01:00
Michiel Scholten
b0aedcd6e4 Merge pull request #1 from jelmer/unique-everseen
Avoid dependency on more_itertools.
2016-12-29 20:47:32 +01:00
6b61e43c5d Upcoming changes 2016-12-29 17:01:55 +01:00
Jelmer Vernooij
570af2d62e Make running in a virtualenv optional. 2016-11-28 23:34:28 +00:00
Jelmer Vernooij
8a19df2741 Drop dependency on utilkit.
utilkit is only used for one one-liner function.
2016-11-28 23:30:51 +00:00
Jelmer Vernooij
9e6d67a0ff Avoid dependency on more_itertools.
Instead, use version of unique_everseen as seen in itertools
documentation.
2016-11-28 23:25:50 +00:00
55 changed files with 3217 additions and 1213 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 # Rope project settings
.ropeproject .ropeproject
# JetBrains PyCharm/Idea
.idea
# vim
*.swp
# digimarks
static/favicons
tags
*.db_*
*.db
settings.py

View File

@@ -1,18 +1,105 @@
# Changelog
All notable changes to this project will be documented in this file.
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 ## TODO
- Sorting of bookmarks - Sorting of bookmarks
- Sort by title - Sort by title
- Sort by date - Sort by date
- Logging of actions - 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 - 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) (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 - Look into compatibility with del.icio.us, so we can make use of existing browser integration
- Add unit tests
## v1.0.0 ## [Unreleased]
2016-12-29 ### 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
### Added
- Show 404 page if bookmark is not found when editing
- 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
- Fix for misalignment and size of hamburger icon
- Updated Python (pip) dependencies
- Updated MaterializeCSS and jQuery
### Removed
- Removed dependency on more_itertools
- Removed dependency on utilkit
## [1.0.0] - 2016-12-29
- json view of public tag pages, returns all items - json view of public tag pages, returns all items
- feed (rss/atom) view of public tag pages, returns latest 15 - feed (rss/atom) view of public tag pages, returns latest 15
@@ -47,9 +134,7 @@
- Updated MaterializeCSS and jQuery - Updated MaterializeCSS and jQuery
## v0.2.0 ## [0.2.0] - 2016-08-02
2016-08-02
- Favicon courtesy Freepik on flaticon.com - Favicon courtesy Freepik on flaticon.com
- Tag tags for easy adding of tags - Tag tags for easy adding of tags
@@ -60,9 +145,7 @@
- Option to strip parameters from url (like '?utm_source=social') - Option to strip parameters from url (like '?utm_source=social')
## v0.1.0 ## [0.1.0] - 2016-07-26
2016-07-26
- Initial release - Initial release
- Flask application with functionality to add users, add and edit bookmarks, - Flask application with functionality to add users, add and edit bookmarks,

View File

@@ -1,7 +1,7 @@
digimarks 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. Simple bookmarking service, using a SQLite database to store bookmarks, supporting tags and automatic title fetching.
@@ -33,16 +33,48 @@ necessary packages:
pip install -r requirements.txt pip install -r requirements.txt
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 Usage / example configuration
----------------------------- -----------------------------
OUT OF DATE!
Copy ``settings.py`` from example_config to the parent directory and Copy ``settings.py`` from example_config to the parent directory and
configure to your needs (*at the least* change the value of `SYSTEMKEY`). 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 Run digimarks as a service under nginx or apache and call the appropriate
url's when wanted. 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 Bookmarklet
@@ -72,8 +104,9 @@ If you for whatever reason would lose this user key, just either look on the con
Server configuration Server configuration
~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~
* `vhost for Apache2.4`_ * `systemd for digimarks API`_ which uses the `gunicorn config`_
* `uwsgi.ini`_ * `nginx for digimarks API`_
* `more config`_
What's new? What's new?
@@ -89,18 +122,21 @@ Attributions
.. _digimarks: https://github.com/aquatix/digimarks .. _digimarks: https://github.com/aquatix/digimarks
.. _webhook: https://en.wikipedia.org/wiki/Webhook
.. |PyPI version| image:: https://img.shields.io/pypi/v/digimarks.svg .. |PyPI version| image:: https://img.shields.io/pypi/v/digimarks.svg
:target: https://pypi.python.org/pypi/digimarks/ :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 .. |PyPI license| image:: https://img.shields.io/github/license/aquatix/digimarks.svg
:target: https://pypi.python.org/pypi/digimarks/ :target: https://pypi.python.org/pypi/digimarks/
.. |Code health| image:: https://landscape.io/github/aquatix/digimarks/master/landscape.svg?style=flat .. |Code health| image:: https://landscape.io/github/aquatix/digimarks/master/landscape.svg?style=flat
:target: https://landscape.io/github/aquatix/digimarks/master :target: https://landscape.io/github/aquatix/digimarks/master
:alt: Code Health :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 .. _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 .. _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 .. _uwsgi.ini: https://github.com/aquatix/digimarks/blob/master/example_config/uwsgi.ini
.. _Changelog: https://github.com/aquatix/digimarks/blob/master/CHANGELOG.md .. _Changelog: https://github.com/aquatix/digimarks/blob/master/CHANGELOG.md
.. _Freepik: http://www.flaticon.com/free-icon/letter-m_2041 .. _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,648 +0,0 @@
from __future__ import print_function
import datetime
import gzip
import hashlib
import os
import sys
import requests
import shutil
import bs4
from more_itertools import unique_everseen
from urlparse import urlparse, urlunparse, urljoin
from utilkit import datetimeutil
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
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 = {}
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()
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': datetimeutil.datetime_to_string(self.created_date),
'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 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 """
return render_template('index.html')
@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())
return render_template('bookmarks.html', bookmarks=bookmarks, userkey=userkey, tags=tags, filter_text=filter_text, message=message)
#@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()
bookmark = Bookmark.get(Bookmark.url_hash == urlhash, Bookmark.userkey == userkey)
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 = ''
return render_template('edit.html', action='Edit bookmark', userkey=userkey, bookmark=bookmark, message=message, formaction='edit', tags=tags)
@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)
return render_template('edit.html', action='Add bookmark', userkey=userkey, bookmark=bookmark, tags=tags, message=message)
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:
bookmark.set_favicon()
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()
return render_template('tags.html', tags=alltags, totaltags=totaltags, totalpublic=totalpublic, totalbookmarks=totalbookmarks,
totaldeleted=totaldeleted, totalstarred=totalstarred, totalhttperrorstatus=totalhttperrorstatus,
totalnotes=totalnotes, userkey=userkey)
@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
return render_template('bookmarks.html', bookmarks=bookmarks, userkey=userkey, tags=tags, tag=tag, publictag=publictag, action=pageheader, message=message)
@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())
return render_template('publicbookmarks.html', bookmarks=bookmarks, tag=tag, action=this_tag.tag, tagkey=tagkey)
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)
print(user.key)
# Run when called standalone
if __name__ == '__main__':
# run the application
app.run(port=9999, debug=True)

View File

@@ -1,4 +1,4 @@
# Virtualenv to use with the wsgi file # Virtualenv to use with the wsgi file (optional)
VENV = '/srv/marks.example.com/venv/bin/activate_this.py' VENV = '/srv/marks.example.com/venv/bin/activate_this.py'
PORT = 8086 PORT = 8086
@@ -10,6 +10,10 @@ DEBUG = False
# echo -n "yourstring" | sha1sum # echo -n "yourstring" | sha1sum
SYSTEMKEY = 'S3kr1t' SYSTEMKEY = 'S3kr1t'
# RapidAPI key for favicons
# https://rapidapi.com/realfavicongenerator/api/realfavicongenerator
MASHAPE_API_KEY = 'your_MASHAPE_key'
LOG_LOCATION = 'digimarks.log' LOG_LOCATION = 'digimarks.log'
#LOG_LOCATION = '/var/log/digimarks/digimarks.log' #LOG_LOCATION = '/var/log/digimarks/digimarks.log'
# How many logs to keep in log rotation: # 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,
_

78
pyproject.toml Normal file
View File

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

11
requirements-dev.in Normal file
View File

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

223
requirements-dev.txt Normal file
View File

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

3
requirements-server.in Normal file
View File

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

14
requirements.in Normal file
View File

@@ -0,0 +1,14 @@
# Core application
fastapi[all]
sqlmodel
alembic
aiosqlite
# Fetch title etc from links
beautifulsoup4
# Fetch favicons
extract_favicon
# Generate (atom) feeds for tags and such
feedgen

View File

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

View File

@@ -1,43 +1,7 @@
""" #!/usr/bin/env python
A setuptools based setup module. """Install script for module installation. Compatibility stub because pyproject.toml is used."""
See:
https://packaging.python.org/en/latest/distributing.html
https://github.com/pypa/sampleproject
"""
from setuptools import setup import setuptools
# To use a consistent encoding
from codecs import open
from os import path
here = path.abspath(path.dirname(__file__)) if __name__ == "__main__":
setuptools.setup()
# 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.0.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', 'utilkit'],
py_modules=['digimarks'],
zip_safe=True,
)

View File

View File

@@ -0,0 +1,78 @@
"""Bookmark helper functions, like content scrapers, favicon extractor, updater functions."""
from urllib.parse import urlparse, urlunparse
import bs4
import httpx
from extract_favicon import from_html
from fastapi import Request
from pydantic import AnyUrl
from src.digimarks import tags_helpers
from src.digimarks.models import Bookmark
DIGIMARKS_USER_AGENT = 'digimarks/2.0.0-dev'
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(logger, 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_helpers.set_tags(bookmark, bookmark.tags)

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

@@ -0,0 +1,404 @@
"""digimarks main module."""
import logging
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from typing import Annotated, Sequence, Type
import httpx
from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import 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
from src.digimarks import bookmarks_helpers, tags_helpers, utils
from src.digimarks.models import DEFAULT_THEME, Bookmark, User, Visibility
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
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."""
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."""
if system_key != settings.system_key:
raise HTTPException(status_code=404)
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail='User not found')
return user
# @app.get('/admin/{system_key}/users/', response_model=list[User])
@app.get('/api/v1/admin/{system_key}/users/')
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]
"""
if system_key != settings.system_key:
raise HTTPException(status_code=404)
result = await session.exec(select(User).offset(offset).limit(limit))
users = result.all()
return users
@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,
) -> list[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
@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."""
result = await session.exec(
select(Bookmark).where(
Bookmark.user_key == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
)
)
bookmark = result.first()
return bookmark
@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`."""
bookmark.user_key = user_key
# Auto-fill title, fix tags etc.
bookmarks_helpers.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
@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`."""
bookmark.user_key = user_key
# Auto-fill title, fix tags etc.
bookmarks_helpers.update_bookmark_with_info(bookmark, request, strip_params)
bookmark.url_hash = utils.generate_hash(str(bookmark.url))
session.add(bookmark)
await session.commit()
await session.refresh(bookmark)
return bookmark
@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`."""
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 HTTPException(status_code=404, detail='Bookmark not found')
bookmark.modified_date = datetime.now(UTC)
# 'patch' endpoint, which means that you can send only the data that you want to update, leaving the rest intact
bookmark_data = bookmark.model_dump(exclude_unset=True)
# Merge the changed fields into the existing object
bookmark_db.sqlmodel_update(bookmark_data)
# Autofill title, fix tags, etc. where (still) needed
bookmarks_helpers.update_bookmark_with_info(bookmark, request, strip_params)
session.add(bookmark_db)
await session.commit()
await session.refresh(bookmark_db)
return bookmark_db
@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`."""
result = await session.get(Bookmark, {'url_hash': url_hash, 'user_key': user_key})
bookmark = result
if not bookmark:
raise HTTPException(status_code=404, detail='Bookmark not found')
bookmark.deleted_date = datetime.now(UTC)
bookmark.status = Visibility.DELETED
session.add(bookmark)
await session.commit()
return {'ok': True}
@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."""
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."""
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 tags_helpers.clean_tags(tags)
@app.get('/api/v1/{user_key}/tags/{tag_key}')
async def list_bookmarks_for_tag_for_user(
session: SessionDep,
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))
bookmarks = result.all()
return tags_helpers.list_tags_for_bookmarks(bookmarks)
@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,
# )

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

@@ -0,0 +1,113 @@
"""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 Optional, Type, TypeVar
from pydantic import AnyUrl, computed_field
from sqlmodel import AutoString, Field, SQLModel
DEFAULT_THEME = 'freshgreen'
class User(SQLModel, table=True):
"""User account."""
__tablename__ = 'user'
id: int = Field(primary_key=True)
username: str
key: str
theme: str = Field(default=DEFAULT_THEME)
created_date: datetime
class Visibility:
"""Options for visibility of an object."""
VISIBLE = 0
DELETED = 1
HIDDEN = 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) -> Optional[str]:
if value is None:
return None
if isinstance(value, str):
# Test if value is valid to avoid `process_result_value` failing
try:
internal_type(value) # type: ignore[call-arg]
except ValueError as e:
raise ValueError(f'Invalid value for {internal_type.__name__}: {e}') from e
return str(value)
def process_result_value(self, value, dialect) -> Optional[T]:
if value is None:
return None
return internal_type(value) # type: ignore[call-arg]
return CustomType
class Bookmark(SQLModel, table=True):
"""Bookmark object."""
__tablename__ = 'bookmark'
id: int = Field(primary_key=True)
user_key: str = Field(foreign_key='user.key', nullable=False)
title: str = Field(default='', nullable=False)
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:
"""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."""
__tablename__ = 'publictag'
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,25 @@
/**
* digimarks styling
*
* Overrides on and additions to the digui styling
*/
/* Star, error, note etc */
.card .statuses {
display: flex;
flex-direction: column;
}
.star {
color: #ffeb3b;
}
.thumbnail {
/*width: 80px;*/
width: 66;
}
.thumbnail img {
/*width: 72px;*/
width: 60px;
}

View File

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

View File

@@ -0,0 +1,71 @@
"""Helper functions for tags used with Bookmark models."""
from sqlalchemy import Sequence
from src.digimarks.models import Bookmark
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."""
tags = []
for bookmark in bookmarks:
tags += bookmark.tags_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)

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 pageheader %}{{ action }}{% endblock %}
{% block pagecontent %} {% 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="row">
<div class="col s12"> <div class="col s12">
<div class="card-panel red darken-1"> <div class="card-panel {{ theme.ERRORMESSAGE_BACKGROUND }}">
<span class="white-text"> <span class="{{ theme.ERRORMESSAGE_TEXT }}">
{% if bookmark.http_status == 404 %} {% if bookmark.http_status == 404 %}
<i class="material-icons">report_problem</i>&nbsp;&nbsp;URL not found (404), broken/outdated link? <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 %} {% elif bookmark.http_status == 302 %}
<i class="material-icons">report_problem</i>&nbsp;&nbsp;HTTP status (302), moved temporarily. Use button for new target <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 %} {% elif bookmark.http_status == bookmark.HTTP_CONNECTIONERROR %}
@@ -26,8 +28,8 @@
{% if message %} {% if message %}
<div class="row"> <div class="row">
<div class="col s12"> <div class="col s12">
<div class="card-panel orange lighten-2"> <div class="card-panel {{ theme.MESSAGE_BACKGROUND }}">
<span class="white-text"> <span class="{{ theme.MESSAGE_TEXT }}">
{{ message }} {{ message }}
</span> </span>
</div> </div>
@@ -36,46 +38,45 @@
{% endif %} {% endif %}
{% if formaction and formaction == 'edit' %} {% 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 %} {% 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 %} {% endif %}
<div class="row"> <div class="row">
<div class="input-field col s12"> <div class="input-field col s12">
<i class="material-icons prefix">description</i> <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> <label for="title">Title</label>
{# <span class="helper-text">Leave title empty for autofetching from the page</span>#}
</div> </div>
<div class="input-field col s12"> <div class="input-field col s12">
<i class="material-icons prefix">turned_in</i> <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> <label for="url">URL</label>
{% if bookmark.get_redirect_uri() %} {% if bookmark.get_redirect_uri() %}
<div> <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> </div>
<script type="text/javascript"> <script type="text/javascript">
$(function () { function updateURL() {
$('#btn_urlupdate').on('click', function () { var text = document.getElementById('url');
var text = $('#url'); text.value = '{{ bookmark.get_redirect_uri() }}';
text.val('{{ bookmark.get_redirect_uri() }}'); }
});
});
</script> </script>
{% endif %} {% endif %}
</div> </div>
<div class="input-field col s12"> <div class="input-field col s12">
<i class="material-icons prefix">comment</i> <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> <label for="note">Note</label>
</div> </div>
<div class="input-field col s12"> <div class="input-field col s12">
<i class="material-icons prefix">label</i> <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> <label for="tags">Tags</label>
</div> </div>
</div> </div>
@@ -87,7 +88,7 @@
<div class="collapsible-header"><i class="material-icons">label</i>Existing tags</div> <div class="collapsible-header"><i class="material-icons">label</i>Existing tags</div>
<div class="collapsible-body" style="padding: 10px;"> <div class="collapsible-body" style="padding: 10px;">
{% for tag in tags %} {% for tag in tags %}
<div class="chip clickable" id="tag_{{ tag }}"> <div class="chip clickable" id="chip_{{ tag }}" onclick="addTag('{{ tag }}');">
{{ tag }} {{ tag }}
</div> </div>
{% endfor %} {% endfor %}
@@ -95,29 +96,23 @@
</li> </li>
</ul> </ul>
</div> </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> </div>
{% endif %} {% endif %}
<div class="row"> <div class="row">
<div class="input-field col s12"> <div class="col s12">
{#<i class="material-icons prefix">star</i>#} {#<i class="material-icons prefix">star</i>#}
<label>
<input type="checkbox" name="starred" id="starred" {% if bookmark.starred == True %}checked{% endif %} /> <input type="checkbox" name="starred" id="starred" {% if bookmark.starred == True %}checked{% endif %} />
<label for="starred">Starred</label> <span>Starred</span>
</label>
</div> </div>
<div class="input-field col s12"> <div class="col s12">
<label>
<input type="checkbox" name="strip" id="strip" /> <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> <span>Strip parameters from url (like <em>?utm_source=social</em> - can break the link!)</span>
</label>
</div> </div>
{% if bookmark.url_hash %} {% if bookmark.url_hash %}
@@ -152,9 +147,9 @@
</div> </div>
{% if bookmark.url_hash %} {% if bookmark.url_hash %}
</form> </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"> <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> </form>
</div> </div>
</div> </div>
@@ -164,9 +159,17 @@
{% endif %} {% endif %}
<script> <script>
$(function() { function onSubmitForm()
console.log('woei'); {
$('form.digimark').on('submit',function(){$("#submit").prop("disabled", true); return true;}) 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> </script>
{% endblock %} {% 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,190 @@
{% extends "base.html" %}
{% block title %}Bookmarks{% endblock %}
{% block page_header %}Bookmarks{% endblock %}
{% block page_content %}
<article
x-init="$store.digimarks.userKey = '{{ user_key }}'; $store.digimarks.loadCache(); $store.digimarks.getBookmarks()"
x-data="">
<header>
<nav class="menu">
<ul>
<li><h1>digimarks</h1></li>
<li>
<button x-data @click="$store.digimarks.toggleTagPage()"
:class="$store.digimarks.showTags && 'active'">tags
</button>
</li>
<li>
<button @click="$store.digimarks.startAddingBookmark()">add bookmark</button>
</li>
<li>
<button @click="$store.digimarks.loopToNextTheme()" class="theme-toggle">theme</button>
</li>
<li><input x-model="$store.digimarks.search" placeholder="Search/filter..."></li>
<li x-show="$store.digimarks.loading"><i class="fa-solid fa-rotate-right fa-spin"></i></li>
</ul>
</nav>
</header>
<main>
<section x-cloak x-show="$store.digimarks.showBookmarks" x-transition.opacity>
<h1 x-bind:title="$store.digimarks.userKey">Bookmarks</h1>
<p>
<button @click="$store.digimarks.sortAlphabetically()"
:class="$store.digimarks.sortTitleAsc && 'active'">a-z &darr;
</button>
<button @click="$store.digimarks.sortAlphabetically('desc')"
:class="$store.digimarks.sortTitleDesc && 'active'">z-a &uarr;
</button>
<button @click="$store.digimarks.sortCreated()"
:class="$store.digimarks.sortCreatedAsc && 'active'">date &darr;
</button>
<button @click="$store.digimarks.sortCreated('desc')"
:class="$store.digimarks.sortCreatedDesc && 'active'">date &uarr;
</button>
<button @click="$store.digimarks.toggleListOrGrid()"
:class="$store.digimarks.showBookmarksCards && 'active'">list or grid
</button>
</p>
<table x-cloak x-show="$store.digimarks.showBookmarksList">
<thead>
<tr>
<th colspan="2">&nbsp;</th>
<th>Title</th>
<th>Note</th>
<th>Tags</th>
</tr>
</thead>
<tbody>
<template x-for="bookmark in $store.digimarks.filteredBookmarks" :key="bookmark.id">
<tr>
<td class="thumbnail">
<div class="card-thumb" x-show="bookmark.favicon"><img
x-bind:src="'/content/favicons/' + bookmark.favicon"></div>
</td>
<td>
<div x-show="bookmark.starred" class="star"><i class="fa-fw fa-solid fa-star"></i>
</div>
<div x-show="bookmark.http_status !== 200 && bookmark.http_status !== 304"
class="error"><i
class="fa-fw fa-solid fa-triangle-exclamation"></i>
</div>
</td>
<td><a x-text="bookmark.title" x-bind:href="bookmark.url" target="_blank"></a></td>
<td x-text="bookmark.note"></td>
<td>
<template x-for="tag in bookmark.tag_list">
<span x-text="tag" class="chip"></span>
</template>
</td>
</tr>
</template>
</tbody>
</table>
{#
<ul x-cloak x-show="$store.digimarks.show_bookmarks_list">
<template x-for="bookmark in $store.digimarks.filteredBookmarks" :key="bookmark.id">
<li><a x-text="bookmark.title" x-bind:href="bookmark.url" target="_blank"></a></li>
</template>
</ul>
#}
<section x-cloak x-show="$store.digimarks.showBookmarksCards" class="cards">
<template x-for="bookmark in $store.digimarks.filteredBookmarks" :key="bookmark.id">
<div class="card">
<div class="card-body">
<div class="card-thumb" x-show="bookmark.favicon"><img
x-bind:src="'/content/favicons/' + bookmark.favicon"></div>
<div class="statuses">
<div x-show="bookmark.starred" class="star"><i class="fa-fw fa-solid fa-star"></i>
</div>
<div x-show="bookmark.http_status !== 200 && bookmark.http_status !== 304"
class="error"><i
class="fa-fw fa-solid fa-triangle-exclamation"></i>
</div>
<div x-show="bookmark.note"><i class="fa-fw fa-regular fa-note-sticky"></i></div>
</div>
<div><a x-text="bookmark.title" x-bind:href="bookmark.url" target="_blank"></a></div>
</div>
<div class="card-footer">
<button title="show actions"><i class="fa-solid fa-square-caret-down"></i></button>
<div class="meta">
<template x-for="tag in bookmark.tag_list">
<span x-text="tag" class="chip"></span>
</template>
</div>
{# <div x-text="bookmark.created_date" class="meta"></div>#}
</div>
</div>
</template>
</section>
</section>
<section x-cloak x-show="$store.digimarks.showTags" x-transition.opacity>
<h1>Tags</h1>
<table>
<thead>
<tr>
<th>Tag</th>
<th>Public link</th>
<th>Number of bookmarks</th>
</tr>
</thead>
<tbody>
<template x-for="tag in $store.digimarks.filteredTags" :key="tag">
<tr>
<td x-text="tag"></td>
<td></td>
<td></td>
</tr>
</template>
</tbody>
</table>
</section>
<dialog x-cloak id="editFormDialog"
x-transition:enter="modal-enter"
x-transition:enter-start="modal-enter"
x-transition:enter-end="modal-enter-active"
x-transition:leave="modal-leave-active"
x-transition:leave-start="modal-enter-active"
x-transition:leave-end="modal-enter">
<h1>Add/Edit bookmark</h1>
{#
<div class="card-panel {{ theme.ERRORMESSAGE_BACKGROUND }}">
<span class="error">
{% if bookmark.http_status == 404 %}
<i class="material-icons">report_problem</i>&nbsp;&nbsp;URL not found (404), broken/outdated link?
{% elif bookmark.http_status == 301 %}
<i class="material-icons">report_problem</i>&nbsp;&nbsp;HTTP status (301), moved permanently. Use button for new target
{% elif bookmark.http_status == 302 %}
<i class="material-icons">report_problem</i>&nbsp;&nbsp;HTTP status (302), moved temporarily. Use button for new target
{% elif bookmark.http_status == bookmark.HTTP_CONNECTIONERROR %}
<i class="material-icons">report_problem</i>&nbsp;&nbsp;Connection error, server might have been offline at the time of last edit
{% else %}
<i class="material-icons">report_problem</i>&nbsp;&nbsp;HTTP status {{ bookmark.http_status }}
{% endif %}
</span>
</div>
#}
<form method="dialog">
<input type="text" name="">
<p>
<label>
<input type="checkbox" name="strip" id="strip"/>
<span>Strip parameters from url (like <em>?utm_source=social</em> - can break the link!)</span>
</label>
</p>
<div>
<button value="cancel">Cancel</button>
<button @click="$store.digimarks.saveBookmark()">Save</button>
</div>
</form>
</dialog>
</main>
</article>
{% endblock %}

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,77 +0,0 @@
/* label color */
.input-field label {
color: #000;
}
/* label focus color */
.input-field input[type=text]:focus + label {
color: #000;
}
/* 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;
}
/* 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,63 +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.97.8/css/materialize.min.css" type="text/css" rel="stylesheet" media="screen,projection"/>
<link href="{{ url_for('static', filename='css/digimarks.css') }}" type="text/css" rel="stylesheet" media="screen,projection"/>
<script src="https://code.jquery.com/jquery-3.1.1.min.js"></script>
</head>
<body class="grey lighten-4">
<nav class="green darken-3" 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 grey-text lighten-5">
<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.97.8/js/materialize.min.js"></script>
<script src="{{ url_for('static', filename='js/init.js') }}"></script>
</body>
</html>

View File

@@ -1,146 +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 orange lighten-2">
<span class="white-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 yellow-text">star</i></a>
</div>
<div class="chip">
<a href="{{ url_for('bookmarks', userkey=userkey, filtermethod='broken') }}"><i class="tiny material-icons red-text">report_problem</i></a>
</div>
<div class="chip">
<a href="{{ url_for('bookmarks', userkey=userkey, filtermethod='note') }}"><i class="tiny material-icons">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 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 and bookmark.http_status != 304 %}
<div><i class="small material-icons red-text" title="HTTP status {{ bookmark.http_status }}">report_problem</i></div>
{% endif %}
{% if bookmark.starred == True %}
<div><i class="small material-icons yellow-text">star</i></div>
{% 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">
{% for tag in bookmark.tags_list %}
<div class="chip">
<a href="{{ url_for('tag', userkey=userkey, tag=tag) }}">{{ tag }}</a>
</div>
{% endfor %}
<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 class="white-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 red" 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

10
wsgi.py
View File

@@ -1,10 +0,0 @@
# Activate virtualenv
import settings
activate_this = settings.VENV
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)