├── .github └── workflows │ └── push-docker-hub.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── __init__.py ├── model_types.py ├── models.py └── routes.py ├── client ├── .browserslistrc ├── .eslintrc.js ├── .gitignore ├── jest.config.js ├── package-lock.json ├── package.json ├── public │ ├── apple-touch-icon-114x114.png │ ├── apple-touch-icon-120x120.png │ ├── apple-touch-icon-144x144.png │ ├── apple-touch-icon-152x152.png │ ├── apple-touch-icon-57x57.png │ ├── apple-touch-icon-60x60.png │ ├── apple-touch-icon-72x72.png │ ├── apple-touch-icon-76x76.png │ ├── favicon-128.png │ ├── favicon-16x16.png │ ├── favicon-196x196.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── favicon.ico │ ├── index.html │ ├── mstile-144x144.png │ ├── mstile-150x150.png │ ├── mstile-310x150.png │ ├── mstile-310x310.png │ └── mstile-70x70.png ├── src │ ├── App.vue │ ├── components │ │ ├── Calendar.vue │ │ ├── Editor.vue │ │ ├── Header.vue │ │ ├── NoteCard.vue │ │ ├── SimpleTask.vue │ │ ├── Tags.vue │ │ └── UnsavedForm.vue │ ├── interfaces.ts │ ├── main.ts │ ├── router │ │ └── index.ts │ ├── services │ │ ├── consts.ts │ │ ├── eventHub.ts │ │ ├── localstorage.ts │ │ ├── notes.ts │ │ ├── requests.ts │ │ ├── sharedBuefy.ts │ │ ├── sidebar.ts │ │ └── user.ts │ ├── shims-tsx.d.ts │ ├── shims-vue.d.ts │ └── views │ │ ├── Auth.vue │ │ ├── Day.vue │ │ ├── ErrorPage.vue │ │ ├── Home.vue │ │ ├── HomeRedirect.vue │ │ ├── Login.vue │ │ ├── NewNote.vue │ │ ├── Note.vue │ │ ├── PageNotFound.vue │ │ ├── Search.vue │ │ ├── Signup.vue │ │ └── UnauthorizedPage.vue ├── tests │ └── unit │ │ └── example.spec.ts ├── tsconfig.json └── vue.config.js ├── config.py ├── docker-compose.yml ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ ├── 7bd1ee1840ca_meta_table.py │ ├── 9bd71ed6ccff_remove_unique_constraint_name_.py │ ├── 9ca5901af374_cleanup.py │ ├── a477f34dbaa4_initial_config.py │ ├── ad68860179f2_added_auto_save_column_to_user_table.py │ └── c440f31aff28_add_unique_constraint_name_for_note_.py ├── requirements.txt ├── run.sh ├── server.py ├── verify_data_migrations.py └── verify_env.py /.github/workflows/push-docker-hub.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | tags: 8 | - '*' 9 | pull_request: 10 | branches: 11 | - 'master' 12 | 13 | jobs: 14 | docker: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - 18 | name: Checkout 19 | uses: actions/checkout@v2 20 | - 21 | name: Docker meta 22 | id: meta 23 | uses: crazy-max/ghaction-docker-meta@v2 24 | with: 25 | images: m0ngr31/dailynotes 26 | tags: | 27 | type=raw,value=latest,enable=${{ endsWith(github.ref, 'master') }} 28 | type=ref,event=tag 29 | flavor: | 30 | latest=false 31 | - 32 | name: Login to DockerHub 33 | if: github.event_name != 'pull_request' 34 | uses: docker/login-action@v1 35 | with: 36 | username: ${{ secrets.DOCKERHUB_USERNAME }} 37 | password: ${{ secrets.DOCKERHUB_TOKEN }} 38 | - 39 | name: Build and push 40 | uses: docker/build-push-action@v2 41 | with: 42 | context: . 43 | push: ${{ github.event_name != 'pull_request' }} 44 | tags: ${{ steps.meta.outputs.tags }} 45 | labels: ${{ steps.meta.outputs.labels }} 46 | - 47 | name: Update repo description 48 | uses: peter-evans/dockerhub-description@v2 49 | with: 50 | username: ${{ secrets.DOCKERHUB_USERNAME }} 51 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 52 | repository: m0ngr31/dailynotes -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/node,flask,python,visualstudiocode 3 | # Edit at https://www.gitignore.io/?templates=node,flask,python,visualstudiocode 4 | 5 | ### Flask ### 6 | instance/* 7 | !instance/.gitignore 8 | .webassets-cache 9 | 10 | ### Flask.Python Stack ### 11 | # Byte-compiled / optimized / DLL files 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | 16 | # C extensions 17 | *.so 18 | 19 | # Distribution / packaging 20 | .Python 21 | build/ 22 | develop-eggs/ 23 | dist/ 24 | downloads/ 25 | eggs/ 26 | .eggs/ 27 | lib/ 28 | lib64/ 29 | parts/ 30 | sdist/ 31 | var/ 32 | wheels/ 33 | pip-wheel-metadata/ 34 | share/python-wheels/ 35 | *.egg-info/ 36 | .installed.cfg 37 | *.egg 38 | MANIFEST 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .nox/ 54 | .coverage 55 | .coverage.* 56 | .cache 57 | nosetests.xml 58 | coverage.xml 59 | *.cover 60 | .hypothesis/ 61 | .pytest_cache/ 62 | 63 | # Translations 64 | *.mo 65 | *.pot 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # pipenv 80 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 81 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 82 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 83 | # install all needed dependencies. 84 | #Pipfile.lock 85 | 86 | # celery beat schedule file 87 | celerybeat-schedule 88 | 89 | # SageMath parsed files 90 | *.sage.py 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | .spyproject 95 | 96 | # Rope project settings 97 | .ropeproject 98 | 99 | # Mr Developer 100 | .mr.developer.cfg 101 | .project 102 | .pydevproject 103 | 104 | # mkdocs documentation 105 | /site 106 | 107 | # mypy 108 | .mypy_cache/ 109 | .dmypy.json 110 | dmypy.json 111 | 112 | # Pyre type checker 113 | .pyre/ 114 | 115 | ### Node ### 116 | # Logs 117 | logs 118 | *.log 119 | npm-debug.log* 120 | yarn-debug.log* 121 | yarn-error.log* 122 | lerna-debug.log* 123 | 124 | # Diagnostic reports (https://nodejs.org/api/report.html) 125 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 126 | 127 | # Runtime data 128 | pids 129 | *.pid 130 | *.seed 131 | *.pid.lock 132 | 133 | # Directory for instrumented libs generated by jscoverage/JSCover 134 | lib-cov 135 | 136 | # Coverage directory used by tools like istanbul 137 | coverage 138 | *.lcov 139 | 140 | # nyc test coverage 141 | .nyc_output 142 | 143 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 144 | .grunt 145 | 146 | # Bower dependency directory (https://bower.io/) 147 | bower_components 148 | 149 | # node-waf configuration 150 | .lock-wscript 151 | 152 | # Compiled binary addons (https://nodejs.org/api/addons.html) 153 | build/Release 154 | 155 | # Dependency directories 156 | node_modules/ 157 | jspm_packages/ 158 | 159 | # TypeScript v1 declaration files 160 | typings/ 161 | 162 | # TypeScript cache 163 | *.tsbuildinfo 164 | 165 | # Optional npm cache directory 166 | .npm 167 | 168 | # Optional eslint cache 169 | .eslintcache 170 | 171 | # Optional REPL history 172 | .node_repl_history 173 | 174 | # Output of 'npm pack' 175 | *.tgz 176 | 177 | # Yarn Integrity file 178 | .yarn-integrity 179 | 180 | # dotenv environment variables file 181 | .env 182 | .env.test 183 | 184 | # parcel-bundler cache (https://parceljs.org/) 185 | 186 | # next.js build output 187 | .next 188 | 189 | # nuxt.js build output 190 | .nuxt 191 | 192 | # rollup.js default build output 193 | 194 | # Uncomment the public line if your project uses Gatsby 195 | # https://nextjs.org/blog/next-9-1#public-directory-support 196 | # https://create-react-app.dev/docs/using-the-public-folder/#docsNav 197 | # public 198 | 199 | # Storybook build outputs 200 | .out 201 | .storybook-out 202 | 203 | # vuepress build output 204 | .vuepress/dist 205 | 206 | # Serverless directories 207 | .serverless/ 208 | 209 | # FuseBox cache 210 | .fusebox/ 211 | 212 | # DynamoDB Local files 213 | .dynamodb/ 214 | 215 | # Temporary folders 216 | tmp/ 217 | temp/ 218 | 219 | ### VisualStudioCode Patch ### 220 | # Ignore all local history of files 221 | .history 222 | 223 | # End of https://www.gitignore.io/api/node,flask,python,visualstudiocode 224 | 225 | .DS_Store 226 | /static 227 | 228 | # local env files 229 | .env.local 230 | .env.*.local 231 | 232 | # Editor directories and files 233 | .idea 234 | .vscode 235 | *.suo 236 | *.ntvs* 237 | *.njsproj 238 | *.sln 239 | *.sw? 240 | 241 | /config 242 | test.sh 243 | test.py 244 | 245 | # Docker volume 246 | /dailynotes-volume 247 | 248 | # Generated exports 249 | export.zip 250 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nikolaik/python-nodejs:python3.8-nodejs12-alpine 2 | 3 | RUN mkdir /app 4 | WORKDIR /app 5 | 6 | COPY . . 7 | 8 | RUN apk add build-base libffi-dev 9 | 10 | RUN \ 11 | cd /app && \ 12 | pip install -r requirements.txt && \ 13 | chmod +x run.sh && \ 14 | chmod +x verify_env.py && \ 15 | chmod +x verify_data_migrations.py 16 | 17 | RUN \ 18 | cd /app/client && \ 19 | npm ci && \ 20 | npm rebuild node-sass && \ 21 | npm run build 22 | 23 | EXPOSE 5000 24 | ENTRYPOINT "./run.sh" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DailyNotes: Daily tasks and notes in Markdown 2 | 3 |

