├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.rst ├── __init__.py ├── digimarks.py ├── example_config ├── apache_vhost.conf ├── settings.py └── uwsgi.ini ├── requirements-dev.in ├── requirements-dev.txt ├── requirements.in ├── requirements.txt ├── setup.py ├── static ├── css │ └── digimarks.css ├── favicon.ico ├── faviconfallback.png ├── favicons │ └── .notempty └── js │ └── init.js ├── templates ├── 404.html ├── base.html ├── bookmarks.html ├── bookmarks.js ├── cards.html ├── edit.html ├── index.html ├── list.html ├── publicbookmarks.html ├── redirect.html └── tags.html └── wsgi.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | # vim 92 | *.swp 93 | 94 | # digimarks 95 | static/favicons 96 | tags 97 | *.db_* 98 | *.db 99 | settings.py 100 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | 8 | ## TODO 9 | 10 | - Sorting of bookmarks 11 | - Sort by title 12 | - Sort by date 13 | - Logging of actions 14 | - Add new way of authentication and editing bookmark collections: 15 | https://github.com/aquatix/digimarks/issues/8 and https://github.com/aquatix/digimarks/issues/9 16 | - Change adding tags to use the MaterializeCSS tags: https://materializecss.com/chips.html 17 | - Do calls to the API endpoint of an existing bookmark when editing properties 18 | (for example to update tags, title and such, also to already suggest title) 19 | - Look into compatibility with del.icio.us, so we can make use of existing browser integration 20 | - Add unit tests 21 | 22 | 23 | ## [Unreleased] 24 | 25 | ### Added 26 | - 'lightblue' theme 27 | - 'black amoled' theme 28 | - Python 3 compatibility (tested with Python 3.5 and 3.6) 29 | - Accept 'HTTP 202' responses as 'OK' 30 | - API: Added endpoint for 'bookmarks', returning JSON 31 | - Top navigation items now have icons too, like the sidebar in mobile view 32 | - Download favicons from RealFaviconGenerator: https://realfavicongenerator.net/api/download_website_favicon 33 | - Added `//findmissingfavicons` endpoint to fill in the blanks in the favicon collection 34 | - Added fallback favicon image (semitransparent digimarks 'M' logo) for bookmarks without a favicon. No more broken images. 35 | - Added theme support for buttons. 36 | - Autocompletion in bookmark search field 37 | - API: search endpoint 38 | - Redirect endpoint for a bookmark, de-referring to its url (`/r//`) 39 | 40 | ### Changed 41 | - Fixed theming of browser chrome in mobile browsers 42 | - Changed link colour of 'dark' theme from blue to orange 43 | - Modified card padding so it fits more content 44 | - Fixed ability to select a checkbox in the add/edit bookmark form 45 | - Made the 404 page theme aware, falls back to default (green) theme 46 | - Fixed admin pages not working anymore due to `settings` object name clash 47 | - On Add/Edit bookmark and encountering a 301, show a better message about automatically changing the URL with the provided button 48 | - Switched to 1.0 (alpha 4) version of MaterializeCSS 49 | - jQuery-b-gone: changed all jQuery code to regular JavaScript code/MaterializeCSS framework 50 | - Fixed colour of filter text in search field for dark themes 51 | - Unified rendering of 'private' and 'public' views of bookmark cards 52 | - Code cleanups, readability fixes 53 | - digimarks User Agent string to correctly identify ourselves, also preventing servers blocking 'bots' 54 | - Text search now also finds matches in the 'note' and 'url' of a bookmark, aside from its title 55 | - Main navigation items ('tags' and 'add bookmark') are now buttons, better visible as action items. 56 | - Removed item limit for feeds 57 | - Form fields are now themed 58 | - Disabled browser autocomplete for forms, which generally interfered with editing bookmarks (e.g., tag field) and the search field, 59 | which has its own autocomplete now 60 | - Changed default theme to the 'freshgreen' variant 61 | - Links are now themed in the proper colours everywhere 62 | 63 | ### Removed 64 | - Removed dependency on jQuery 65 | 66 | 67 | ## [1.1.0] - 2017-07-22 68 | 69 | ### Added 70 | - Show 404 page if bookmark is not found when editing 71 | - Cache buster to force loading of the latest styling 72 | - Theming support, default is 'green' 73 | - Themes need an extra `theme` field in the User table 74 | - Added 'freshgreen' and 'dark' themes 75 | 76 | ### Changed 77 | - Make running in a virtualenv optional 78 | - Fix for misalignment and size of hamburger icon 79 | - Updated Python (pip) dependencies 80 | - Updated MaterializeCSS and jQuery 81 | 82 | ### Removed 83 | - Removed dependency on more_itertools 84 | - Removed dependency on utilkit 85 | 86 | 87 | ## [1.0.0] - 2016-12-29 88 | 89 | - json view of public tag pages, returns all items 90 | - feed (rss/atom) view of public tag pages, returns latest 15 91 | - feed link on public tag page 92 | - Support for bookmarklets 93 | - UI tweaks 94 | - Redesigned cards with bigger favicon. Looks cleaner 95 | - Different favicon service with 60x60px icons 96 | - Prevent duplicate form submission on add/edit bookmark 97 | - Delete bookmark from bookmark card 98 | - Undo delete link in "Bookmark has been deleted" message 99 | - Delete public tag page 100 | - On tags overview page: 101 | - Show which tags have public pages, with link 102 | - How many bookmarks each tag has 103 | - Statistics on: 104 | - total tags 105 | - number of public tag pages 106 | - total number of bookmarks 107 | - number of starred bookmarks 108 | - number of bookmarks with a non-OK http status 109 | - number of deleted bookmarks 110 | - Filter on 'star' status, 'broken' status (non-http-200-OK) 111 | - Bookmark can have a note now 112 | - Note icon on card with text in title (desktop browser) 113 | - Filter on bookmarks with a note 114 | - Show url domain name along with 'no title' for items without title 115 | - Catch connection timeouts and such 116 | - Open in new tab/window, prevent 117 | http://davidebove.com/blog/2016/05/05/target_blank-the-vulnerability-in-your-browser/ 118 | - Put the tag selection in a collapsible element to prevent clutter in edit window 119 | - Updated MaterializeCSS and jQuery 120 | 121 | 122 | ## [0.2.0] - 2016-08-02 123 | 124 | - Favicon courtesy Freepik on flaticon.com 125 | - Tag tags for easy adding of tags 126 | - Updates to MaterializeCSS and jQuery 127 | - Several bug- and code style fixes 128 | - Styling tweaks 129 | - Added 'Add bookmark' FAB to the bookmarks overview 130 | - Option to strip parameters from url (like '?utm_source=social') 131 | 132 | 133 | ## [0.1.0] - 2016-07-26 134 | 135 | - Initial release 136 | - Flask application with functionality to add users, add and edit bookmarks, 137 | tag bookmarks, group by tags, create tag-based public pages (read-only, to be shared 138 | with interested parties) 139 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | digimarks 2 | ========= 3 | 4 | |PyPI version| |PyPI license| |Code health| |Codacy| 5 | 6 | Simple bookmarking service, using a SQLite database to store bookmarks, supporting tags and automatic title fetching. 7 | 8 | 9 | Installation 10 | ------------ 11 | 12 | From PyPI 13 | ~~~~~~~~~ 14 | 15 | Assuming you already are inside a virtualenv: 16 | 17 | .. code-block:: bash 18 | 19 | pip install digimarks 20 | 21 | 22 | From Git 23 | ~~~~~~~~ 24 | 25 | Create a new virtualenv (if you are not already in one) and install the 26 | necessary packages: 27 | 28 | .. code-block:: bash 29 | 30 | git clone https://github.com/aquatix/digimarks.git 31 | cd digimarks 32 | mkvirtualenv digimarks # or whatever project you are working on 33 | pip install -r requirements.txt 34 | 35 | 36 | Usage / example configuration 37 | ----------------------------- 38 | 39 | Copy ``settings.py`` from example_config to the parent directory and 40 | configure to your needs (*at the least* change the value of `SYSTEMKEY`). 41 | 42 | 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). 43 | 44 | Run digimarks as a service under nginx or apache and call the appropriate 45 | url's when wanted. 46 | 47 | Url's are of the form https://marks.example.com// 48 | 49 | 50 | Bookmarklet 51 | ~~~~~~~~~~~ 52 | 53 | To easily save a link from your browser, open its bookmark manager and create a new bookmark with as url: 54 | 55 | .. code-block:: javascript 56 | 57 | javascript:location.href='http://marks.example.com/1234567890abcdef/add?url='+encodeURIComponent(location.href); 58 | 59 | 60 | Creating a new user 61 | ------------------- 62 | 63 | After having set up the ```settings.py``` as under Usage, you can add a new user, by going to this path on your digimarks server: 64 | 65 | //adduser 66 | 67 | where `secretkey` is the value set in settings.SYSTEMKEY 68 | 69 | digimarks will then redirect to the bookmarks overview page of the new user. Please remember the user key (the hash in the url), as it will not be visible otherwise in the interface. 70 | 71 | If you for whatever reason would lose this user key, just either look on the console (or webserver logs) where the list of available user keys is printed on digimarks startup, or open bookmarks.db with a SQLite editor. 72 | 73 | 74 | Server configuration 75 | ~~~~~~~~~~~~~~~~~~~~ 76 | 77 | * `vhost for Apache2.4`_ 78 | * `uwsgi.ini`_ 79 | 80 | 81 | What's new? 82 | ----------- 83 | 84 | See the `Changelog`_. 85 | 86 | 87 | Attributions 88 | ------------ 89 | 90 | 'M' favicon by `Freepik`_. 91 | 92 | 93 | .. _digimarks: https://github.com/aquatix/digimarks 94 | .. _webhook: https://en.wikipedia.org/wiki/Webhook 95 | .. |PyPI version| image:: https://img.shields.io/pypi/v/digimarks.svg 96 | :target: https://pypi.python.org/pypi/digimarks/ 97 | .. |PyPI license| image:: https://img.shields.io/github/license/aquatix/digimarks.svg 98 | :target: https://pypi.python.org/pypi/digimarks/ 99 | .. |Code health| image:: https://landscape.io/github/aquatix/digimarks/master/landscape.svg?style=flat 100 | :target: https://landscape.io/github/aquatix/digimarks/master 101 | :alt: Code Health 102 | .. |Codacy| image:: https://api.codacy.com/project/badge/Grade/9a34319d917b43219a29e59e9ac75e3b 103 | :alt: Codacy Badge 104 | :target: https://app.codacy.com/app/aquatix/digimarks?utm_source=github.com&utm_medium=referral&utm_content=aquatix/digimarks&utm_campaign=badger 105 | .. _hook settings: https://github.com/aquatix/digimarks/blob/master/example_config/examples.yaml 106 | .. _vhost for Apache2.4: https://github.com/aquatix/digimarks/blob/master/example_config/apache_vhost.conf 107 | .. _uwsgi.ini: https://github.com/aquatix/digimarks/blob/master/example_config/uwsgi.ini 108 | .. _Changelog: https://github.com/aquatix/digimarks/blob/master/CHANGELOG.md 109 | .. _Freepik: http://www.flaticon.com/free-icon/letter-m_2041 110 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aquatix/digimarks/db091ae02e601cbe1de36f615e50b15859435cd5/__init__.py -------------------------------------------------------------------------------- /digimarks.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import binascii 4 | import datetime 5 | import gzip 6 | import hashlib 7 | import os 8 | import shutil 9 | import sys 10 | 11 | import bs4 12 | import requests 13 | from dateutil import tz 14 | from feedgen.feed import FeedGenerator 15 | from flask import (Flask, abort, jsonify, make_response, redirect, 16 | render_template, request, url_for) 17 | from peewee import * # noqa 18 | 19 | try: 20 | # Python 3 21 | from urllib.parse import urljoin, urlparse, urlunparse 22 | except ImportError: 23 | # Python 2 24 | from urlparse import urljoin, urlparse, urlunparse 25 | 26 | 27 | DIGIMARKS_USER_AGENT = 'digimarks/1.2.0-dev' 28 | 29 | DEFAULT_THEME = 'freshgreen' 30 | themes = { 31 | 'green': { 32 | 'BROWSERCHROME': '#2e7d32', # green darken-2 33 | 'BODY': 'grey lighten-4', 34 | 'TEXT': 'black-text', 35 | 'TEXTHEX': '#000', 36 | 'NAV': 'green darken-3', 37 | 'PAGEHEADER': 'grey-text lighten-5', 38 | 'MESSAGE_BACKGROUND': 'orange lighten-2', 39 | 'MESSAGE_TEXT': 'white-text', 40 | 'ERRORMESSAGE_BACKGROUND': 'red darken-1', 41 | 'ERRORMESSAGE_TEXT': 'white-text', 42 | 'BUTTON': '#1b5e20', # green darken-4 43 | 'BUTTON_ACTIVE': '#43a047', # green darken-1 44 | 'LINK_TEXT': '#1b5e20', # green darken-4 45 | 'CARD_BACKGROUND': 'green darken-3', 46 | 'CARD_TEXT': 'white-text', 47 | 'CARD_LINK': '#FFF', # white-text 48 | 'CHIP_TEXT': '#1b5e20', # green darken-4 49 | 'FAB': 'red', 50 | 51 | 'STAR': 'yellow-text', 52 | 'PROBLEM': 'red-text', 53 | 'COMMENT': '', 54 | }, 55 | 'freshgreen': { 56 | 'BROWSERCHROME': '#43a047', # green darken-1 57 | 'BODY': 'grey lighten-5', 58 | 'TEXT': 'black-text', 59 | 'TEXTHEX': '#000', 60 | 'NAV': 'green darken-1', 61 | 'PAGEHEADER': 'grey-text lighten-5', 62 | 'MESSAGE_BACKGROUND': 'orange lighten-2', 63 | 'MESSAGE_TEXT': 'white-text', 64 | 'ERRORMESSAGE_BACKGROUND': 'red darken-1', 65 | 'ERRORMESSAGE_TEXT': 'white-text', 66 | 'BUTTON': '#1b5e20', # green darken-4 67 | 'BUTTON_ACTIVE': '#43a047', # green darken-1 68 | 'LINK_TEXT': '#1b5e20', # green darken-4 69 | 'CARD_BACKGROUND': 'green darken-1', 70 | 'CARD_TEXT': 'white-text', 71 | 'CARD_LINK': '#FFF', # white-text 72 | 'CHIP_TEXT': '#1b5e20', # green darken-4 73 | 'FAB': 'red', 74 | 75 | 'STAR': 'yellow-text', 76 | 'PROBLEM': 'red-text', 77 | 'COMMENT': '', 78 | }, 79 | 'lightblue': { 80 | 'BROWSERCHROME': '#0288d1', # light-blue darken-2 81 | 'BODY': 'white', 82 | 'TEXT': 'black-text', 83 | 'TEXTHEX': '#000', 84 | 'NAV': 'light-blue darken-2', 85 | 'PAGEHEADER': 'grey-text lighten-5', 86 | 'MESSAGE_BACKGROUND': 'orange lighten-2', 87 | 'MESSAGE_TEXT': 'white-text', 88 | 'ERRORMESSAGE_BACKGROUND': 'red darken-1', 89 | 'ERRORMESSAGE_TEXT': 'white-text', 90 | 'BUTTON': '#fb8c00', # orange darken-1 91 | 'BUTTON_ACTIVE': '#ffa726', # orange lighten-1 92 | 'LINK_TEXT': '#FFF', # white 93 | 'CARD_BACKGROUND': 'light-blue lighten-2', 94 | 'CARD_TEXT': 'black-text', 95 | 'CARD_LINK': '#263238', # blue-grey-text darken-4 96 | 'CHIP_TEXT': '#FFF', # white 97 | 'FAB': 'light-blue darken-4', 98 | 99 | 'STAR': 'yellow-text', 100 | 'PROBLEM': 'red-text', 101 | 'COMMENT': '', 102 | }, 103 | 'dark': { 104 | 'BROWSERCHROME': '#212121', # grey darken-4 105 | 'BODY': 'grey darken-4', 106 | 'TEXT': 'grey-text lighten-1', 107 | 'TEXTHEX': '#bdbdbd', 108 | 'NAV': 'grey darken-3', 109 | 'PAGEHEADER': 'grey-text lighten-1', 110 | 'MESSAGE_BACKGROUND': 'orange lighten-2', 111 | 'MESSAGE_TEXT': 'white-text', 112 | 'ERRORMESSAGE_BACKGROUND': 'red darken-1', 113 | 'ERRORMESSAGE_TEXT': 'white-text', 114 | 'BUTTON': '#fb8c00', # orange darken-1 115 | 'BUTTON_ACTIVE': '#ffa726', # orange lighten-1 116 | 'LINK_TEXT': '#fb8c00', # orange-text darken-1 117 | 'CARD_BACKGROUND': 'grey darken-3', 118 | 'CARD_TEXT': 'grey-text lighten-1', 119 | 'CARD_LINK': '#fb8c00', # orange-text darken-1 120 | 'CHIP_TEXT': '#fb8c00', # orange-text darken-1 121 | 'FAB': 'red', 122 | 123 | 'STAR': 'yellow-text', 124 | 'PROBLEM': 'red-text', 125 | 'COMMENT': '', 126 | }, 127 | 'amoled': { 128 | 'BROWSERCHROME': '#000', # grey darken-4 129 | 'BODY': 'black', 130 | 'TEXT': 'grey-text lighten-1', 131 | 'TEXTHEX': '#bdbdbd', 132 | 'NAV': 'grey darken-3', 133 | 'PAGEHEADER': 'grey-text lighten-1', 134 | 'MESSAGE_BACKGROUND': 'orange lighten-2', 135 | 'MESSAGE_TEXT': 'white-text', 136 | 'ERRORMESSAGE_BACKGROUND': 'red darken-1', 137 | 'ERRORMESSAGE_TEXT': 'white-text', 138 | 'BUTTON': '#fb8c00', # orange darken-1 139 | 'BUTTON_ACTIVE': '#ffa726', # orange lighten-1 140 | 'LINK_TEXT': '#fb8c00', # orange-text darken-1 141 | 'CARD_BACKGROUND': 'grey darken-3', 142 | 'CARD_TEXT': 'grey-text lighten-1', 143 | 'CARD_LINK': '#fb8c00', # orange-text darken-1 144 | 'CHIP_TEXT': '#fb8c00', # orange-text darken-1 145 | 'FAB': 'red', 146 | 147 | 'STAR': 'yellow-text', 148 | 'PROBLEM': 'red-text', 149 | 'COMMENT': '', 150 | } 151 | } 152 | 153 | try: 154 | import settings 155 | except ImportError: 156 | print('Copy settings_example.py to settings.py and set the configuration to your own preferences') 157 | sys.exit(1) 158 | 159 | # app configuration 160 | APP_ROOT = os.path.dirname(os.path.realpath(__file__)) 161 | MEDIA_ROOT = os.path.join(APP_ROOT, 'static') 162 | MEDIA_URL = '/static/' 163 | DATABASE = { 164 | 'name': os.path.join(APP_ROOT, 'bookmarks.db'), 165 | 'engine': 'peewee.SqliteDatabase', 166 | } 167 | #PHANTOM = '/usr/local/bin/phantomjs' 168 | #SCRIPT = os.path.join(APP_ROOT, 'screenshot.js') 169 | 170 | # create our flask app and a database wrapper 171 | app = Flask(__name__) 172 | app.config.from_object(__name__) 173 | database = SqliteDatabase(os.path.join(APP_ROOT, 'bookmarks.db')) 174 | 175 | # Strip unnecessary whitespace due to jinja2 codeblocks 176 | app.jinja_env.trim_blocks = True 177 | app.jinja_env.lstrip_blocks = True 178 | 179 | # set custom url for the app, for example '/bookmarks' 180 | try: 181 | app.config['APPLICATION_ROOT'] = settings.APPLICATION_ROOT 182 | except AttributeError: 183 | pass 184 | 185 | # Cache the tags 186 | all_tags = {} 187 | usersettings = {} 188 | 189 | 190 | def ifilterfalse(predicate, iterable): 191 | # ifilterfalse(lambda x: x%2, range(10)) --> 0 2 4 6 8 192 | if predicate is None: 193 | predicate = bool 194 | for x in iterable: 195 | if not predicate(x): 196 | yield x 197 | 198 | 199 | def unique_everseen(iterable, key=None): 200 | "List unique elements, preserving order. Remember all elements ever seen." 201 | # unique_everseen('AAAABBBCCDAABBB') --> A B C D 202 | # unique_everseen('ABBCcAD', str.lower) --> A B C D 203 | seen = set() 204 | seen_add = seen.add 205 | if key is None: 206 | for element in ifilterfalse(seen.__contains__, iterable): 207 | seen_add(element) 208 | yield element 209 | else: 210 | for element in iterable: 211 | k = key(element) 212 | if k not in seen: 213 | seen_add(k) 214 | yield element 215 | 216 | def clean_tags(tags_list): 217 | tags_res = [x.strip() for x in tags_list] 218 | tags_res = list(unique_everseen(tags_res)) 219 | tags_res.sort() 220 | if tags_res and tags_res[0] == '': 221 | del tags_res[0] 222 | return tags_res 223 | 224 | 225 | magic_dict = { 226 | b"\x1f\x8b\x08": "gz", 227 | b"\x42\x5a\x68": "bz2", 228 | b"\x50\x4b\x03\x04": "zip" 229 | } 230 | 231 | max_len = max(len(x) for x in magic_dict) 232 | 233 | def file_type(filename): 234 | with open(filename, "rb") as f: 235 | file_start = f.read(max_len) 236 | for magic, filetype in magic_dict.items(): 237 | if file_start.startswith(magic): 238 | return filetype 239 | return "no match" 240 | 241 | 242 | class BaseModel(Model): 243 | class Meta: 244 | database = database 245 | 246 | 247 | class User(BaseModel): 248 | """ User account """ 249 | username = CharField() 250 | key = CharField() 251 | theme = CharField(default=DEFAULT_THEME) 252 | created_date = DateTimeField(default=datetime.datetime.now) 253 | 254 | def generate_key(self): 255 | """ Generate userkey """ 256 | self.key = binascii.hexlify(os.urandom(24)) 257 | return self.key 258 | 259 | 260 | class Bookmark(BaseModel): 261 | """ Bookmark instance, connected to User """ 262 | # Foreign key to User 263 | userkey = CharField() 264 | 265 | title = CharField(default='') 266 | url = CharField() 267 | note = TextField(default='') 268 | #image = CharField(default='') 269 | url_hash = CharField(default='') 270 | tags = CharField(default='') 271 | starred = BooleanField(default=False) 272 | 273 | # Website (domain) favicon 274 | favicon = CharField(null=True) 275 | 276 | # Status code: 200 is OK, 404 is not found, for example (showing an error) 277 | HTTP_CONNECTIONERROR = 0 278 | HTTP_OK = 200 279 | HTTP_ACCEPTED = 202 280 | HTTP_MOVEDTEMPORARILY = 304 281 | HTTP_NOTFOUND = 404 282 | 283 | http_status = IntegerField(default=200) 284 | redirect_uri = None 285 | 286 | created_date = DateTimeField(default=datetime.datetime.now) 287 | modified_date = DateTimeField(null=True) 288 | deleted_date = DateTimeField(null=True) 289 | 290 | # Bookmark status; deleting doesn't remove from DB 291 | VISIBLE = 0 292 | DELETED = 1 293 | status = IntegerField(default=VISIBLE) 294 | 295 | 296 | class Meta: 297 | ordering = (('created_date', 'desc'),) 298 | 299 | def set_hash(self): 300 | """ Generate hash """ 301 | self.url_hash = hashlib.md5(self.url.encode('utf-8')).hexdigest() 302 | 303 | def set_title_from_source(self): 304 | """ Request the title by requesting the source url """ 305 | try: 306 | result = requests.get(self.url, headers={'User-Agent': DIGIMARKS_USER_AGENT}) 307 | self.http_status = result.status_code 308 | except: 309 | # For example 'MissingSchema: Invalid URL 'abc': No schema supplied. Perhaps you meant http://abc?' 310 | self.http_status = 404 311 | if self.http_status == 200 or self.http_status == 202: 312 | html = bs4.BeautifulSoup(result.text, 'html.parser') 313 | try: 314 | self.title = html.title.text.strip() 315 | except AttributeError: 316 | self.title = '' 317 | return self.title 318 | 319 | def set_status_code(self): 320 | """ Check the HTTP status of the url, as it might not exist for example """ 321 | try: 322 | result = requests.head(self.url, headers={'User-Agent': DIGIMARKS_USER_AGENT}) 323 | self.http_status = result.status_code 324 | except requests.ConnectionError: 325 | self.http_status = self.HTTP_CONNECTIONERROR 326 | return self.http_status 327 | 328 | def _set_favicon_with_iconsbetterideaorg(self, domain): 329 | """ Fetch favicon for the domain """ 330 | fileextension = '.png' 331 | meta = requests.head( 332 | 'http://icons.better-idea.org/icon?size=60&url=' + domain, 333 | allow_redirects=True, 334 | headers={'User-Agent': DIGIMARKS_USER_AGENT} 335 | ) 336 | if meta.url[-3:].lower() == 'ico': 337 | fileextension = '.ico' 338 | response = requests.get( 339 | 'http://icons.better-idea.org/icon?size=60&url=' + domain, 340 | stream=True, 341 | headers={'User-Agent': DIGIMARKS_USER_AGENT} 342 | ) 343 | filename = os.path.join(MEDIA_ROOT, 'favicons/' + domain + fileextension) 344 | with open(filename, 'wb') as out_file: 345 | shutil.copyfileobj(response.raw, out_file) 346 | del response 347 | filetype = file_type(filename) 348 | if filetype == 'gz': 349 | # decompress 350 | orig = gzip.GzipFile(filename, 'rb') 351 | origcontent = orig.read() 352 | orig.close() 353 | os.remove(filename) 354 | with open(filename, 'wb') as new: 355 | new.write(origcontent) 356 | self.favicon = domain + fileextension 357 | 358 | def _set_favicon_with_realfavicongenerator(self, domain): 359 | """ Fetch favicon for the domain """ 360 | response = requests.get( 361 | 'https://realfavicongenerator.p.rapidapi.com/favicon/icon?platform=android_chrome&site=' + domain, 362 | stream=True, 363 | headers={'User-Agent': DIGIMARKS_USER_AGENT, 'X-Mashape-Key': settings.MASHAPE_API_KEY} 364 | ) 365 | if response.status_code == 404: 366 | # Fall back to desktop favicon 367 | response = requests.get( 368 | 'https://realfavicongenerator.p.rapidapi.com/favicon/icon?platform=desktop&site=' + domain, 369 | stream=True, 370 | headers={'User-Agent': DIGIMARKS_USER_AGENT, 'X-Mashape-Key': settings.MASHAPE_API_KEY} 371 | ) 372 | # Debug for the moment 373 | print(domain) 374 | print(response.headers) 375 | if 'Content-Length' in response.headers and response.headers['Content-Length'] == '0': 376 | # No favicon found, likely 377 | print('Skipping this favicon, needs fallback') 378 | return 379 | # Default to 'image/png' 380 | fileextension = '.png' 381 | if response.headers['content-type'] == 'image/jpeg': 382 | fileextension = '.jpg' 383 | if response.headers['content-type'] == 'image/x-icon': 384 | fileextension = '.ico' 385 | filename = os.path.join(MEDIA_ROOT, 'favicons/' + domain + fileextension) 386 | with open(filename, 'wb') as out_file: 387 | shutil.copyfileobj(response.raw, out_file) 388 | del response 389 | filetype = file_type(filename) 390 | if filetype == 'gz': 391 | # decompress 392 | orig = gzip.GzipFile(filename, 'rb') 393 | origcontent = orig.read() 394 | orig.close() 395 | os.remove(filename) 396 | with open(filename, 'wb') as new: 397 | new.write(origcontent) 398 | self.favicon = domain + fileextension 399 | 400 | def set_favicon(self): 401 | """ Fetch favicon for the domain """ 402 | u = urlparse(self.url) 403 | domain = u.netloc 404 | if os.path.isfile(os.path.join(MEDIA_ROOT, 'favicons/' + domain + '.png')): 405 | # If file exists, don't re-download it 406 | self.favicon = domain + '.png' 407 | return 408 | if os.path.isfile(os.path.join(MEDIA_ROOT, 'favicons/' + domain + '.ico')): 409 | # If file exists, don't re-download it 410 | self.favicon = domain + '.ico' 411 | return 412 | #self._set_favicon_with_iconsbetterideaorg(domain) 413 | self._set_favicon_with_realfavicongenerator(domain) 414 | 415 | def set_tags(self, newtags): 416 | """ Set tags from `tags`, strip and sort them """ 417 | tags_split = newtags.split(',') 418 | tags_clean = clean_tags(tags_split) 419 | self.tags = ','.join(tags_clean) 420 | 421 | def get_redirect_uri(self): 422 | if self.redirect_uri: 423 | return self.redirect_uri 424 | if self.http_status == 301 or self.http_status == 302: 425 | result = requests.head(self.url, allow_redirects=True, headers={'User-Agent': DIGIMARKS_USER_AGENT}) 426 | self.http_status = result.status_code 427 | self.redirect_uri = result.url 428 | return result.url 429 | return None 430 | 431 | def get_uri_domain(self): 432 | parsed = urlparse(self.url) 433 | return parsed.hostname 434 | 435 | @classmethod 436 | def strip_url_params(cls, url): 437 | parsed = urlparse(url) 438 | return urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, '', parsed.fragment)) 439 | 440 | @property 441 | def tags_list(self): 442 | """ Get the tags as a list, iterable in template """ 443 | if self.tags: 444 | return self.tags.split(',') 445 | return [] 446 | 447 | def to_dict(self): 448 | result = { 449 | 'title': self.title, 450 | 'url': self.url, 451 | 'created': self.created_date.strftime('%Y-%m-%d %H:%M:%S'), 452 | 'url_hash': self.url_hash, 453 | 'tags': self.tags, 454 | } 455 | return result 456 | 457 | @property 458 | def serialize(self): 459 | return self.to_dict() 460 | 461 | 462 | class PublicTag(BaseModel): 463 | """ Publicly shared tag """ 464 | tagkey = CharField() 465 | userkey = CharField() 466 | tag = CharField() 467 | created_date = DateTimeField(default=datetime.datetime.now) 468 | 469 | def generate_key(self): 470 | """ Generate hash-based key for publicly shared tag """ 471 | self.tagkey = binascii.hexlify(os.urandom(16)) 472 | 473 | 474 | def get_tags_for_user(userkey): 475 | """ Extract all tags from the bookmarks """ 476 | bookmarks = Bookmark.select().filter(Bookmark.userkey == userkey, Bookmark.status == Bookmark.VISIBLE) 477 | tags = [] 478 | for bookmark in bookmarks: 479 | tags += bookmark.tags_list 480 | return clean_tags(tags) 481 | 482 | 483 | def get_cached_tags(userkey): 484 | """ Fail-safe way to get the cached tags for `userkey` """ 485 | try: 486 | return all_tags[userkey] 487 | except KeyError: 488 | return [] 489 | 490 | 491 | def get_theme(userkey): 492 | try: 493 | usertheme = usersettings[userkey]['theme'] 494 | return themes[usertheme] 495 | except KeyError: 496 | return themes[DEFAULT_THEME] # default 497 | 498 | 499 | def make_external(url): 500 | return urljoin(request.url_root, url) 501 | 502 | 503 | def _find_bookmarks(userkey, filter_text): 504 | return Bookmark.select().where( 505 | Bookmark.userkey == userkey, 506 | ( 507 | Bookmark.title.contains(filter_text) | 508 | Bookmark.url.contains(filter_text) | 509 | Bookmark.note.contains(filter_text) 510 | ), 511 | Bookmark.status == Bookmark.VISIBLE 512 | ).order_by(Bookmark.created_date.desc()) 513 | 514 | 515 | @app.errorhandler(404) 516 | def page_not_found(e): 517 | theme = themes[DEFAULT_THEME] 518 | return render_template('404.html', error=e, theme=theme), 404 519 | 520 | 521 | @app.route('/') 522 | def index(): 523 | """ Homepage, point visitors to project page """ 524 | theme = themes[DEFAULT_THEME] 525 | return render_template('index.html', theme=theme) 526 | 527 | 528 | def get_bookmarks(userkey, filtermethod=None, sortmethod=None): 529 | """ User homepage, list their bookmarks, optionally filtered and/or sorted """ 530 | #return object_list('bookmarks.html', Bookmark.select()) 531 | #user = User.select(key=userkey) 532 | #if user: 533 | # bookmarks = Bookmark.select(User=user) 534 | # return render_template('bookmarks.html', bookmarks) 535 | #else: 536 | # abort(404) 537 | message = request.args.get('message') 538 | bookmarktags = get_cached_tags(userkey) 539 | 540 | filter_text = '' 541 | if request.form: 542 | filter_text = request.form['filter_text'] 543 | 544 | filter_starred = False 545 | if filtermethod and filtermethod.lower() == 'starred': 546 | filter_starred = True 547 | 548 | filter_broken = False 549 | if filtermethod and filtermethod.lower() == 'broken': 550 | filter_broken = True 551 | 552 | filter_note = False 553 | if filtermethod and filtermethod.lower() == 'note': 554 | filter_note = True 555 | 556 | if filter_text: 557 | bookmarks = _find_bookmarks(userkey, filter_text) 558 | elif filter_starred: 559 | bookmarks = Bookmark.select().where(Bookmark.userkey == userkey, 560 | Bookmark.starred).order_by(Bookmark.created_date.desc()) 561 | elif filter_broken: 562 | bookmarks = Bookmark.select().where(Bookmark.userkey == userkey, 563 | Bookmark.http_status != 200).order_by(Bookmark.created_date.desc()) 564 | elif filter_note: 565 | bookmarks = Bookmark.select().where(Bookmark.userkey == userkey, 566 | Bookmark.note != '').order_by(Bookmark.created_date.desc()) 567 | else: 568 | bookmarks = Bookmark.select().where( 569 | Bookmark.userkey == userkey, 570 | Bookmark.status == Bookmark.VISIBLE 571 | ).order_by(Bookmark.created_date.desc()) 572 | 573 | return bookmarks, bookmarktags, filter_text, message 574 | 575 | 576 | @app.route('/', methods=['GET', 'POST']) 577 | @app.route('//filter/', methods=['GET', 'POST']) 578 | @app.route('//sort/', methods=['GET', 'POST']) 579 | @app.route('//', methods=['GET', 'POST']) 580 | @app.route('///filter/', methods=['GET', 'POST']) 581 | @app.route('///sort/', methods=['GET', 'POST']) 582 | def bookmarks_page(userkey, filtermethod=None, sortmethod=None, show_as='cards'): 583 | bookmarks, bookmarktags, filter_text, message = get_bookmarks(userkey, filtermethod, sortmethod) 584 | theme = get_theme(userkey) 585 | return render_template( 586 | 'bookmarks.html', 587 | bookmarks=bookmarks, 588 | userkey=userkey, 589 | tags=bookmarktags, 590 | filter_text=filter_text, 591 | message=message, 592 | theme=theme, 593 | editable=True, # bookmarks can be edited 594 | showtags=True, # tags should be shown with the bookmarks 595 | filtermethod=filtermethod, 596 | sortmethod=sortmethod, 597 | show_as=show_as, # show list of bookmarks instead of cards 598 | ) 599 | 600 | 601 | @app.route('//js') 602 | def bookmarks_js(userkey): 603 | """ Return list of bookmarks with their favicons, to be used for autocompletion """ 604 | bookmarks = Bookmark.select().where( 605 | Bookmark.userkey == userkey, 606 | Bookmark.status == Bookmark.VISIBLE 607 | ).order_by(Bookmark.created_date.desc()) 608 | resp = make_response(render_template( 609 | 'bookmarks.js', 610 | bookmarks=bookmarks 611 | )) 612 | resp.headers['Content-type'] = 'text/javascript; charset=utf-8' 613 | return resp 614 | 615 | 616 | @app.route('/r//') 617 | def bookmark_redirect(userkey, urlhash): 618 | """ Securely redirect a bookmark to its url, stripping referrer (if browser plays nice) """ 619 | # @TODO: add counter to this bookmark 620 | try: 621 | bookmark = Bookmark.get( 622 | Bookmark.url_hash == urlhash, 623 | Bookmark.userkey == userkey, 624 | Bookmark.status == Bookmark.VISIBLE 625 | ) 626 | except Bookmark.DoesNotExist: 627 | abort(404) 628 | return render_template('redirect.html', url=bookmark.url) 629 | 630 | 631 | @app.route('/api/v1/', methods=['GET', 'POST']) 632 | @app.route('/api/v1//filter/', methods=['GET', 'POST']) 633 | @app.route('/api/v1//sort/', methods=['GET', 'POST']) 634 | def bookmarks_json(userkey, filtermethod=None, sortmethod=None): 635 | bookmarks, bookmarktags, filter_text, message = get_bookmarks(userkey, filtermethod, sortmethod) 636 | 637 | bookmarkslist = [i.serialize for i in bookmarks] 638 | 639 | the_data = { 640 | 'bookmarks': bookmarkslist, 641 | 'tags': bookmarktags, 642 | 'filter_text': filter_text, 643 | 'message': message, 644 | 'userkey': userkey, 645 | } 646 | return jsonify(the_data) 647 | 648 | 649 | @app.route('/api/v1//') 650 | def bookmark_json(userkey, urlhash): 651 | """ Serialise bookmark to json """ 652 | try: 653 | bookmark = Bookmark.get( 654 | Bookmark.url_hash == urlhash, 655 | Bookmark.userkey == userkey, 656 | Bookmark.status == Bookmark.VISIBLE 657 | ) 658 | return jsonify(bookmark.to_dict()) 659 | except Bookmark.DoesNotExist: 660 | return jsonify({'message': 'Bookmark not found', 'status': 'error 404'}) 661 | 662 | 663 | @app.route('/api/v1//search/') 664 | def search_bookmark_titles_json(userkey, filter_text): 665 | """ Serialise bookmark to json """ 666 | bookmarks = _find_bookmarks(userkey, filter_text) 667 | result = [] 668 | for bookmark in bookmarks: 669 | result.append(bookmark.to_dict()) 670 | return jsonify(result) 671 | 672 | 673 | @app.route('//') 674 | @app.route('///edit') 675 | def editbookmark(userkey, urlhash): 676 | """ Bookmark edit form """ 677 | # bookmark = getbyurlhash() 678 | try: 679 | bookmark = Bookmark.get(Bookmark.url_hash == urlhash, Bookmark.userkey == userkey) 680 | except Bookmark.DoesNotExist: 681 | abort(404) 682 | message = request.args.get('message') 683 | tags = get_cached_tags(userkey) 684 | if not bookmark.note: 685 | # Workaround for when an existing bookmark has a null note 686 | bookmark.note = '' 687 | theme = get_theme(userkey) 688 | return render_template( 689 | 'edit.html', 690 | action='Edit bookmark', 691 | userkey=userkey, 692 | bookmark=bookmark, 693 | message=message, 694 | formaction='edit', 695 | tags=tags, 696 | theme=theme 697 | ) 698 | 699 | 700 | @app.route('//add') 701 | def addbookmark(userkey): 702 | """ Bookmark add form """ 703 | url = request.args.get('url') 704 | if not url: 705 | url = '' 706 | if request.args.get('referrer'): 707 | url = request.referrer 708 | bookmark = Bookmark(title='', url=url, tags='') 709 | message = request.args.get('message') 710 | tags = get_cached_tags(userkey) 711 | theme = get_theme(userkey) 712 | return render_template( 713 | 'edit.html', 714 | action='Add bookmark', 715 | userkey=userkey, 716 | bookmark=bookmark, 717 | tags=tags, 718 | message=message, 719 | theme=theme 720 | ) 721 | 722 | 723 | def updatebookmark(userkey, urlhash=None): 724 | """ Add (no urlhash) or edit (urlhash is set) a bookmark """ 725 | title = request.form.get('title') 726 | url = request.form.get('url') 727 | tags = request.form.get('tags') 728 | note = request.form.get('note') 729 | starred = False 730 | if request.form.get('starred'): 731 | starred = True 732 | strip_params = False 733 | if request.form.get('strip'): 734 | strip_params = True 735 | 736 | if url and not urlhash: 737 | # New bookmark 738 | bookmark, created = Bookmark.get_or_create(url=url, userkey=userkey) 739 | if not created: 740 | message = 'Existing bookmark, did not overwrite with new values' 741 | return redirect(url_for('editbookmark', userkey=userkey, urlhash=bookmark.url_hash, message=message)) 742 | elif url: 743 | # Existing bookmark, get from DB 744 | bookmark = Bookmark.get(Bookmark.userkey == userkey, Bookmark.url_hash == urlhash) 745 | # Editing this bookmark, set modified_date to now 746 | bookmark.modified_date = datetime.datetime.now() 747 | else: 748 | # No url was supplied, abort. @TODO: raise exception? 749 | return None 750 | 751 | bookmark.title = title 752 | if strip_params: 753 | url = Bookmark.strip_url_params(url) 754 | bookmark.url = url 755 | bookmark.starred = starred 756 | bookmark.set_tags(tags) 757 | bookmark.note = note 758 | bookmark.set_hash() 759 | #bookmark.fetch_image() 760 | if not title: 761 | # Title was empty, automatically fetch it from the url, will also update the status code 762 | bookmark.set_title_from_source() 763 | else: 764 | bookmark.set_status_code() 765 | 766 | if bookmark.http_status == 200 or bookmark.http_status == 202: 767 | try: 768 | bookmark.set_favicon() 769 | except IOError: 770 | # Icon file could not be saved possibly, don't bail completely 771 | pass 772 | 773 | bookmark.save() 774 | return bookmark 775 | 776 | 777 | @app.route('//adding', methods=['GET', 'POST']) 778 | #@app.route('//adding') 779 | def addingbookmark(userkey): 780 | """ Add the bookmark from form submit by /add """ 781 | tags = get_cached_tags(userkey) 782 | 783 | if request.method == 'POST': 784 | bookmark = updatebookmark(userkey) 785 | if not bookmark: 786 | return redirect(url_for('addbookmark', userkey=userkey, message='No url provided', tags=tags)) 787 | if type(bookmark).__name__ == 'Response': 788 | return bookmark 789 | all_tags[userkey] = get_tags_for_user(userkey) 790 | return redirect(url_for('editbookmark', userkey=userkey, urlhash=bookmark.url_hash)) 791 | return redirect(url_for('addbookmark', userkey=userkey, tags=tags)) 792 | 793 | 794 | @app.route('///editing', methods=['GET', 'POST']) 795 | def editingbookmark(userkey, urlhash): 796 | """ Edit the bookmark from form submit """ 797 | 798 | if request.method == 'POST': 799 | bookmark = updatebookmark(userkey, urlhash=urlhash) 800 | all_tags[userkey] = get_tags_for_user(userkey) 801 | return redirect(url_for('editbookmark', userkey=userkey, urlhash=bookmark.url_hash)) 802 | return redirect(url_for('editbookmark', userkey=userkey, urlhash=urlhash)) 803 | 804 | 805 | @app.route('///delete', methods=['GET', 'POST']) 806 | def deletingbookmark(userkey, urlhash): 807 | """ Delete the bookmark from form submit by /delete """ 808 | query = Bookmark.update(status=Bookmark.DELETED).where(Bookmark.userkey == userkey, Bookmark.url_hash == urlhash) 809 | query.execute() 810 | query = Bookmark.update(deleted_date=datetime.datetime.now()).where( 811 | Bookmark.userkey == userkey, 812 | Bookmark.url_hash == urlhash 813 | ) 814 | query.execute() 815 | message = 'Bookmark deleted. Undo deletion'.format(url_for( 816 | 'undeletebookmark', 817 | userkey=userkey, 818 | urlhash=urlhash 819 | )) 820 | all_tags[userkey] = get_tags_for_user(userkey) 821 | return redirect(url_for('bookmarks_page', userkey=userkey, message=message)) 822 | 823 | 824 | @app.route('///undelete') 825 | def undeletebookmark(userkey, urlhash): 826 | """ Undo deletion of the bookmark identified by urlhash """ 827 | query = Bookmark.update(status=Bookmark.VISIBLE).where(Bookmark.userkey == userkey, Bookmark.url_hash == urlhash) 828 | query.execute() 829 | message = 'Bookmark restored' 830 | all_tags[userkey] = get_tags_for_user(userkey) 831 | return redirect(url_for('bookmarks_page', userkey=userkey, message=message)) 832 | 833 | 834 | @app.route('//tags') 835 | def tags_page(userkey): 836 | """ Overview of all tags used by user """ 837 | tags = get_cached_tags(userkey) 838 | #publictags = PublicTag.select().where(Bookmark.userkey == userkey) 839 | alltags = [] 840 | for tag in tags: 841 | try: 842 | publictag = PublicTag.get(PublicTag.userkey == userkey, PublicTag.tag == tag) 843 | except PublicTag.DoesNotExist: 844 | publictag = None 845 | 846 | total = Bookmark.select().where( 847 | Bookmark.userkey == userkey, 848 | Bookmark.tags.contains(tag), 849 | Bookmark.status == Bookmark.VISIBLE 850 | ).count() 851 | alltags.append({'tag': tag, 'publictag': publictag, 'total': total}) 852 | totaltags = len(alltags) 853 | totalbookmarks = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.status == Bookmark.VISIBLE).count() 854 | totalpublic = PublicTag.select().where(PublicTag.userkey == userkey).count() 855 | totalstarred = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.starred).count() 856 | totaldeleted = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.status == Bookmark.DELETED).count() 857 | totalnotes = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.note != '').count() 858 | totalhttperrorstatus = Bookmark.select().where(Bookmark.userkey == userkey, Bookmark.http_status != 200).count() 859 | theme = get_theme(userkey) 860 | return render_template( 861 | 'tags.html', 862 | tags=alltags, 863 | totaltags=totaltags, 864 | totalpublic=totalpublic, 865 | totalbookmarks=totalbookmarks, 866 | totaldeleted=totaldeleted, 867 | totalstarred=totalstarred, 868 | totalhttperrorstatus=totalhttperrorstatus, 869 | totalnotes=totalnotes, 870 | userkey=userkey, 871 | theme=theme 872 | ) 873 | 874 | 875 | @app.route('//tag/') 876 | def tag_page(userkey, tag): 877 | """ Overview of all bookmarks with a certain tag """ 878 | bookmarks = Bookmark.select().where( 879 | Bookmark.userkey == userkey, 880 | Bookmark.tags.contains(tag), 881 | Bookmark.status == Bookmark.VISIBLE 882 | ).order_by(Bookmark.created_date.desc()) 883 | tags = get_cached_tags(userkey) 884 | pageheader = 'tag: ' + tag 885 | message = request.args.get('message') 886 | 887 | try: 888 | publictag = PublicTag.get(PublicTag.userkey == userkey, PublicTag.tag == tag) 889 | except PublicTag.DoesNotExist: 890 | publictag = None 891 | 892 | theme = get_theme(userkey) 893 | return render_template( 894 | 'bookmarks.html', 895 | bookmarks=bookmarks, 896 | userkey=userkey, 897 | tags=tags, 898 | tag=tag, 899 | publictag=publictag, 900 | action=pageheader, 901 | message=message, 902 | theme=theme, 903 | editable=True, 904 | showtags=True, 905 | ) 906 | 907 | 908 | def get_publictag(tagkey): 909 | """ Return tag and bookmarks in this public tag collection """ 910 | this_tag = PublicTag.get(PublicTag.tagkey == tagkey) 911 | bookmarks = Bookmark.select().where( 912 | Bookmark.userkey == this_tag.userkey, 913 | Bookmark.tags.contains(this_tag.tag), 914 | Bookmark.status == Bookmark.VISIBLE 915 | ).order_by(Bookmark.created_date.desc()) 916 | return this_tag, bookmarks 917 | 918 | 919 | @app.route('/pub/') 920 | def publictag_page(tagkey): 921 | """ Read-only overview of the bookmarks in the userkey/tag of this PublicTag """ 922 | #this_tag = get_object_or_404(PublicTag.select().where(PublicTag.tagkey == tagkey)) 923 | try: 924 | this_tag, bookmarks = get_publictag(tagkey) 925 | theme = themes[DEFAULT_THEME] 926 | return render_template( 927 | 'publicbookmarks.html', 928 | bookmarks=bookmarks, 929 | tag=this_tag.tag, 930 | action=this_tag.tag, 931 | tagkey=tagkey, 932 | theme=theme 933 | ) 934 | except PublicTag.DoesNotExist: 935 | abort(404) 936 | 937 | 938 | @app.route('/api/v1/pub/') 939 | def publictag_json(tagkey): 940 | """ json representation of the Read-only overview of the bookmarks in the userkey/tag of this PublicTag """ 941 | try: 942 | this_tag, bookmarks = get_publictag(tagkey) 943 | result = { 944 | #'tag': this_tag, 945 | 'tagkey': tagkey, 946 | 'count': len(bookmarks), 947 | 'items': [], 948 | } 949 | for bookmark in bookmarks: 950 | result['items'].append(bookmark.to_dict()) 951 | return jsonify(result) 952 | except PublicTag.DoesNotExist: 953 | abort(404) 954 | 955 | 956 | @app.route('/pub//feed') 957 | def publictag_feed(tagkey): 958 | """ rss/atom representation of the Read-only overview of the bookmarks in the userkey/tag of this PublicTag """ 959 | try: 960 | this_tag = PublicTag.get(PublicTag.tagkey == tagkey) 961 | bookmarks = Bookmark.select().where( 962 | Bookmark.userkey == this_tag.userkey, 963 | Bookmark.tags.contains(this_tag.tag), 964 | Bookmark.status == Bookmark.VISIBLE 965 | ) 966 | 967 | feed = FeedGenerator() 968 | feed.title(this_tag.tag) 969 | feed.id(request.url) 970 | feed.link(href=request.url, rel='self') 971 | feed.link(href=make_external(url_for('publictag_page', tagkey=tagkey))) 972 | 973 | for bookmark in bookmarks: 974 | entry = feed.add_entry() 975 | 976 | updated_date = bookmark.modified_date 977 | if not bookmark.modified_date: 978 | updated_date = bookmark.created_date 979 | bookmarktitle = '{} (no title)'.format(bookmark.url) 980 | if bookmark.title: 981 | bookmarktitle = bookmark.title 982 | 983 | entry.id(bookmark.url) 984 | entry.title(bookmarktitle) 985 | entry.link(href=bookmark.url) 986 | entry.author(name='digimarks') 987 | entry.pubdate(bookmark.created_date.replace(tzinfo=tz.tzlocal())) 988 | entry.published(bookmark.created_date.replace(tzinfo=tz.tzlocal())) 989 | entry.updated(updated_date.replace(tzinfo=tz.tzlocal())) 990 | 991 | response = make_response(feed.atom_str(pretty=True)) 992 | response.headers.set('Content-Type', 'application/atom+xml') 993 | return response 994 | except PublicTag.DoesNotExist: 995 | abort(404) 996 | 997 | 998 | @app.route('///makepublic', methods=['GET', 'POST']) 999 | def addpublictag(userkey, tag): 1000 | #user = get_object_or_404(User.get(User.key == userkey)) 1001 | try: 1002 | User.get(User.key == userkey) 1003 | except User.DoesNotExist: 1004 | abort(404) 1005 | try: 1006 | publictag = PublicTag.get(PublicTag.userkey == userkey, PublicTag.tag == tag) 1007 | except PublicTag.DoesNotExist: 1008 | publictag = None 1009 | if not publictag: 1010 | newpublictag = PublicTag() 1011 | newpublictag.generate_key() 1012 | newpublictag.userkey = userkey 1013 | newpublictag.tag = tag 1014 | newpublictag.save() 1015 | 1016 | message = 'Public link to this tag created' 1017 | return redirect(url_for('tag_page', userkey=userkey, tag=tag, message=message)) 1018 | 1019 | message = 'Public link already existed' 1020 | return redirect(url_for('tag_page', userkey=userkey, tag=tag, message=message)) 1021 | 1022 | 1023 | @app.route('///removepublic/', methods=['GET', 'POST']) 1024 | def removepublictag(userkey, tag, tagkey): 1025 | q = PublicTag.delete().where(PublicTag.userkey == userkey, PublicTag.tag == tag, PublicTag.tagkey == tagkey) 1026 | q.execute() 1027 | message = 'Public link deleted' 1028 | return redirect(url_for('tag_page', userkey=userkey, tag=tag, message=message)) 1029 | 1030 | 1031 | @app.route('//adduser') 1032 | def adduser(systemkey): 1033 | """ Add user endpoint, convenience """ 1034 | if systemkey == settings.SYSTEMKEY: 1035 | newuser = User() 1036 | newuser.generate_key() 1037 | newuser.username = 'Nomen Nescio' 1038 | newuser.save() 1039 | all_tags[newuser.key] = [] 1040 | return redirect('/{}'.format(newuser.key.decode("utf-8")), code=302) 1041 | else: 1042 | abort(404) 1043 | 1044 | 1045 | @app.route('//refreshfavicons') 1046 | def refreshfavicons(systemkey): 1047 | """ Add user endpoint, convenience """ 1048 | if systemkey == settings.SYSTEMKEY: 1049 | bookmarks = Bookmark.select() 1050 | for bookmark in bookmarks: 1051 | if bookmark.favicon: 1052 | try: 1053 | filename = os.path.join(MEDIA_ROOT, 'favicons/' + bookmark.favicon) 1054 | os.remove(filename) 1055 | except OSError as e: 1056 | print(e) 1057 | bookmark.set_favicon() 1058 | return redirect('/') 1059 | else: 1060 | abort(404) 1061 | 1062 | 1063 | @app.route('//findmissingfavicons') 1064 | def findmissingfavicons(systemkey): 1065 | """ Add user endpoint, convenience """ 1066 | if systemkey == settings.SYSTEMKEY: 1067 | bookmarks = Bookmark.select() 1068 | for bookmark in bookmarks: 1069 | try: 1070 | if not bookmark.favicon or not os.path.isfile(os.path.join(MEDIA_ROOT, 'favicons/' + bookmark.favicon)): 1071 | # This favicon is missing 1072 | # Clear favicon, so fallback can be used instead of showing a broken image 1073 | bookmark.favicon = None 1074 | bookmark.save() 1075 | # Try to fetch and save new favicon 1076 | bookmark.set_favicon() 1077 | bookmark.save() 1078 | except OSError as e: 1079 | print(e) 1080 | return redirect('/') 1081 | else: 1082 | abort(404) 1083 | 1084 | 1085 | # Initialisation == create the bookmark, user and public tag tables if they do not exist 1086 | Bookmark.create_table(True) 1087 | User.create_table(True) 1088 | PublicTag.create_table(True) 1089 | 1090 | users = User.select() 1091 | print('Current user keys:') 1092 | for user in users: 1093 | all_tags[user.key] = get_tags_for_user(user.key) 1094 | usersettings[user.key] = {'theme': user.theme} 1095 | print(user.key) 1096 | 1097 | # Run when called standalone 1098 | if __name__ == '__main__': 1099 | # run the application 1100 | app.run(host='0.0.0.0', port=9999, debug=True) 1101 | -------------------------------------------------------------------------------- /example_config/apache_vhost.conf: -------------------------------------------------------------------------------- 1 | 2 | ServerAdmin webmaster@example.com 3 | ServerName marks.example.com 4 | 5 | WSGIDaemonProcess digimarks user=youruser group=youruser threads=5 python-path=/srv/marks.example.com/digimarks/ 6 | WSGIScriptAlias / /srv/marks.example.com/digimarks/wsgi.py 7 | 8 | 9 | WSGIProcessGroup digimarks 10 | WSGIApplicationGroup %{GLOBAL} 11 | Require all granted 12 | 13 | 14 | 15 | 16 | Require all granted 17 | 18 | 19 | 20 | ErrorLog /var/log/apache2/error_marks.example.com.log 21 | 22 | # Possible values include: debug, info, notice, warn, error, crit, 23 | # alert, emerg. 24 | LogLevel warn 25 | 26 | CustomLog /var/log/apache2/access_marks.example.com.log combined 27 | ServerSignature On 28 | 29 | 30 | -------------------------------------------------------------------------------- /example_config/settings.py: -------------------------------------------------------------------------------- 1 | # Virtualenv to use with the wsgi file (optional) 2 | VENV = '/srv/marks.example.com/venv/bin/activate_this.py' 3 | 4 | PORT = 8086 5 | 6 | DEBUG = False 7 | 8 | # Password/url key to do admin stuff with, like adding a user 9 | # NB: change this to something else! For example, in bash: 10 | # echo -n "yourstring" | sha1sum 11 | SYSTEMKEY = 'S3kr1t' 12 | 13 | # RapidAPI key for favicons 14 | # https://rapidapi.com/realfavicongenerator/api/realfavicongenerator 15 | MASHAPE_API_KEY = 'your_MASHAPE_key' 16 | 17 | LOG_LOCATION = 'digimarks.log' 18 | #LOG_LOCATION = '/var/log/digimarks/digimarks.log' 19 | # How many logs to keep in log rotation: 20 | LOG_BACKUP_COUNT = 10 21 | -------------------------------------------------------------------------------- /example_config/uwsgi.ini: -------------------------------------------------------------------------------- 1 | # Example supervisord configuration 2 | # Run with /srv/venv/bin/uwsgi --ini /srv/digimarks/uwsgi.ini:digimarks 3 | 4 | [digimarks] 5 | chdir = /srv/digimarks 6 | socket = /tmp/uwsgi_digimarks.sock 7 | module = wsgi 8 | threads = 4 9 | master = true 10 | processes = 5 11 | vacuum = true 12 | no-orphans = true 13 | chmod-socket = 666 14 | logger = main file:/var/log/webapps/digimarks.log 15 | logger = file:/var/log/webapps/digimarks_debug.log 16 | -------------------------------------------------------------------------------- /requirements-dev.in: -------------------------------------------------------------------------------- 1 | -r requirements.in 2 | 3 | pylint 4 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile requirements-dev.in 3 | astroid==3.3.10 4 | # via pylint 5 | beautifulsoup4==4.13.4 6 | # via bs4 7 | blinker==1.9.0 8 | # via flask 9 | bs4==0.0.2 10 | # via -r requirements.in 11 | certifi==2025.4.26 12 | # via requests 13 | charset-normalizer==3.4.2 14 | # via requests 15 | click==8.2.1 16 | # via flask 17 | dill==0.4.0 18 | # via pylint 19 | feedgen==1.0.0 20 | # via -r requirements.in 21 | flask==3.1.1 22 | # via -r requirements.in 23 | idna==3.10 24 | # via requests 25 | isort==6.0.1 26 | # via pylint 27 | itsdangerous==2.2.0 28 | # via flask 29 | jinja2==3.1.6 30 | # via flask 31 | lxml==5.4.0 32 | # via feedgen 33 | markupsafe==3.0.2 34 | # via 35 | # flask 36 | # jinja2 37 | # werkzeug 38 | mccabe==0.7.0 39 | # via pylint 40 | peewee==3.18.1 41 | # via -r requirements.in 42 | platformdirs==4.3.8 43 | # via pylint 44 | pylint==3.3.7 45 | # via -r requirements-dev.in 46 | python-dateutil==2.9.0.post0 47 | # via feedgen 48 | requests==2.32.3 49 | # via -r requirements.in 50 | six==1.17.0 51 | # via python-dateutil 52 | soupsieve==2.7 53 | # via beautifulsoup4 54 | tomlkit==0.13.2 55 | # via pylint 56 | typing-extensions==4.13.2 57 | # via beautifulsoup4 58 | urllib3==2.4.0 59 | # via requests 60 | werkzeug==3.1.3 61 | # via flask 62 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | # Core application 2 | flask 3 | peewee 4 | 5 | # Fetch title etc from links 6 | bs4 7 | requests 8 | 9 | # Generate (atom) feeds for tags and such 10 | feedgen 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile requirements.in 3 | beautifulsoup4==4.13.4 4 | # via bs4 5 | blinker==1.9.0 6 | # via flask 7 | bs4==0.0.2 8 | # via -r requirements.in 9 | certifi==2025.4.26 10 | # via requests 11 | charset-normalizer==3.4.2 12 | # via requests 13 | click==8.2.1 14 | # via flask 15 | feedgen==1.0.0 16 | # via -r requirements.in 17 | flask==3.1.1 18 | # via -r requirements.in 19 | idna==3.10 20 | # via requests 21 | itsdangerous==2.2.0 22 | # via flask 23 | jinja2==3.1.6 24 | # via flask 25 | lxml==5.4.0 26 | # via feedgen 27 | markupsafe==3.0.2 28 | # via 29 | # flask 30 | # jinja2 31 | # werkzeug 32 | peewee==3.18.1 33 | # via -r requirements.in 34 | python-dateutil==2.9.0.post0 35 | # via feedgen 36 | requests==2.32.3 37 | # via -r requirements.in 38 | six==1.17.0 39 | # via python-dateutil 40 | soupsieve==2.7 41 | # via beautifulsoup4 42 | typing-extensions==4.13.2 43 | # via beautifulsoup4 44 | urllib3==2.4.0 45 | # via requests 46 | werkzeug==3.1.3 47 | # via flask 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | A setuptools based setup module. 3 | See: 4 | https://packaging.python.org/en/latest/distributing.html 5 | https://github.com/pypa/sampleproject 6 | """ 7 | 8 | from setuptools import setup 9 | # To use a consistent encoding 10 | from codecs import open as codecopen 11 | from os import path 12 | 13 | here = path.abspath(path.dirname(__file__)) 14 | 15 | # Get the long description from the relevant file 16 | with codecopen(path.join(here, 'README.rst'), encoding='utf-8') as f: 17 | long_description = f.read() 18 | 19 | setup( 20 | name='digimarks', # pip install digimarks 21 | description='Simple bookmarking service, using a SQLite database to store bookmarks, supporting tags, automatic title fetching and REST API calls.', 22 | #long_description=open('README.md', 'rt').read(), 23 | long_description=long_description, 24 | 25 | # version 26 | # third part for minor release 27 | # second when api changes 28 | # first when it becomes stable someday 29 | version='1.1.99', 30 | author='Michiel Scholten', 31 | author_email='michiel@diginaut.net', 32 | 33 | url='https://github.com/aquatix/digimarks', 34 | license='Apache', 35 | 36 | # as a practice no need to hard code version unless you know program wont 37 | # work unless the specific versions are used 38 | install_requires=['Flask', 'Peewee', 'Flask-Peewee', 'requests', 'bs4'], 39 | 40 | py_modules=['digimarks'], 41 | 42 | zip_safe=True, 43 | ) 44 | -------------------------------------------------------------------------------- /static/css/digimarks.css: -------------------------------------------------------------------------------- 1 | /** 2 | * digimarks styling 3 | */ 4 | 5 | /** Navigation **/ 6 | 7 | nav .sidenav-trigger 8 | { 9 | /* Fix for misalignment of hamburger icon */ 10 | margin: 0; 11 | } 12 | 13 | nav .sidenav-trigger i 14 | { 15 | /* Make the hamburger icon great again */ 16 | font-size: 2.7rem; 17 | } 18 | 19 | /** Cards and tags **/ 20 | 21 | .card .card-content, 22 | .card .card-reveal 23 | { 24 | padding: 12px; 25 | } 26 | 27 | .card.tiny 28 | { 29 | height: 140px; 30 | overflow: hidden; 31 | } 32 | 33 | .card.tiny .card-title 34 | { 35 | font-size: 18px; 36 | } 37 | 38 | .card .card-reveal .digimark-card-header, 39 | .card .digimark-card-header.activator, 40 | .chip.clickable 41 | { 42 | cursor: pointer; 43 | /*display: block;*/ 44 | } 45 | 46 | .card .digimark-card-header-tags 47 | { 48 | padding-top: 10px; 49 | } 50 | 51 | .card-image 52 | { 53 | min-width: 60px; 54 | } 55 | 56 | .card-image i, 57 | .list-image i 58 | { 59 | padding: 5px 0 0 15px; 60 | } 61 | 62 | .card.horizontal .card-image img.favicon, 63 | .list-image img.favicon 64 | { 65 | height: 60px; 66 | width: 60px; 67 | } 68 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aquatix/digimarks/db091ae02e601cbe1de36f615e50b15859435cd5/static/favicon.ico -------------------------------------------------------------------------------- /static/faviconfallback.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aquatix/digimarks/db091ae02e601cbe1de36f615e50b15859435cd5/static/faviconfallback.png -------------------------------------------------------------------------------- /static/favicons/.notempty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aquatix/digimarks/db091ae02e601cbe1de36f615e50b15859435cd5/static/favicons/.notempty -------------------------------------------------------------------------------- /static/js/init.js: -------------------------------------------------------------------------------- 1 | /* global M */ 2 | 3 | var options = {}; 4 | var elem = document.querySelector(".sidenav"); 5 | var instance = M.Sidenav.init(elem, options); 6 | 7 | elem = document.querySelector(".collapsible"); 8 | instance = M.Collapsible.init(elem, { 9 | // inDuration: 1000, 10 | // outDuration: 1000 11 | }); 12 | -------------------------------------------------------------------------------- /templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}404: Page not found{% endblock %} 3 | {% block pageheader %}404: Page not found{% endblock %} 4 | {% block pagecontent %} 5 | The page you requested was not found. 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block title %}{% endblock %} - digimarks 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 78 | 79 | {% if not sortmethod %} 80 | {% set sortmethod = None %} 81 | {% endif %} 82 | {% if not show_as %} 83 | {% set show_as = None %} 84 | {% endif %} 85 | 86 | 87 | 106 |
107 |
108 |
109 |

{% block pageheader %}Bookmarks{% endblock %}

110 |
111 |
112 |
113 |
114 |
115 | {% block pagecontent %} 116 | {% endblock %} 117 |
118 |
119 | 120 | 121 | 122 | 123 | 124 | {% block extrajs %}{% endblock %} 125 | 126 | 127 | -------------------------------------------------------------------------------- /templates/bookmarks.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% if not action %} 3 | {% set action = 'Bookmarks' %} 4 | {% endif %} 5 | {% block title %}{{ action }}{% endblock %} 6 | {% block pageheader %}{{ action }}{% endblock %} 7 | {% block pagecontent %} 8 | 9 | {% if tag and not publictag %} 10 |
11 | 14 |
15 | {% endif %} 16 | 17 | {% if tag and publictag %} 18 |
19 | 20 |
21 | {% endif %} 22 | 23 | {% if message %} 24 |
25 |
26 |
27 | 28 | {{ message|safe }} 29 | 30 |
31 |
32 |
33 | {% endif %} 34 | 35 |
36 |
37 |
38 | 39 |
40 | 41 |
42 |

43 | {% if show_as and show_as == 'list' %} 44 | apps 45 | {% else %} 46 | reorder 47 | {% endif %} 48 |

49 |
50 |
51 |
52 | 53 | {% if tags %} 54 |
55 |
56 |
    57 |
  • 58 |
    labelFilter on star/problem/comment/tag
    59 |
    60 |
    61 | star 62 |
    63 |
    64 | report_problem 65 |
    66 |
    67 | comment 68 |
    69 | {% for tag in tags %} 70 |
    71 | {{ tag }} 72 |
    73 | {% endfor %} 74 |
  • 75 |