4 | 5 | 6 | 7 | 8 |

9 | 10 | Current version: **1.0-beta18** 11 | 12 | ## About 13 | The idea for this app came from using my Hobonichi Techo planner every morning to write down what I needed to accomplish that day & using it for scratching down random thoughts and notes as the day went on. The closest thing I've seen to an app for replacing this system is Noteplan, but I don't use a Mac or an iOS device, and it's not self-hostable, so I decided to write my own. 14 | 15 | Since I had the need for keeping track of to-dos throughout the day, regular Markdown didn't work for me since it doesn't natively support tasks. So as an alternative I'm using Github Flavored Markdown (GFM). I really wanted it to feel like an actual text editor and not just a textbox, so I decided to use CodeMirror to handle all the input. Fira Code is used to provide font ligatures. Some other nice features include code highlighting, text/code folding, and a task list where you can toggle the status of any task from any date or note. 16 | 17 | ## Roadmap 18 | I'd like to try add include at least of some the following features to get to a final v1.0 release: 19 | 20 | - iCal support 21 | - HTML preview (instead of just markdown) 22 | - Kanban board for tasks (and new syntax to attach meta info like swimlane and project for each task) 23 | - Nested tagging 24 | 25 | 26 | ## In Action 27 | Here is some screenshots of what it looks like: 28 | 29 | Main editor: 30 | 31 | ![](https://i.imgur.com/WEZff9a.png) 32 | 33 | Search page: 34 | 35 | ![](https://i.imgur.com/JKqHlhT.png) 36 | 37 | 38 | Task list: 39 | 40 | ![](https://i.imgur.com/TSHboCT.png) 41 | 42 | ## Running 43 | The recommended way of running is to pull the image from [Docker Hub](https://hub.docker.com/r/m0ngr31/dailynotes). 44 | 45 | ### Docker Setup 46 | 47 | #### Environment Variables 48 | | Environment Variable | Description | Default | 49 | |---|---|---| 50 | | API_SECRET_KEY | Used to sign API tokens. | Will be generated automatically if not passed in. | 51 | | DATABASE_URI | Connection string for DB. | Will create and use a SQLite DB if not passed in. | 52 | | DB_ENCRYPTION_KEY | Secret key for encrypting data. Length must be a multiple of 16.