76 |
77 |
78 | {% endif %} 79 | 80 | {% if show_as and show_as == 'list' %} 81 | {% include 'list.html' %} 82 | {% else %} 83 | {% include 'cards.html' %} 84 | {% endif %} 85 | 86 |
87 | 88 | add 89 | 90 |
91 | {% endblock %} 92 | {% block extrajs %} 93 | 112 | 113 | {% endblock %} 114 | -------------------------------------------------------------------------------- /templates/bookmarks.js: -------------------------------------------------------------------------------- 1 | var elem = document.querySelector('.autocomplete'); 2 | var instance = M.Autocomplete.getInstance(elem); 3 | instance.updateData({ 4 | {% for bookmark in bookmarks %} 5 | {% if bookmark.favicon %} 6 | "{{ bookmark.title | replace('"', '\\"') | replace('\n', '') | replace('\r', '') }}": "{{ url_for('static', filename='favicons/' + bookmark.favicon) }}", 7 | {% else %} 8 | "{{ bookmark.title | replace('"', '\\"') | replace('\n', '') | replace('\r', '') }}": null, 9 | {% endif %} 10 | {% endfor %} 11 | }); 12 | -------------------------------------------------------------------------------- /templates/cards.html: -------------------------------------------------------------------------------- 1 |
2 | {% for bookmark in bookmarks %} 3 |
4 |
5 |
6 | {% if bookmark.favicon %} 7 |
8 | {% else %} 9 |
10 | {% endif %} 11 | {% if bookmark.http_status != 200 and bookmark.http_status != 304 %} 12 |
report_problem
13 | {% endif %} 14 | {% if bookmark.starred == True %} 15 |
star
16 | {% endif %} 17 | {% if bookmark.note %} 18 |
comment
19 | {% endif %} 20 |
21 | 37 |
38 | Added @ {{ bookmark.created_date.strftime('%Y-%m-%d %H:%M') }}close 39 | {% if editable %} 40 |
41 | mode_edit EDIT 42 | delete DELETE 43 |
44 | {% endif %} 45 | {% if showtags %} 46 |
47 | {% for tag in bookmark.tags_list %} 48 |
49 | {{ tag }} 50 |
51 | {% endfor %} 52 |
53 | {% endif %} 54 |
55 |
56 |
57 | {% endfor %} 58 | 59 | {# 60 | 64 | #} 65 |
66 | -------------------------------------------------------------------------------- /templates/edit.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}{{ action }}{% endblock %} 3 | {% block pageheader %}{{ action }}{% endblock %} 4 | {% block pagecontent %} 5 | 6 | {% if bookmark.http_status != 200 and bookmark.http_status != 202 and bookmark.http_status != 304 %} 7 |
8 |
9 |
10 | 11 | {% if bookmark.http_status == 404 %} 12 | report_problem  URL not found (404), broken/outdated link? 13 | {% elif bookmark.http_status == 301 %} 14 | report_problem  HTTP status (301), moved permanently. Use button for new target 15 | {% elif bookmark.http_status == 302 %} 16 | report_problem  HTTP status (302), moved temporarily. Use button for new target 17 | {% elif bookmark.http_status == bookmark.HTTP_CONNECTIONERROR %} 18 | report_problem  Connection error, server might have been offline at the time of last edit 19 | {% else %} 20 | report_problem  HTTP status {{ bookmark.http_status }} 21 | {% endif %} 22 | 23 |
24 |
25 |
26 | {% endif %} 27 | 28 | {% if message %} 29 |
30 |
31 |
32 | 33 | {{ message }} 34 | 35 |
36 |
37 |
38 | {% endif %} 39 | 40 | {% if formaction and formaction == 'edit' %} 41 |
42 | {% else %} 43 | 44 | {% endif %} 45 | 46 |
47 |
48 | description 49 | 50 | 51 | {# Leave title empty for autofetching from the page#} 52 |
53 | 54 |
55 | turned_in 56 | 57 | 58 | {% if bookmark.get_redirect_uri() %} 59 | 62 | 68 | {% endif %} 69 |
70 | 71 |
72 | comment 73 | 74 | 75 |
76 | 77 |
78 | label 79 | 80 | 81 |
82 |
83 | {% if tags %} 84 |
85 |
86 |
    87 |
  • 88 |
    labelExisting tags
    89 |
    90 | {% for tag in tags %} 91 |
    92 | {{ tag }} 93 |
    94 | {% endfor %} 95 |
    96 |
  • 97 |
98 |
99 |
100 | {% endif %} 101 |
102 | 103 |
104 | {#star#} 105 | 109 |
110 | 111 |
112 | 116 |
117 | 118 | {% if bookmark.url_hash %} 119 |
120 |
121 |
122 | 123 | 124 | 125 | 126 | 127 | {% if bookmark.modified_date %} 128 | 129 | 130 | 131 | 132 | {% endif %} 133 | {% if bookmark.deleted_date %} 134 | 135 | 136 | 137 | 138 | {% endif %} 139 |
Added{{ bookmark.created_date.strftime('%Y-%m-%d %H:%M') }}
Modified{{ bookmark.modified_date.strftime('%Y-%m-%d %H:%M') }}
Deleted{{ bookmark.deleted_date.strftime('%Y-%m-%d %H:%M') }}
140 |
141 |
142 |
143 | {% endif %} 144 | 145 |
146 |

147 |
148 | {% if bookmark.url_hash %} 149 | 150 |
151 |
152 |

153 |
154 |
155 |
156 | {% else %} 157 | 158 | 159 | {% endif %} 160 | 161 | 175 | {% endblock %} 176 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}digimarks{% endblock %} 3 | {% block pageheader %}digimarks{% endblock %} 4 | {% block pagecontent %} 5 |

Please visit your personal url, or see the digimarks project page.

6 | 7 |
8 |
9 |
10 | 11 | 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. 12 | 13 |
14 |
15 |
16 | 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /templates/list.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | {% if showtags %} 9 | 10 | {% endif %} 11 | 12 | 13 | 14 | 15 | {% for bookmark in bookmarks %} 16 | 17 | 33 | 42 | 43 | {% if showtags %} 44 | 51 | {% endif %} 52 | 58 | 59 | {% endfor %} 60 | 61 |
 BookmarkAddedTags 
18 | {% if bookmark.favicon %} 19 | 20 | {% else %} 21 | 22 | {% endif %} 23 | {% if bookmark.http_status != 200 and bookmark.http_status != 304 %} 24 | report_problem 25 | {% endif %} 26 | {% if bookmark.starred == True %} 27 | star 28 | {% endif %} 29 | {% if bookmark.note %} 30 | comment 31 | {% endif %} 32 | 34 | 35 | {% if bookmark.title %} 36 | {{ bookmark.title }} 37 | {% else %} 38 | {{ bookmark.get_uri_domain() }} (no title) 39 | {% endif %} 40 | 41 | {{ bookmark.created_date.strftime('%Y-%m-%d %H:%M') }} 45 | {% for tag in bookmark.tags_list %} 46 |
47 | {{ tag }} 48 |
49 | {% endfor %} 50 |
53 | {% if editable %} 54 | mode_edit 55 | delete 56 | {% endif %} 57 |
62 |
63 | -------------------------------------------------------------------------------- /templates/publicbookmarks.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% if not action %} 3 | {% set action = 'Bookmarks' %} 4 | {% endif %} 5 | {% block title %}{{ action }}{% endblock %} 6 | {% block pageheader %}{{ action }}{% endblock %} 7 | {% block pagecontent %} 8 | 9 | {% if message %} 10 |
11 |
12 |
13 | 14 | {{ message }} 15 | 16 |
17 |
18 |
19 | {% endif %} 20 | 21 |
22 |
23 | rss_feed feed 24 |
25 |
26 | 27 | {% include 'cards.html' %} 28 | 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /templates/redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Redirecting - digimarks 5 | 6 | 7 | 8 | 12 | 13 | 14 |