*Warning*: If changed data will not be able to be decrypted! | Will be generated automatically if not passed in. | 53 | | PREVENT_SIGNUPS | Disable signup form? Anything in this variable will prevent signups. | False | 54 | | BASE_URL | Used when using a subfolder on a reverse proxy | None | 55 | | PUID | User ID (for folder permissions) | None | 56 | | PGID | Group ID (for folder permissions) | None | 57 | 58 | 59 | #### Volumes 60 | | Volume Name | Description | 61 | |---|---| 62 | | /app/config | Used to store DB and environment variables. This is not needed if you pass in all of the above environment variables. | 63 | 64 | 65 | #### Docker Run 66 | By default, the easiest way to get running is: 67 | 68 | ```bash 69 | docker run -p 5000:5000 -v /config_dir:/app/config m0ngr31/dailynotes 70 | ``` 71 | 72 | ## Development setup 73 | 74 | ### Installing dependencies 75 | You need Python (works on 2 and 3) and Node >= 8 installed 76 | 77 | ```bash 78 | pip install -r requirements.txt 79 | cd client 80 | npm ci 81 | ``` 82 | 83 | ### Creating the environment 84 | You can use the environment variables from above, or you can generate new ones by running the following: 85 | 86 | ```bash 87 | ./verify_env.py 88 | ``` 89 | 90 | Keep in mind that since the data is encrypted, if you modify the `DB_ENCRYPTION_KEY` variable, your data will not be accessible anymore. 91 | 92 | ### Running 93 | During development you need to run the client and server simultaneously 94 | 95 | ```bash 96 | ./run.sh 97 | ``` 98 | 99 | ```bash 100 | cd client 101 | npm run serve 102 | ``` 103 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from config import Config 3 | from flask_sqlalchemy import SQLAlchemy 4 | from flask_migrate import Migrate 5 | from flask_jwt_extended import JWTManager 6 | from flask_argon2 import Argon2 7 | 8 | 9 | app = Flask(__name__, 10 | static_url_path='/static', 11 | static_folder = "../dist/static", 12 | template_folder = "../dist" 13 | ) 14 | 15 | app.config.from_object(Config) 16 | db = SQLAlchemy(app) 17 | migrate = Migrate(app, db) 18 | jwt = JWTManager(app) 19 | argon2 = Argon2(app) 20 | 21 | from app import routes, models 22 | -------------------------------------------------------------------------------- /app/model_types.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.types import TypeDecorator, CHAR 2 | from sqlalchemy.dialects.postgresql import UUID 3 | import uuid 4 | 5 | 6 | class GUID(TypeDecorator): 7 | """Platform-independent GUID type. 8 | 9 | Uses PostgreSQL's UUID type, otherwise uses 10 | CHAR(32), storing as stringified hex values. 11 | 12 | """ 13 | impl = CHAR 14 | 15 | def load_dialect_impl(self, dialect): 16 | if dialect.name == 'postgresql': 17 | return dialect.type_descriptor(UUID()) 18 | else: 19 | return dialect.type_descriptor(CHAR(32)) 20 | 21 | def process_bind_param(self, value, dialect): 22 | if value is None: 23 | return value 24 | elif dialect.name == 'postgresql': 25 | return str(value) 26 | else: 27 | if not isinstance(value, uuid.UUID): 28 | return "%.32x" % uuid.UUID(value).int 29 | else: 30 | # hexstring 31 | return "%.32x" % value.int 32 | 33 | def process_result_value(self, value, dialect): 34 | if value is None: 35 | return value 36 | else: 37 | if not isinstance(value, uuid.UUID): 38 | value = uuid.UUID(value) 39 | return value 40 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | from app import app, db 2 | from app.model_types import GUID 3 | from sqlalchemy.sql import func 4 | from sqlalchemy.ext.hybrid import hybrid_property 5 | from sqlalchemy import event 6 | from sqlalchemy.orm.attributes import InstrumentedAttribute 7 | from Crypto.Cipher import AES 8 | import binascii 9 | import uuid 10 | import frontmatter 11 | import re 12 | 13 | 14 | key = app.config['DB_ENCRYPTION_KEY'] 15 | 16 | 17 | def aes_encrypt(data): 18 | cipher = AES.new(key, AES.MODE_CFB, key[::-1]) 19 | return cipher.encrypt(data) 20 | 21 | def aes_encrypt_old(data): 22 | cipher = AES.new(key) 23 | data = data + (" " * (16 - (len(data) % 16))) 24 | return binascii.hexlify(cipher.encrypt(data)) 25 | 26 | def aes_decrypt(data): 27 | # From a new object 28 | if type(data) is InstrumentedAttribute: 29 | return '' 30 | 31 | cipher = AES.new(key, AES.MODE_CFB, key[::-1]) 32 | 33 | decrypted = cipher.decrypt(data) 34 | 35 | try: 36 | return decrypted.decode('utf-8') 37 | except: 38 | # Data is in old encryption or it is unencrypted 39 | return aes_decrypt_old(data) 40 | 41 | def aes_decrypt_old(data): 42 | try: 43 | cipher = AES.new(key) 44 | return cipher.decrypt(binascii.unhexlify(data)).rstrip().decode('ascii') 45 | except: 46 | # If data is not encrypted, just return it 47 | return data 48 | 49 | 50 | class User(db.Model): 51 | uuid = db.Column(GUID, primary_key=True, index=True, unique=True, default=lambda: uuid.uuid4()) 52 | username = db.Column(db.String(64), unique=True, nullable=False) 53 | password_hash = db.Column(db.String(128), nullable=False) 54 | auto_save = db.Column(db.Boolean, nullable=True) 55 | notes = db.relationship('Note', lazy='dynamic', cascade='all, delete, delete-orphan') 56 | meta = db.relationship('Meta', lazy='dynamic', cascade='all, delete, delete-orphan') 57 | 58 | def __repr__(self): 59 | return ''.format(self.uuid) 60 | 61 | 62 | class Meta(db.Model): 63 | uuid = db.Column(GUID, primary_key=True, index=True, unique=True, default=lambda: uuid.uuid4()) 64 | user_id = db.Column(GUID, db.ForeignKey('user.uuid'), nullable=False) 65 | note_id = db.Column(GUID, db.ForeignKey('note.uuid'), nullable=False) 66 | name_encrypted = db.Column('name', db.String) 67 | name_compare = db.Column(db.String) 68 | kind = db.Column(db.String) 69 | 70 | @hybrid_property 71 | def name(self): 72 | return aes_decrypt(self.name_encrypted) 73 | 74 | @name.setter 75 | def name(self, value): 76 | self.name_encrypted = aes_encrypt(value) 77 | 78 | def __repr__(self): 79 | return ''.format(self.uuid) 80 | 81 | @property 82 | def serialize(self): 83 | return { 84 | 'uuid': self.uuid, 85 | 'name': self.name, 86 | 'kind': self.kind, 87 | 'note_id': self.note_id, 88 | } 89 | 90 | 91 | class Note(db.Model): 92 | uuid = db.Column(GUID, primary_key=True, index=True, unique=True, default=lambda: uuid.uuid4()) 93 | user_id = db.Column(GUID, db.ForeignKey('user.uuid'), nullable=False) 94 | data = db.Column(db.String) 95 | title = db.Column(db.String(128), nullable=False) 96 | date = db.Column(db.DateTime(timezone=True), server_default=func.now()) 97 | is_date = db.Column(db.Boolean, default=False) 98 | meta = db.relationship('Meta', lazy='dynamic', cascade='all, delete, delete-orphan') 99 | 100 | @hybrid_property 101 | def text(self): 102 | return aes_decrypt(self.data) 103 | 104 | @text.setter 105 | def text(self, value): 106 | self.data = aes_encrypt(value) 107 | 108 | @hybrid_property 109 | def name(self): 110 | return aes_decrypt(self.title) 111 | 112 | @name.setter 113 | def name(self, value): 114 | self.title = aes_encrypt(value) 115 | 116 | def __repr__(self): 117 | return ''.format(self.uuid) 118 | 119 | @property 120 | def serialize(self): 121 | return { 122 | 'uuid': self.uuid, 123 | 'data': self.text, 124 | 'title': self.name, 125 | 'date': self.date, 126 | 'is_date': self.is_date, 127 | } 128 | 129 | 130 | # Update title automatically 131 | def before_change_note(mapper, connection, target): 132 | title = None 133 | 134 | data = frontmatter.loads(target.text) 135 | 136 | if isinstance(data.get('title'), str) and len(data.get('title')) > 0: 137 | title = data.get('title') 138 | 139 | if title and not target.is_date: 140 | target.name = title 141 | 142 | 143 | # Handle changes to tasks, projects, and tags 144 | def after_change_note(mapper, connection, target): 145 | tags = [] 146 | projects = [] 147 | 148 | data = frontmatter.loads(target.text) 149 | 150 | if isinstance(data.get('tags'), list): 151 | tags = list(set([x.replace(',', '\,') for x in data.get('tags')])) 152 | elif isinstance(data.get('tags'), str): 153 | tags = list(set(map(str.strip, data['tags'].split(',')))) 154 | tags = [x for x in tags if x] 155 | 156 | if isinstance(data.get('projects'), list): 157 | projects = list(set([x.replace(',', '\,') for x in data.get('projects')])) 158 | elif isinstance(data.get('projects'), str): 159 | projects = list(set(map(str.strip, data['projects'].split(',')))) 160 | projects = [x for x in projects if x] 161 | 162 | tasks = re.findall("- \[[x| ]\] .*$", data.content, re.MULTILINE) 163 | 164 | existing_tags = [] 165 | existing_projects = [] 166 | existing_tasks = [] 167 | 168 | metas = Meta.query.filter_by(note_id=target.uuid).all() 169 | 170 | for meta in metas: 171 | if meta.kind == 'tag': 172 | existing_tags.append(meta) 173 | elif meta.kind == 'project': 174 | existing_projects.append(meta) 175 | elif meta.kind == 'task': 176 | existing_tasks.append(meta) 177 | 178 | for tag in existing_tags: 179 | if tag.name not in tags: 180 | connection.execute( 181 | 'DELETE FROM meta WHERE uuid = ?', 182 | '{}'.format(tag.uuid).replace('-', '') 183 | ) 184 | else: 185 | tags.remove(tag.name) 186 | 187 | for tag in tags: 188 | connection.execute( 189 | 'INSERT INTO meta (uuid, user_id, note_id, name, kind) VALUES (?, ?, ?, ?, ?)', 190 | '{}'.format(uuid.uuid4()).replace('-', ''), 191 | '{}'.format(target.user_id).replace('-', ''), 192 | '{}'.format(target.uuid).replace('-', ''), 193 | aes_encrypt(tag), 194 | 'tag' 195 | ) 196 | 197 | for project in existing_projects: 198 | if project.name not in projects: 199 | connection.execute( 200 | 'DELETE FROM meta WHERE uuid = ?', 201 | '{}'.format(project.uuid).replace('-', '') 202 | ) 203 | else: 204 | projects.remove(project.name) 205 | 206 | for project in projects: 207 | connection.execute( 208 | 'INSERT INTO meta (uuid, user_id, note_id, name, kind) VALUES (?, ?, ?, ?, ?)', 209 | '{}'.format(uuid.uuid4()).replace('-', ''), 210 | '{}'.format(target.user_id).replace('-', ''), 211 | '{}'.format(target.uuid).replace('-', ''), 212 | aes_encrypt(project), 213 | 'project' 214 | ) 215 | 216 | for task in existing_tasks: 217 | if task.name not in tasks: 218 | connection.execute( 219 | 'DELETE FROM meta WHERE uuid = ?', 220 | '{}'.format(task.uuid).replace('-', '') 221 | ) 222 | else: 223 | tasks.remove(task.name) 224 | 225 | for task in tasks: 226 | encrypted_task = aes_encrypt(task) 227 | 228 | connection.execute( 229 | 'INSERT INTO meta (uuid, user_id, note_id, name, name_compare, kind) VALUES (?, ?, ?, ?, ?, ?)', 230 | '{}'.format(uuid.uuid4()).replace('-', ''), 231 | '{}'.format(target.user_id).replace('-', ''), 232 | '{}'.format(target.uuid).replace('-', ''), 233 | encrypted_task, 234 | encrypted_task, 235 | 'task' 236 | ) 237 | 238 | def before_update_task(mapper, connection, target): 239 | if target.kind != 'task': 240 | return 241 | 242 | if target.name_encrypted == target.name_compare: 243 | return 244 | 245 | note = Note.query.get(target.note_id) 246 | 247 | if not note: 248 | return 249 | 250 | note_data = aes_encrypt(note.text.replace(aes_decrypt(target.name_compare), target.name)) 251 | 252 | connection.execute( 253 | 'UPDATE note SET data = ? WHERE uuid = ?', 254 | note_data, 255 | '{}'.format(note.uuid).replace('-', '') 256 | ) 257 | 258 | target.name_compare = target.name_encrypted 259 | 260 | 261 | event.listen(Note, 'before_insert', before_change_note) 262 | event.listen(Note, 'before_update', before_change_note) 263 | event.listen(Note, 'after_insert', after_change_note) 264 | event.listen(Note, 'after_update', after_change_note) 265 | event.listen(Meta, 'before_update', before_update_task) 266 | -------------------------------------------------------------------------------- /app/routes.py: -------------------------------------------------------------------------------- 1 | import os 2 | import zipfile 3 | 4 | from app import app, db, argon2 5 | from app.models import User, Note, Meta, aes_encrypt, aes_encrypt_old 6 | from flask import render_template, request, jsonify, abort, send_file 7 | from flask_jwt_extended import jwt_required, create_access_token, get_jwt_identity 8 | 9 | 10 | @app.route('/api/sign-up', methods=['POST']) 11 | def sign_up(): 12 | if app.config['PREVENT_SIGNUPS']: 13 | abort(400) 14 | 15 | req = request.get_json() 16 | username = req.get('username') 17 | password = req.get('password') 18 | 19 | if not username or not password: 20 | abort(400) 21 | 22 | password_hash = argon2.generate_password_hash(password) 23 | 24 | new_user = User(username=username.lower(), password_hash=password_hash) 25 | db.session.add(new_user) 26 | db.session.commit() 27 | 28 | access_token = create_access_token(identity=username) 29 | return jsonify(access_token=access_token), 200 30 | 31 | 32 | @app.route('/api/login', methods=['POST']) 33 | def login(): 34 | req = request.get_json() 35 | username = req.get('username') 36 | password = req.get('password') 37 | 38 | if not username or not password: 39 | abort(400) 40 | 41 | user = User.query.filter_by(username=username.lower()).first() 42 | 43 | if not user: 44 | return jsonify({"msg": "Bad username or password"}), 401 45 | 46 | if not argon2.check_password_hash(user.password_hash, password): 47 | return jsonify({"msg": "Bad username or password"}), 401 48 | 49 | access_token = create_access_token(identity=username) 50 | return jsonify(access_token=access_token), 200 51 | 52 | 53 | @app.route('/api/save_day', methods=['PUT']) 54 | @jwt_required() 55 | def save_day(): 56 | req = request.get_json() 57 | title = req.get('title') 58 | data = req.get('data', '') 59 | 60 | if not title: 61 | abort(400) 62 | 63 | username = get_jwt_identity() 64 | 65 | if not username: 66 | abort(401) 67 | 68 | user = User.query.filter_by(username=username.lower()).first() 69 | 70 | if not user: 71 | abort(400) 72 | 73 | enc_date = aes_encrypt(title) 74 | note = user.notes.filter_by(title=enc_date).first() 75 | 76 | if not Note: 77 | # Check old encryption 78 | enc_date = aes_encrypt_old(title) 79 | note = user.notes.filter_by(title=enc_date).first() 80 | if not note: 81 | note = Note(user_id=user.uuid, name=title, text=data, is_date=True) 82 | else: 83 | note.text = data 84 | 85 | db.session.add(note) 86 | db.session.flush() 87 | db.session.commit() 88 | 89 | return jsonify(note=note.serialize), 200 90 | 91 | 92 | @app.route('/api/create_note', methods=['POST']) 93 | @jwt_required() 94 | def create_note(): 95 | req = request.get_json() 96 | data = req.get('data', '') 97 | 98 | if not data: 99 | abort(400) 100 | 101 | username = get_jwt_identity() 102 | 103 | if not username: 104 | abort(401) 105 | 106 | user = User.query.filter_by(username=username.lower()).first() 107 | 108 | if not user: 109 | abort(400) 110 | 111 | note = Note(user_id=user.uuid, text=data) 112 | 113 | db.session.add(note) 114 | db.session.flush() 115 | db.session.commit() 116 | 117 | return jsonify(note=note.serialize), 200 118 | 119 | 120 | @app.route('/api/save_task', methods=['PUT']) 121 | @jwt_required() 122 | def save_task(): 123 | req = request.get_json() 124 | uuid = req.get('uuid') 125 | name = req.get('name') 126 | 127 | if not uuid or not name: 128 | abort(400) 129 | 130 | username = get_jwt_identity() 131 | 132 | if not username: 133 | abort(401) 134 | 135 | user = User.query.filter_by(username=username.lower()).first() 136 | 137 | if not user: 138 | abort(400) 139 | 140 | task = user.meta.filter_by(uuid=uuid).first() 141 | 142 | if not task: 143 | abort(400) 144 | 145 | task.name = name 146 | 147 | db.session.add(task) 148 | db.session.flush() 149 | db.session.commit() 150 | 151 | return jsonify({}), 200 152 | 153 | 154 | @app.route('/api/save_note', methods=['PUT']) 155 | @jwt_required() 156 | def save_note(): 157 | req = request.get_json() 158 | uuid = req.get('uuid') 159 | data = req.get('data', '') 160 | 161 | if not uuid: 162 | abort(400) 163 | 164 | username = get_jwt_identity() 165 | 166 | if not username: 167 | abort(401) 168 | 169 | user = User.query.filter_by(username=username.lower()).first() 170 | 171 | if not user: 172 | abort(400) 173 | 174 | note = user.notes.filter_by(uuid=uuid).first() 175 | 176 | if not note: 177 | abort(400) 178 | 179 | note.text = data 180 | 181 | db.session.add(note) 182 | db.session.flush() 183 | db.session.commit() 184 | 185 | return jsonify(note=note.serialize), 200 186 | 187 | 188 | @app.route('/api/delete_note/', methods=['DELETE']) 189 | @jwt_required() 190 | def delete_note(uuid): 191 | if not uuid: 192 | abort(400) 193 | 194 | username = get_jwt_identity() 195 | 196 | if not username: 197 | abort(401) 198 | 199 | user = User.query.filter_by(username=username.lower()).first() 200 | 201 | if not user: 202 | abort(400) 203 | 204 | note = user.notes.filter_by(uuid=uuid).first() 205 | 206 | if not note: 207 | abort(400) 208 | 209 | db.session.delete(note) 210 | db.session.commit() 211 | 212 | return jsonify({}), 200 213 | 214 | 215 | @app.route('/api/refresh_jwt', methods=['GET']) 216 | @jwt_required() 217 | def refresh_jwt(): 218 | username = get_jwt_identity() 219 | 220 | if not username: 221 | abort(401) 222 | 223 | access_token = create_access_token(identity=username) 224 | return jsonify(token=access_token), 200 225 | 226 | 227 | @app.route('/api/note', methods=['GET']) 228 | @jwt_required() 229 | def get_note(): 230 | uuid = request.args.get('uuid') 231 | 232 | if not uuid: 233 | abort(400) 234 | 235 | username = get_jwt_identity() 236 | user = User.query.filter_by(username=username.lower()).first() 237 | 238 | if not user: 239 | abort(400) 240 | 241 | note = user.notes.filter_by(uuid=uuid).first() 242 | 243 | if not note: 244 | abort(400) 245 | 246 | return jsonify(note=note.serialize), 200 247 | 248 | 249 | @app.route('/api/date', methods=['GET']) 250 | @jwt_required() 251 | def get_date(): 252 | date = request.args.get('date') 253 | 254 | if not date: 255 | abort(400) 256 | 257 | username = get_jwt_identity() 258 | user = User.query.filter_by(username=username.lower()).first() 259 | 260 | if not user: 261 | abort(400) 262 | 263 | ret_note = { 264 | 'title': date, 265 | 'data': '---\ntags: \nprojects: \n---\n\n', 266 | 'is_date': True, 267 | 'user_id': user.uuid 268 | } 269 | 270 | date_enc = aes_encrypt(date) 271 | note = user.notes.filter_by(title=date_enc, is_date=True).first() 272 | 273 | if not note: 274 | # Check old encryption 275 | date_enc = aes_encrypt_old(date) 276 | note = user.notes.filter_by(title=date_enc, is_date=True).first() 277 | 278 | if note: 279 | ret_note = note.serialize 280 | 281 | return jsonify(day=ret_note), 200 282 | 283 | 284 | @app.route('/api/events', methods=['GET']) 285 | @jwt_required() 286 | def cal_events(): 287 | username = get_jwt_identity() 288 | user = User.query.filter_by(username=username.lower()).first() 289 | 290 | if not user: 291 | abort(400) 292 | 293 | # TODO: Only do current month or something 294 | notes = user.notes.filter_by(is_date=True).all() 295 | 296 | return jsonify(events=[x.name for x in notes]), 200 297 | 298 | 299 | @app.route('/api/sidebar', methods=['GET']) 300 | @jwt_required() 301 | def sidebar_data(): 302 | username = get_jwt_identity() 303 | user = User.query.filter_by(username=username.lower()).first() 304 | 305 | if not user: 306 | abort(400) 307 | 308 | notes = sorted([a.serialize for a in user.notes.filter_by(is_date=False).all()], key=lambda note: note['title'].lower()) 309 | tags = sorted(set([a.name for a in user.meta.filter_by(kind="tag").all()]), key=lambda s: s.lower()) 310 | projects = sorted(set([a.name for a in user.meta.filter_by(kind="project").all()]), key=lambda s: s.lower()) 311 | tasks = sorted([a.serialize for a in user.meta.filter_by(kind="task").all()], key=lambda task: task['note_id']) 312 | auto_save = user.auto_save 313 | 314 | return jsonify(tags=tags,projects=projects,notes=notes,tasks=tasks,auto_save=auto_save), 200 315 | 316 | 317 | @app.route('/api/toggle_auto_save', methods=['POST']) 318 | @jwt_required() 319 | def toggle_auto_save(): 320 | req = request.get_json() 321 | auto_save = req.get('auto_save', False) 322 | 323 | username = get_jwt_identity() 324 | 325 | if not username: 326 | abort(401) 327 | 328 | user = User.query.filter_by(username=username.lower()).first() 329 | 330 | if not user: 331 | abort(400) 332 | 333 | user.auto_save = auto_save 334 | 335 | db.session.add(user) 336 | db.session.flush() 337 | db.session.commit() 338 | 339 | return jsonify({}), 200 340 | 341 | 342 | @app.route('/api/search', methods=['POST']) 343 | @jwt_required() 344 | def search(): 345 | req = request.get_json() 346 | selected_search = req.get('selected', '') 347 | search_string = req.get('search', '') 348 | 349 | if not selected_search or not search_string or not len(search_string) > 0: 350 | abort(400) 351 | 352 | if selected_search not in ['project', 'tag', 'search']: 353 | abort(400) 354 | 355 | username = get_jwt_identity() 356 | 357 | if not username: 358 | abort(401) 359 | 360 | user = User.query.filter_by(username=username.lower()).first() 361 | 362 | if not user: 363 | abort(400) 364 | 365 | matched_notes = [] 366 | 367 | if selected_search == 'project': 368 | all_projects = user.meta.filter_by(kind="project").all() 369 | 370 | for project in all_projects: 371 | if search_string.lower() in project.name.lower(): 372 | matched_notes.append(project.note_id) 373 | 374 | elif selected_search == 'tag': 375 | all_tags = user.meta.filter_by(kind="tag").all() 376 | 377 | for tag in all_tags: 378 | if search_string.lower() in tag.name.lower(): 379 | matched_notes.append(tag.note_id) 380 | 381 | elif selected_search == 'search': 382 | all_notes = user.notes.all() 383 | 384 | for note in all_notes: 385 | if search_string.lower() in note.text.lower(): 386 | matched_notes.append(note.uuid) 387 | 388 | filtered_notes = Note.query.filter(Note.uuid.in_(matched_notes)).all() 389 | notes = [] 390 | 391 | for note in filtered_notes: 392 | cleaned_note = note.serialize 393 | cleaned_note['tags'] = sorted(set([x.name for x in note.meta.filter_by(kind="tag").all()]), key=lambda s: s.lower()) 394 | cleaned_note['projects'] = sorted(set([x.name for x in note.meta.filter_by(kind="project").all()]), key=lambda s: s.lower()) 395 | notes.append(cleaned_note) 396 | 397 | sorted_nodes = sorted(notes, key=lambda s: s['title'].lower()) 398 | 399 | return jsonify(notes=sorted_nodes), 200 400 | 401 | 402 | @app.route('/api/export') 403 | @jwt_required() 404 | def export(): 405 | username = get_jwt_identity() 406 | user = User.query.filter_by(username=username.lower()).first() 407 | 408 | if not user: 409 | abort(400) 410 | 411 | zip_location = app.config['EXPORT_FILE'] 412 | zf = zipfile.ZipFile(zip_location, mode='w') 413 | os.chmod(zip_location, 0o755) 414 | notes = user.notes 415 | for note in notes: 416 | ret_note = note.serialize 417 | zf.writestr(ret_note['title'] + '.md', ret_note['data'], zipfile.ZIP_DEFLATED) 418 | print(ret_note) 419 | zf.close() 420 | 421 | rval = send_file(zip_location, as_attachment=True) 422 | os.remove(zip_location) 423 | return rval 424 | 425 | 426 | @app.route('/', defaults={'path': ''}) 427 | @app.route('/') 428 | def catch_all(path): 429 | return render_template("index.html") 430 | -------------------------------------------------------------------------------- /client/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: ["plugin:vue/essential", "@vue/prettier", "@vue/typescript"], 7 | rules: { 8 | "no-console": process.env.NODE_ENV === "production" ? "error" : "off", 9 | "no-debugger": process.env.NODE_ENV === "production" ? "error" : "off" 10 | }, 11 | parserOptions: { 12 | parser: "@typescript-eslint/parser" 13 | }, 14 | overrides: [ 15 | { 16 | files: [ 17 | "**/__tests__/*.{j,t}s?(x)", 18 | "**/tests/unit/**/*.spec.{j,t}s?(x)" 19 | ], 20 | env: { 21 | jest: true 22 | } 23 | } 24 | ] 25 | }; 26 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | -------------------------------------------------------------------------------- /client/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "@vue/cli-plugin-unit-jest/presets/typescript" 3 | }; 4 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "daily-notes", 3 | "version": "1.0.0-beta.15", 4 | "private": true, 5 | "license": "MIT", 6 | "repository": { 7 | "url": "https://github.com/m0ngr31/DailyNotes" 8 | }, 9 | "readme": "https://github.com/m0ngr31/DailyNotes/blob/master/README.md", 10 | "scripts": { 11 | "serve": "vue-cli-service serve", 12 | "build": "vue-cli-service build", 13 | "test:unit": "vue-cli-service test:unit", 14 | "lint": "vue-cli-service lint" 15 | }, 16 | "dependencies": { 17 | "@fortawesome/fontawesome-free": "^5.12.0", 18 | "axios": "^0.21.1", 19 | "buefy": "^0.8.9", 20 | "bulmaswatch": "^0.7.5", 21 | "codemirror": "^5.50.2", 22 | "date-fns": "^2.8.1", 23 | "lodash": "^4.17.15", 24 | "vue": "^2.6.10", 25 | "vue-class-component": "^7.0.2", 26 | "vue-masonry-css": "^1.0.3", 27 | "vue-meta": "^2.3.1", 28 | "vue-property-decorator": "^8.3.0", 29 | "vue-router": "^3.1.3" 30 | }, 31 | "devDependencies": { 32 | "@types/codemirror": "0.0.82", 33 | "@types/jest": "^24.0.19", 34 | "@types/lodash": "^4.14.149", 35 | "@types/node": "^14.11.1", 36 | "@vue/cli-plugin-eslint": "^4.1.0", 37 | "@vue/cli-plugin-router": "^4.1.0", 38 | "@vue/cli-plugin-typescript": "^4.1.0", 39 | "@vue/cli-plugin-unit-jest": "^4.1.0", 40 | "@vue/cli-service": "^4.1.0", 41 | "@vue/eslint-config-prettier": "^5.0.0", 42 | "@vue/eslint-config-typescript": "^4.0.0", 43 | "@vue/test-utils": "1.0.0-beta.29", 44 | "eslint": "^5.16.0", 45 | "eslint-plugin-prettier": "^3.1.1", 46 | "eslint-plugin-vue": "^5.0.0", 47 | "lint-staged": "^9.5.0", 48 | "node-sass": "^4.13.0", 49 | "prettier": "^1.19.1", 50 | "sass-loader": "^8.0.0", 51 | "typescript": "~3.5.3", 52 | "vue-template-compiler": "^2.6.10" 53 | }, 54 | "gitHooks": { 55 | "pre-commit": "lint-staged" 56 | }, 57 | "lint-staged": { 58 | "*.{js,vue,ts}": [ 59 | "vue-cli-service lint", 60 | "git add" 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /client/public/apple-touch-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/apple-touch-icon-114x114.png -------------------------------------------------------------------------------- /client/public/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /client/public/apple-touch-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/apple-touch-icon-144x144.png -------------------------------------------------------------------------------- /client/public/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /client/public/apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /client/public/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /client/public/apple-touch-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/apple-touch-icon-72x72.png -------------------------------------------------------------------------------- /client/public/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /client/public/favicon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/favicon-128.png -------------------------------------------------------------------------------- /client/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/favicon-16x16.png -------------------------------------------------------------------------------- /client/public/favicon-196x196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/favicon-196x196.png -------------------------------------------------------------------------------- /client/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/favicon-32x32.png -------------------------------------------------------------------------------- /client/public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/favicon-96x96.png -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 | DailyNotes 30 | 31 | 32 | 35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /client/public/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/mstile-144x144.png -------------------------------------------------------------------------------- /client/public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/mstile-150x150.png -------------------------------------------------------------------------------- /client/public/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/mstile-310x150.png -------------------------------------------------------------------------------- /client/public/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/mstile-310x310.png -------------------------------------------------------------------------------- /client/public/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0ngr31/DailyNotes/c3064dc6c7cf919422c515f6635b042a3fbfbda0/client/public/mstile-70x70.png -------------------------------------------------------------------------------- /client/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 19 | 20 | 38 | 39 | 151 | -------------------------------------------------------------------------------- /client/src/components/Calendar.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | -------------------------------------------------------------------------------- /client/src/components/Editor.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 148 | 149 | -------------------------------------------------------------------------------- /client/src/components/Header.vue: -------------------------------------------------------------------------------- 1 | 97 | 98 | 204 | 205 | -------------------------------------------------------------------------------- /client/src/components/NoteCard.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 62 | 63 | -------------------------------------------------------------------------------- /client/src/components/SimpleTask.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 74 | 75 | -------------------------------------------------------------------------------- /client/src/components/Tags.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 52 | 53 | -------------------------------------------------------------------------------- /client/src/components/UnsavedForm.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 95 | -------------------------------------------------------------------------------- /client/src/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface IHeaderOptions { 2 | title: string; 3 | showDateNavs?: boolean; 4 | showDelete?: boolean; 5 | hideCreate?: boolean; 6 | saveDisabled?: boolean; 7 | saveFn?: () => Promise; 8 | deleteFn?: () => Promise; 9 | } 10 | 11 | export interface INote { 12 | uuid?: string | null; 13 | data: string; 14 | title?: string; 15 | is_date?: boolean; 16 | tags?: string; 17 | projects?: string; 18 | } 19 | 20 | export interface IMeta { 21 | uuid: string; 22 | name: string; 23 | note_id: string; 24 | } 25 | -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Buefy from 'buefy'; 3 | import VueMeta from 'vue-meta'; 4 | import VueMasonry from 'vue-masonry-css'; 5 | 6 | import App from './App.vue'; 7 | import router from './router'; 8 | 9 | Vue.config.productionTip = false; 10 | 11 | Vue.use(Buefy, { 12 | defaultIconPack: 'fas', 13 | }); 14 | Vue.use(VueMeta); 15 | Vue.use(VueMasonry); 16 | 17 | new Vue({ 18 | router, 19 | render: h => h(App) 20 | }).$mount('#app'); 21 | -------------------------------------------------------------------------------- /client/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import VueRouter from "vue-router"; 3 | 4 | import Home from "../views/Home.vue"; 5 | 6 | import PageNotFound from '../views/PageNotFound.vue'; 7 | import UnauthorizedPage from '../views/UnauthorizedPage.vue'; 8 | import ErrorPage from '../views/ErrorPage.vue'; 9 | 10 | import Day from '../views/Day.vue'; 11 | import Note from '../views/Note.vue'; 12 | import NewNote from '../views/NewNote.vue'; 13 | import Search from '../views/Search.vue'; 14 | import HomeRedirect from '../views/HomeRedirect.vue'; 15 | 16 | import Auth from '../views/Auth.vue'; 17 | import Login from '../views/Login.vue'; 18 | import Signup from '../views/Signup.vue'; 19 | 20 | import {getToken} from '../services/user'; 21 | import SidebarInst from '../services/sidebar'; 22 | 23 | 24 | Vue.use(VueRouter); 25 | 26 | const routes = [ 27 | { 28 | path: '/auth', 29 | component: Auth, 30 | meta: { auth: false }, 31 | children: [ 32 | { 33 | path: '', 34 | alias: 'login', 35 | name: 'Login', 36 | component: Login 37 | }, 38 | { 39 | path: 'sign-up', 40 | name: 'Sign Up', 41 | component: Signup 42 | } 43 | ] 44 | }, 45 | { 46 | path: '/', 47 | component: Home, 48 | meta: { auth: true }, 49 | children: [ 50 | { 51 | path: '', 52 | name: 'Home Redirect', 53 | component: HomeRedirect 54 | }, 55 | { 56 | path: 'date/:id', 57 | name: 'day-id', 58 | component: Day 59 | }, 60 | { 61 | path: 'note/:uuid', 62 | name: 'note-id', 63 | component: Note 64 | }, 65 | { 66 | path: 'new-note', 67 | name: 'new-note', 68 | component: NewNote 69 | }, 70 | { 71 | path: 'search', 72 | name: 'search', 73 | component: Search 74 | } 75 | ] 76 | }, 77 | { 78 | path: '/page-not-found', 79 | name: '404', 80 | component: PageNotFound 81 | }, 82 | { 83 | path: '/not-authorized', 84 | name: '401', 85 | component: UnauthorizedPage 86 | }, 87 | { 88 | path: '/error', 89 | name: 'Error', 90 | component: ErrorPage 91 | }, 92 | { 93 | path: '*', 94 | redirect: '/page-not-found' 95 | } 96 | 97 | ]; 98 | 99 | const router = new VueRouter({ 100 | mode: 'history', 101 | linkActiveClass: 'active', 102 | base: process.env.BASE_URL, 103 | routes 104 | }); 105 | 106 | router.beforeEach(async (to, from, next) => { 107 | const currentUser = getToken(); 108 | const requiresAuth = to.matched.some(record => record.meta.auth); 109 | 110 | if (requiresAuth && !currentUser) { 111 | await next({name: 'Login', query: {from: to.path}}); 112 | } else if (!requiresAuth && currentUser) { 113 | await next({name: 'Home Redirect'}); 114 | } else { 115 | if (requiresAuth && to.name !== 'day-id') { 116 | SidebarInst.date = null; 117 | } 118 | 119 | if (requiresAuth && to.name !== 'search') { 120 | SidebarInst.searchString = ''; 121 | SidebarInst.selectedSearch = ''; 122 | } 123 | 124 | await next(); 125 | } 126 | }); 127 | 128 | export default router; 129 | -------------------------------------------------------------------------------- /client/src/services/consts.ts: -------------------------------------------------------------------------------- 1 | export const newNote = `---\ntitle: \ntags: \nprojects: \n---\n\n`; 2 | export const newDay = `---\ntags: \nprojects: \n---\n\n`; 3 | -------------------------------------------------------------------------------- /client/src/services/eventHub.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | const eventHub = new Vue(); 4 | 5 | export default eventHub; 6 | -------------------------------------------------------------------------------- /client/src/services/localstorage.ts: -------------------------------------------------------------------------------- 1 | export function getItemOrDefault(key: string, _default: any = null) { 2 | if (!typeof(Storage)) { 3 | return _default; 4 | } 5 | 6 | const value = localStorage.getItem(key); 7 | 8 | if (!value){ 9 | return _default; 10 | } 11 | 12 | return JSON.parse(value); 13 | } 14 | 15 | export function setItem(key: string, data: any) { 16 | if (!typeof(Storage)) { 17 | return; 18 | } 19 | 20 | localStorage.setItem(key, JSON.stringify(data)); 21 | } 22 | -------------------------------------------------------------------------------- /client/src/services/notes.ts: -------------------------------------------------------------------------------- 1 | import {Requests} from './requests'; 2 | 3 | import {INote} from '../interfaces'; 4 | 5 | export const NoteService = { 6 | /** 7 | * Get the note for the active day. 8 | * 9 | * @param date Date in 'MM-dd-yyyy' format 10 | */ 11 | getDate: async (date: string): Promise => { 12 | if (!date) { 13 | Promise.reject(); 14 | } 15 | 16 | try { 17 | const res = await Requests.get('/date', { 18 | date 19 | }); 20 | 21 | if (res.data && res.data.day) { 22 | return res.data.day as INote; 23 | } 24 | 25 | throw new Error('no matching data'); 26 | } catch (e) { 27 | throw new Error(e); 28 | } 29 | }, 30 | 31 | /** 32 | * Get the selected note. 33 | * 34 | * @param uuid 35 | */ 36 | getNote: async (uuid: string): Promise => { 37 | if (!uuid) { 38 | Promise.reject(); 39 | } 40 | 41 | try { 42 | const res = await Requests.get('/note', { 43 | uuid, 44 | }); 45 | 46 | return res.data.note as INote; 47 | } catch (e) { 48 | throw new Error(e); 49 | } 50 | }, 51 | 52 | createNote: async (noteData: INote): Promise => { 53 | const res = await Requests.post('/create_note', noteData); 54 | return res.data.note; 55 | }, 56 | 57 | /** 58 | * Get a list of all the notes 59 | */ 60 | getNotes: async (): Promise => { 61 | try { 62 | const res = await Requests.get('/notes'); 63 | 64 | return res.data as INote[]; 65 | } catch (e) { 66 | throw new Error(e); 67 | } 68 | }, 69 | 70 | /** 71 | * Save an individual date 72 | * 73 | * @param noteData INote object 74 | */ 75 | saveDay: async (noteData: INote): Promise => { 76 | const res = await Requests.put('/save_day', noteData); 77 | return res.data.note; 78 | }, 79 | 80 | /** 81 | * Save an individual note 82 | * 83 | * @param noteData INote object 84 | */ 85 | saveNote: async (noteData: INote): Promise => { 86 | const res = await Requests.put('/save_note', noteData); 87 | return res.data.note; 88 | }, 89 | 90 | /** 91 | * Delete an individual note 92 | */ 93 | deleteNote: async (uuid: string): Promise => { 94 | await Requests.delete(`/delete_note/${uuid}`); 95 | }, 96 | 97 | /** 98 | * Exports all notes to a zip file and downloads 99 | */ 100 | exportNotes: async (): Promise => { 101 | Requests.download("/export", "export.zip"); 102 | } 103 | }; 104 | -------------------------------------------------------------------------------- /client/src/services/requests.ts: -------------------------------------------------------------------------------- 1 | import axios, {AxiosPromise} from 'axios'; 2 | 3 | import {getToken, clearToken, setToken} from './user'; 4 | import router from '../router/index'; 5 | import {SharedBuefy} from './sharedBuefy'; 6 | 7 | axios.defaults.baseURL = process.env.VUE_APP_BASE_URL 8 | ? `${process.env.VUE_APP_BASE_URL}/api` 9 | : '/api'; 10 | 11 | axios.interceptors.request.use(config => { 12 | // Get token 13 | const token = getToken(); 14 | 15 | if (token) { 16 | config.headers.common['Authorization'] = `Bearer ${token}`; 17 | } 18 | 19 | return config; 20 | }, (error) => { 21 | return Promise.reject(error); 22 | }); 23 | 24 | axios.interceptors.response.use(res => res, async err => { 25 | if (err.response && (err.response.status === 403 || err.response.status === 401 || err.response.status === 422)) { 26 | // Logout 27 | clearToken(); 28 | 29 | // Preventing dialogs from firing 30 | SharedBuefy.preventDialog = true; 31 | 32 | if (router.currentRoute.path.indexOf('/auth') !== 0) { 33 | try { 34 | (SharedBuefy.notifications as any).open({ 35 | duration: 5000, 36 | message: 'Session expired. Logging out.', 37 | position: 'is-top', 38 | type: 'is-warning' 39 | }); 40 | } catch(e) {} 41 | } 42 | 43 | try { 44 | (SharedBuefy.activeDialog as any).close(); 45 | } catch(e) {} 46 | 47 | router.push({ name: 'Login' }); 48 | } 49 | 50 | // Prevent dialogs from firing on network errors caused by expired JWTs 51 | setTimeout(() => { 52 | SharedBuefy.preventDialog = false; 53 | }, 1000); 54 | 55 | return Promise.reject(err); 56 | }); 57 | 58 | export const Requests = { 59 | post: (url: string, data: any): AxiosPromise => { 60 | return axios.post(url, data); 61 | }, 62 | 63 | get: (url: string, data?: any): AxiosPromise => { 64 | return axios.get(url, { params: data || {} }); 65 | }, 66 | 67 | put: (url: string, data: any): AxiosPromise => { 68 | return axios.put(url, data); 69 | }, 70 | 71 | delete: (url: string): AxiosPromise => { 72 | return axios.delete(url); 73 | }, 74 | 75 | download: (url: string, filename: string): void => { 76 | axios({ 77 | url: url, // File URL Goes Here 78 | method: "GET", 79 | responseType: "blob" 80 | }).then(res => { 81 | var FILE = window.URL.createObjectURL(new Blob([res.data])); 82 | var docUrl = document.createElement("a"); 83 | docUrl.href = FILE; 84 | docUrl.setAttribute("download", filename); 85 | document.body.appendChild(docUrl); 86 | docUrl.click(); 87 | document.body.removeChild(docUrl); 88 | }); 89 | } 90 | }; 91 | -------------------------------------------------------------------------------- /client/src/services/sharedBuefy.ts: -------------------------------------------------------------------------------- 1 | // Stupid hack to give access to Buefy outside of Vue components 2 | export const SharedBuefy = { 3 | activeDialog: null, 4 | dialog: null, 5 | notifications: null, 6 | preventDialog: false, 7 | openConfirmDialog: function(options: any = {}) { 8 | if (this.preventDialog) { 9 | return; 10 | } 11 | 12 | if (this.activeDialog) { 13 | try { 14 | (this.activeDialog as any).close(); 15 | this.activeDialog = null; 16 | } catch (e) {} 17 | } 18 | 19 | this.activeDialog = (this.dialog as any).confirm(options); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /client/src/services/sidebar.ts: -------------------------------------------------------------------------------- 1 | import parse from 'date-fns/parse'; 2 | import formatISO from 'date-fns/formatISO'; 3 | import {Route} from 'vue-router'; 4 | import _ from 'lodash'; 5 | 6 | import {Requests} from './requests'; 7 | 8 | import router from '../router'; 9 | 10 | import {INote, IMeta} from '../interfaces'; 11 | 12 | class SidebarSerivce { 13 | public hide: boolean = false; 14 | public events: any[] = []; 15 | public tags: string[] = []; 16 | public tasks: IMeta[] = []; 17 | public projects: string[] = []; 18 | public notes: INote[] = []; 19 | public calLoading: boolean = false; 20 | public autoSave: boolean = false; 21 | public date: any = null; 22 | public sidebarLoading: boolean = false; 23 | public searchLoading: boolean = false; 24 | public selectedSearch: string = ''; 25 | public searchString: any = ''; 26 | public filteredNotes: any[] = []; 27 | 28 | /** 29 | * Updates the active date on the calendar picker. This is throttled 30 | * so that it doesn't get overwhelmed. 31 | * 32 | * @param $route A VueRouter Route object 33 | */ 34 | public updateDate = _.throttle(($route: Route) => { 35 | if (!$route.params || !$route.params.id) { 36 | this.date = null; 37 | return; 38 | } 39 | 40 | try { 41 | this.date = parse($route.params.id, 'MM-dd-yyyy', new Date()); 42 | } catch (e) { 43 | // Reset date 44 | this.date = null; 45 | } 46 | }, 250, {trailing: true, leading: false}); 47 | 48 | /** 49 | * Get the event indicators for the calendar 50 | */ 51 | public async getEvents(): Promise { 52 | if (this.calLoading) { 53 | return; 54 | } 55 | 56 | let date = new Date(); 57 | 58 | if (this.date) { 59 | date = this.date; 60 | } 61 | 62 | this.calLoading = true; 63 | 64 | try { 65 | const res = await Requests.get('/events', { 66 | date: formatISO(date), 67 | }); 68 | 69 | if (res && res.data && res.data.events) { 70 | this.events = _.map(res.data.events, event => { 71 | return { 72 | date: parse(event, 'MM-dd-yyyy', new Date()), 73 | }; 74 | }); 75 | } 76 | } catch (e) {} 77 | 78 | this.calLoading = false; 79 | } 80 | 81 | /** 82 | * Gets the tags, projects, and notes information loaded into the sidebar. 83 | * 84 | * @param showLoad Boolean to show the loading indicator or not 85 | */ 86 | public async getSidebarInfo(showLoad = false): Promise { 87 | if (this.sidebarLoading) { 88 | return; 89 | } 90 | 91 | if (showLoad) { 92 | this.sidebarLoading = true; 93 | } 94 | 95 | try { 96 | const res = await Requests.get('/sidebar'); 97 | 98 | if (res && res.data) { 99 | this.tags = res.data.tags; 100 | this.tasks = res.data.tasks; 101 | this.projects = res.data.projects; 102 | this.notes = res.data.notes; 103 | this.autoSave = res.data.auto_save; 104 | } 105 | 106 | if (this.selectedSearch.length && this.searchString.length) { 107 | this.searchNotes(); 108 | } 109 | } catch (e) {} 110 | 111 | this.sidebarLoading = false; 112 | } 113 | 114 | public async searchNotes() { 115 | if (this.searchLoading) { 116 | return; 117 | } 118 | 119 | this.searchLoading = true; 120 | 121 | try { 122 | const res = await Requests.post('/search', { 123 | selected: this.selectedSearch, 124 | search: this.searchString, 125 | }); 126 | 127 | if (res && res.data) { 128 | this.filteredNotes = res.data.notes || []; 129 | } 130 | } catch (e) {} 131 | 132 | this.searchLoading = false; 133 | 134 | router.push({name: 'search', query: {[this.selectedSearch]: this.searchString}}).catch(err => {}); 135 | } 136 | 137 | public async saveTaskProgress(name: string, uuid: string) { 138 | try { 139 | await Requests.put('/save_task', {name, uuid}); 140 | this.getSidebarInfo(); 141 | } catch (e) {} 142 | } 143 | 144 | public async toggleAutoSave(autoSave: boolean) { 145 | try { 146 | await Requests.post('/toggle_auto_save', {auto_save: autoSave}); 147 | this.getSidebarInfo(); 148 | } catch (e) {} 149 | } 150 | } 151 | 152 | // Make it a singleton 153 | const SidebarInst = new SidebarSerivce(); 154 | 155 | export default SidebarInst; 156 | -------------------------------------------------------------------------------- /client/src/services/user.ts: -------------------------------------------------------------------------------- 1 | import {Requests} from './requests'; 2 | 3 | const AUTH_TOKEN = 'dn-token'; 4 | 5 | export function getToken() { 6 | if (typeof(Storage)) { 7 | return localStorage.getItem(AUTH_TOKEN); 8 | } 9 | 10 | return null; 11 | } 12 | 13 | export async function setToken(token: string) { 14 | if (typeof(Storage)) { 15 | localStorage.setItem(AUTH_TOKEN, token); 16 | } 17 | } 18 | 19 | export function clearToken() { 20 | if (typeof(Storage)) { 21 | localStorage.removeItem(AUTH_TOKEN) 22 | } 23 | } 24 | 25 | export async function updateJWT() { 26 | try { 27 | const res = await Requests.get('/refresh_jwt'); 28 | 29 | if (!res || !res.data || !res.data.token) { 30 | throw new Error('no token'); 31 | } 32 | 33 | setToken(res.data.token); 34 | } catch (e) {} 35 | } -------------------------------------------------------------------------------- /client/src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from "vue"; 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.vue" { 2 | import Vue from "vue"; 3 | export default Vue; 4 | } 5 | 6 | declare module 'vue-masonry-css'; 7 | -------------------------------------------------------------------------------- /client/src/views/Auth.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 23 | 24 | 32 | 33 | 55 | -------------------------------------------------------------------------------- /client/src/views/Day.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 281 | 282 | 289 | -------------------------------------------------------------------------------- /client/src/views/ErrorPage.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 20 | -------------------------------------------------------------------------------- /client/src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 80 | 81 | -------------------------------------------------------------------------------- /client/src/views/HomeRedirect.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 98 | -------------------------------------------------------------------------------- /client/src/views/NewNote.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 138 | 139 | 146 | -------------------------------------------------------------------------------- /client/src/views/Note.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 222 | 223 | 230 | -------------------------------------------------------------------------------- /client/src/views/PageNotFound.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | -------------------------------------------------------------------------------- /client/src/views/Search.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 105 | 106 | -------------------------------------------------------------------------------- /client/src/views/Signup.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | -------------------------------------------------------------------------------- /client/src/views/UnauthorizedPage.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | -------------------------------------------------------------------------------- /client/tests/unit/example.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from "@vue/test-utils"; 2 | import HelloWorld from "@/components/HelloWorld.vue"; 3 | 4 | describe("HelloWorld.vue", () => { 5 | it("renders props.msg when passed", () => { 6 | const msg = "new message"; 7 | const wrapper = shallowMount(HelloWorld, { 8 | propsData: { msg } 9 | }); 10 | expect(wrapper.text()).toMatch(msg); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "sourceMap": true, 13 | "baseUrl": ".", 14 | "types": [ 15 | "webpack-env", 16 | "jest" 17 | ], 18 | "paths": { 19 | "@/*": [ 20 | "src/*" 21 | ] 22 | }, 23 | "lib": [ 24 | "esnext", 25 | "dom", 26 | "dom.iterable", 27 | "scripthost" 28 | ] 29 | }, 30 | "include": [ 31 | "src/**/*.ts", 32 | "src/**/*.tsx", 33 | "src/**/*.vue", 34 | "tests/**/*.ts", 35 | "tests/**/*.tsx" 36 | ], 37 | "exclude": [ 38 | "node_modules" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /client/vue.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var path = require('path'); 3 | 4 | process.env.VUE_APP_PREVENT_SIGNUPS = process.env.PREVENT_SIGNUPS ? true : ''; 5 | process.env.VUE_APP_BASE_URL = process.env.BASE_URL ? VUE_APP_BASE_URL : ''; 6 | 7 | module.exports = { 8 | lintOnSave: false, 9 | outputDir: path.resolve(__dirname, '../dist'), 10 | assetsDir: 'static', 11 | devServer: { 12 | proxy: { 13 | '^/api': { 14 | target: 'http://localhost:5000', 15 | changeOrigin: true 16 | }, 17 | } 18 | }, 19 | configureWebpack: { 20 | plugins: [ 21 | new webpack.ContextReplacementPlugin( 22 | /date\-fns[\/\\]/, 23 | new RegExp('[/\\\\\](en)[/\\\\\]') 24 | ) 25 | ] 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import datetime 3 | 4 | basedir = os.path.abspath(os.path.dirname(__file__)) 5 | 6 | class Config(object): 7 | JWT_SECRET_KEY = os.environ.get('API_SECRET_KEY') 8 | DB_ENCRYPTION_KEY = os.environ.get('DB_ENCRYPTION_KEY') 9 | PREVENT_SIGNUPS = os.environ.get('PREVENT_SIGNUPS', False) 10 | SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URI') or 'sqlite:///' + os.path.join(basedir + '/config', 'app.db') 11 | SQLALCHEMY_TRACK_MODIFICATIONS = False 12 | JWT_ACCESS_TOKEN_EXPIRES = datetime.timedelta(days=7) 13 | EXPORT_FILE = os.path.join(basedir, 'config', 'export.zip') 14 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | 5 | server: 6 | build: . 7 | ports: 8 | - 5000:5000 9 | volumes: 10 | - ./dailynotes-volume:/app/config 11 | environment: 12 | API_SECRET_KEY: CHANGE_THIS_WITH_SECURE_PASSWORD 13 | DB_ENCRYPTION_KEY: CHANGE_THIS_WITH_SECURE_PASSWORD -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import logging 4 | from logging.config import fileConfig 5 | 6 | from sqlalchemy import engine_from_config 7 | from sqlalchemy import pool 8 | 9 | from alembic import context 10 | 11 | # this is the Alembic Config object, which provides 12 | # access to the values within the .ini file in use. 13 | config = context.config 14 | 15 | # Interpret the config file for Python logging. 16 | # This line sets up loggers basically. 17 | fileConfig(config.config_file_name) 18 | logger = logging.getLogger('alembic.env') 19 | 20 | # add your model's MetaData object here 21 | # for 'autogenerate' support 22 | # from myapp import mymodel 23 | # target_metadata = mymodel.Base.metadata 24 | from flask import current_app 25 | config.set_main_option( 26 | 'sqlalchemy.url', current_app.config.get( 27 | 'SQLALCHEMY_DATABASE_URI').replace('%', '%%')) 28 | target_metadata = current_app.extensions['migrate'].db.metadata 29 | 30 | # other values from the config, defined by the needs of env.py, 31 | # can be acquired: 32 | # my_important_option = config.get_main_option("my_important_option") 33 | # ... etc. 34 | 35 | 36 | def run_migrations_offline(): 37 | """Run migrations in 'offline' mode. 38 | 39 | This configures the context with just a URL 40 | and not an Engine, though an Engine is acceptable 41 | here as well. By skipping the Engine creation 42 | we don't even need a DBAPI to be available. 43 | 44 | Calls to context.execute() here emit the given string to the 45 | script output. 46 | 47 | """ 48 | url = config.get_main_option("sqlalchemy.url") 49 | context.configure( 50 | url=url, target_metadata=target_metadata, literal_binds=True 51 | ) 52 | 53 | with context.begin_transaction(): 54 | context.run_migrations() 55 | 56 | 57 | def run_migrations_online(): 58 | """Run migrations in 'online' mode. 59 | 60 | In this scenario we need to create an Engine 61 | and associate a connection with the context. 62 | 63 | """ 64 | 65 | # this callback is used to prevent an auto-migration from being generated 66 | # when there are no changes to the schema 67 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 68 | def process_revision_directives(context, revision, directives): 69 | if getattr(config.cmd_opts, 'autogenerate', False): 70 | script = directives[0] 71 | if script.upgrade_ops.is_empty(): 72 | directives[:] = [] 73 | logger.info('No changes in schema detected.') 74 | 75 | connectable = engine_from_config( 76 | config.get_section(config.config_ini_section), 77 | prefix='sqlalchemy.', 78 | poolclass=pool.NullPool, 79 | ) 80 | 81 | with connectable.connect() as connection: 82 | context.configure( 83 | connection=connection, 84 | target_metadata=target_metadata, 85 | # render_as_batch=True, 86 | process_revision_directives=process_revision_directives, 87 | user_module_prefix="app.model_types.", 88 | **current_app.extensions['migrate'].configure_args 89 | ) 90 | 91 | with context.begin_transaction(): 92 | context.run_migrations() 93 | 94 | 95 | if context.is_offline_mode(): 96 | run_migrations_offline() 97 | else: 98 | run_migrations_online() 99 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import app.model_types 11 | ${imports if imports else ""} 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = ${repr(up_revision)} 15 | down_revision = ${repr(down_revision)} 16 | branch_labels = ${repr(branch_labels)} 17 | depends_on = ${repr(depends_on)} 18 | 19 | 20 | def upgrade(): 21 | ${upgrades if upgrades else "pass"} 22 | 23 | 24 | def downgrade(): 25 | ${downgrades if downgrades else "pass"} 26 | -------------------------------------------------------------------------------- /migrations/versions/7bd1ee1840ca_meta_table.py: -------------------------------------------------------------------------------- 1 | """Meta Table 2 | 3 | Revision ID: 7bd1ee1840ca 4 | Revises: 9ca5901af374 5 | Create Date: 2020-01-30 01:13:40.069188 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import app.model_types 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = '7bd1ee1840ca' 15 | down_revision = '9ca5901af374' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table('meta', 23 | sa.Column('uuid', app.model_types.GUID(), nullable=False), 24 | sa.Column('user_id', app.model_types.GUID(), nullable=False), 25 | sa.Column('note_id', app.model_types.GUID(), nullable=False), 26 | sa.Column('name', sa.String(), nullable=True), 27 | sa.Column('name_compare', sa.String(), nullable=True), 28 | sa.Column('kind', sa.String(), nullable=True), 29 | sa.ForeignKeyConstraint(['note_id'], ['note.uuid'], ), 30 | sa.ForeignKeyConstraint(['user_id'], ['user.uuid'], ), 31 | sa.PrimaryKeyConstraint('uuid') 32 | ) 33 | op.create_index(op.f('ix_meta_uuid'), 'meta', ['uuid'], unique=True) 34 | # ### end Alembic commands ### 35 | 36 | 37 | def downgrade(): 38 | # ### commands auto generated by Alembic - please adjust! ### 39 | op.drop_index(op.f('ix_meta_uuid'), table_name='meta') 40 | op.drop_table('meta') 41 | # ### end Alembic commands ### 42 | -------------------------------------------------------------------------------- /migrations/versions/9bd71ed6ccff_remove_unique_constraint_name_.py: -------------------------------------------------------------------------------- 1 | """Remove unique constraint name for note title 2 | 3 | Revision ID: 9bd71ed6ccff 4 | Revises: c440f31aff28 5 | Create Date: 2021-02-27 15:10:54.803203 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import app.model_types 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = '9bd71ed6ccff' 15 | down_revision = 'c440f31aff28' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | with op.batch_alter_table('note', schema=None) as batch_op: 22 | batch_op.drop_constraint('title_uniq', type_='unique') 23 | 24 | 25 | def downgrade(): 26 | pass 27 | -------------------------------------------------------------------------------- /migrations/versions/9ca5901af374_cleanup.py: -------------------------------------------------------------------------------- 1 | """cleanup 2 | 3 | Revision ID: 9ca5901af374 4 | Revises: a477f34dbaa4 5 | Create Date: 2020-01-28 20:44:00.184324 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import app.model_types 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = '9ca5901af374' 15 | down_revision = 'a477f34dbaa4' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | with op.batch_alter_table('note', schema=None) as batch_op: 23 | batch_op.drop_column('projects') 24 | batch_op.drop_column('tags') 25 | 26 | # ### end Alembic commands ### 27 | 28 | 29 | def downgrade(): 30 | # ### commands auto generated by Alembic - please adjust! ### 31 | with op.batch_alter_table('note', schema=None) as batch_op: 32 | batch_op.add_column(sa.Column('tags', sa.VARCHAR(), nullable=True)) 33 | batch_op.add_column(sa.Column('projects', sa.VARCHAR(), nullable=True)) 34 | 35 | # ### end Alembic commands ### 36 | -------------------------------------------------------------------------------- /migrations/versions/a477f34dbaa4_initial_config.py: -------------------------------------------------------------------------------- 1 | """Initial config 2 | 3 | Revision ID: a477f34dbaa4 4 | Revises: 5 | Create Date: 2020-01-07 21:31:19.007718 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import app.model_types 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = 'a477f34dbaa4' 15 | down_revision = None 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table('user', 23 | sa.Column('uuid', app.model_types.GUID(), nullable=False), 24 | sa.Column('username', sa.String(length=64), nullable=False), 25 | sa.Column('password_hash', sa.String(length=128), nullable=False), 26 | sa.PrimaryKeyConstraint('uuid'), 27 | sa.UniqueConstraint('username') 28 | ) 29 | op.create_index(op.f('ix_user_uuid'), 'user', ['uuid'], unique=True) 30 | op.create_table('note', 31 | sa.Column('uuid', app.model_types.GUID(), nullable=False), 32 | sa.Column('tags', sa.String(), nullable=True), 33 | sa.Column('projects', sa.String(), nullable=True), 34 | sa.Column('user_id', app.model_types.GUID(), nullable=False), 35 | sa.Column('data', sa.String(), nullable=True), 36 | sa.Column('title', sa.String(length=128), nullable=False), 37 | sa.Column('date', sa.DateTime(timezone=True), server_default=sa.text(u'(CURRENT_TIMESTAMP)'), nullable=True), 38 | sa.Column('is_date', sa.Boolean(), nullable=True), 39 | sa.ForeignKeyConstraint(['user_id'], ['user.uuid'], ), 40 | sa.PrimaryKeyConstraint('uuid'), 41 | sa.UniqueConstraint('title') 42 | ) 43 | op.create_index(op.f('ix_note_uuid'), 'note', ['uuid'], unique=True) 44 | # ### end Alembic commands ### 45 | 46 | 47 | def downgrade(): 48 | # ### commands auto generated by Alembic - please adjust! ### 49 | op.drop_index(op.f('ix_note_uuid'), table_name='note') 50 | op.drop_table('note') 51 | op.drop_index(op.f('ix_user_uuid'), table_name='user') 52 | op.drop_table('user') 53 | # ### end Alembic commands ### 54 | -------------------------------------------------------------------------------- /migrations/versions/ad68860179f2_added_auto_save_column_to_user_table.py: -------------------------------------------------------------------------------- 1 | """Added auto_save column to User table 2 | 3 | Revision ID: ad68860179f2 4 | Revises: 9bd71ed6ccff 5 | Create Date: 2021-11-23 17:33:25.117589 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import app.model_types 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = 'ad68860179f2' 15 | down_revision = '9bd71ed6ccff' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | with op.batch_alter_table('user', schema=None) as batch_op: 23 | batch_op.add_column(sa.Column('auto_save', sa.Boolean(), nullable=True)) 24 | 25 | # ### end Alembic commands ### 26 | 27 | 28 | def downgrade(): 29 | # ### commands auto generated by Alembic - please adjust! ### 30 | with op.batch_alter_table('user', schema=None) as batch_op: 31 | batch_op.drop_column('auto_save') 32 | 33 | # ### end Alembic commands ### 34 | -------------------------------------------------------------------------------- /migrations/versions/c440f31aff28_add_unique_constraint_name_for_note_.py: -------------------------------------------------------------------------------- 1 | """Add named unique constraint name for note title 2 | 3 | Revision ID: c440f31aff28 4 | Revises: 7bd1ee1840ca 5 | Create Date: 2021-02-27 08:59:27.748780 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import app.model_types 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = 'c440f31aff28' 15 | down_revision = '7bd1ee1840ca' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | with op.batch_alter_table('note', schema=None) as batch_op: 22 | batch_op.create_unique_constraint('title_uniq', ['title']) 23 | 24 | 25 | def downgrade(): 26 | with op.batch_alter_table('note', schema=None) as batch_op: 27 | pass 28 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | flask_sqlalchemy 3 | flask-migrate 4 | gunicorn 5 | flask-jwt-extended>4 6 | flask-argon2 7 | python-frontmatter 8 | pycrypto -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if test -f "./config/.env"; then 4 | . ./config/.env 5 | fi 6 | 7 | ./verify_env.py 8 | 9 | if test -f "./config/.env"; then 10 | . ./config/.env 11 | fi 12 | 13 | export FLASK_APP=server.py 14 | 15 | flask db upgrade 16 | 17 | ./verify_data_migrations.py 18 | 19 | exec gunicorn server:app -b 0.0.0.0:5000 -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | from app import app 2 | -------------------------------------------------------------------------------- /verify_data_migrations.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from app import app, db 4 | from app.models import Note, Meta 5 | 6 | # Setup Flask context 7 | ctx = app.test_request_context() 8 | ctx.push() 9 | 10 | def main(): 11 | needs_migration = False 12 | 13 | first_note = Note.query.first() 14 | meta = Meta.query.first() 15 | 16 | if meta or not first_note or first_note.text is not first_note.data: 17 | return 18 | 19 | # Notes need to be migrated 20 | notes = Note.query.all() 21 | 22 | for note in notes: 23 | # Trigger a change 24 | note.text = note.data + '' 25 | note.name = note.title + '' 26 | db.session.add(note) 27 | 28 | db.session.commit() 29 | 30 | main() 31 | -------------------------------------------------------------------------------- /verify_env.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import random 4 | import string 5 | import os 6 | import os.path 7 | 8 | 9 | def gen_random(str_len): 10 | return ''.join(random.choice(string.hexdigits) for x in range(str_len)) 11 | 12 | 13 | def main(): 14 | # Don't need to create config folder if env vars are already set 15 | if os.getenv('API_SECRET_KEY', None) and os.getenv('DATABASE_URI', None) and os.getenv('DB_ENCRYPTION_KEY'): 16 | return 17 | 18 | API_SECRET_KEY = os.getenv('API_SECRET_KEY', None) 19 | ENCRYPTION_KEY = os.getenv('DB_ENCRYPTION_KEY', None) 20 | 21 | if not API_SECRET_KEY: 22 | SECRET_KEY = "export API_SECRET_KEY=\"{}\"".format(gen_random(48)) 23 | else: 24 | SECRET_KEY = "export API_SECRET_KEY=\"{}\"".format(API_SECRET_KEY) 25 | 26 | if not ENCRYPTION_KEY: 27 | ENCRYPTION_KEY = "export DB_ENCRYPTION_KEY=\"{}\"".format(gen_random(16)) 28 | else: 29 | ENCRYPTION_KEY = "export DB_ENCRYPTION_KEY=\"{}\"".format(ENCRYPTION_KEY) 30 | 31 | if not os.path.isdir('./config'): 32 | os.makedirs('./config') 33 | 34 | f = open('./config/.env', "w") 35 | f.writelines([SECRET_KEY, "\n", ENCRYPTION_KEY]) 36 | f.close() 37 | 38 | main() 39 | --------------------------------------------------------------------------------