You're being redirected. If nothing happens, click here instead.

15 | 16 | 17 | -------------------------------------------------------------------------------- /templates/tags.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Tags{% endblock %} 3 | {% block pageheader %}Tags{% endblock %} 4 | {% block pagecontent %} 5 |
6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
labelpresent_to_allturned_incommentstarwarningdelete
{{ totaltags }}{{ totalpublic }}{{ totalbookmarks }}{{ totalnotes }}{{ totalstarred }}{{ totalhttperrorstatus }}{{ totaldeleted }}
32 | 33 |

34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | {% for tag in tags %} 45 | 46 | 49 | 56 | 59 | 60 | {% endfor %} 61 | 62 |
TagPublic linkNumber of bookmarks
47 | {{ tag['tag'] }} 48 | 50 | {% if tag['publictag'] %} 51 | Public link (Delete warning) 52 | {% else %} 53 | Create 54 | {% endif %} 55 | 57 | {{ tag['total'] }} 58 |
63 |
64 |
65 | {% endblock %} 66 | -------------------------------------------------------------------------------- /wsgi.py: -------------------------------------------------------------------------------- 1 | # Activate virtualenv 2 | import settings 3 | activate_this = getattr(settings, 'VENV', None) 4 | # FIXME: python 2 *and* python 3 compatibility 5 | # Python 2 6 | #if activate_this: 7 | # execfile(activate_this, dict(__file__=activate_this)) 8 | # Python 3 9 | with open(activate_this) as file_: 10 | exec(file_.read(), dict(__file__=activate_this)) 11 | 12 | from digimarks import app as application 13 | 14 | if __name__ == "__main__": 15 | # application is ran standalone 16 | application.run(debug=settings.DEBUG) 17 | --------------------------------------------------------------------------------