The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .gitattributes
├── .github
    └── workflows
    │   ├── black.yml
    │   ├── build-docker-on-push-master.yml
    │   ├── build-docker-on-release.yml
    │   ├── build-docker-specific-version.yml
    │   ├── codecov.yml
    │   ├── deploy-docs.yml
    │   └── pypi-auto-deploy.yml
├── .gitignore
├── API.md
├── LICENSE
├── MANIFEST.in
├── README.md
├── SECURITY.md
├── archivy
    ├── __init__.py
    ├── api.py
    ├── cli.py
    ├── click_web
    │   ├── __init__.py
    │   ├── exceptions.py
    │   ├── resources
    │   │   ├── __init__.py
    │   │   ├── cmd_exec.py
    │   │   ├── cmd_form.py
    │   │   ├── index.py
    │   │   └── input_fields.py
    │   └── web_click_types.py
    ├── config.py
    ├── data.py
    ├── forms.py
    ├── helpers.py
    ├── models.py
    ├── routes.py
    ├── search.py
    ├── static
    │   ├── HIGHLIGHTJS-LICENSE
    │   ├── accessibility.css
    │   ├── archivy.svg
    │   ├── delete.png
    │   ├── editor.css
    │   ├── editor.js
    │   ├── editor_dark.css
    │   ├── highlight.js
    │   ├── logo.png
    │   ├── main.css
    │   ├── main_dark.css
    │   ├── markdown.css
    │   ├── markdown_dark.css
    │   ├── math.css
    │   ├── math.js
    │   ├── monokai.css
    │   ├── mvp.css
    │   ├── open_form.js
    │   ├── parser.js
    │   ├── post_and_read.js
    │   └── profile.svg
    ├── tags.py
    └── templates
    │   ├── base.html
    │   ├── bookmarklet.html
    │   ├── click_web
    │       ├── command_form.html
    │       ├── form_macros.html
    │       └── show_tree.html
    │   ├── config.html
    │   ├── dataobjs
    │       ├── new.html
    │       └── show.html
    │   ├── home.html
    │   ├── markdown-parser.html
    │   ├── tags
    │       ├── all.html
    │       └── show.html
    │   └── users
    │       ├── edit.html
    │       └── login.html
├── conftest.py
├── docs
    ├── CONTRIBUTING.md
    ├── config.md
    ├── difference.md
    ├── editing.md
    ├── example-plugin
    │   ├── README.md
    │   ├── archivy_extra_metadata
    │   │   └── __init__.py
    │   └── setup.py
    ├── img
    │   ├── local-edit.png
    │   ├── logo-2.png
    │   └── logo.png
    ├── index.md
    ├── install.md
    ├── overrides
    │   └── main.html
    ├── plugins.md
    ├── reference
    │   ├── architecture.md
    │   ├── filesystem_layer.md
    │   ├── helpers.md
    │   ├── hooks.md
    │   ├── models.md
    │   ├── search.md
    │   ├── web_api.md
    │   └── web_inputs.md
    ├── requirements.txt
    ├── setup-search.md
    ├── usage.md
    └── whats_next.md
├── mkdocs.yml
├── plugins.md
├── requirements-tests.txt
├── requirements.txt
├── setup.py
└── tests
    ├── __init__.py
    ├── functional
        ├── test_plugins.py
        └── test_routes.py
    ├── integration
        └── test_api.py
    ├── test_click_web.py
    ├── test_plugin
        ├── plugin.py
        └── setup.py
    ├── test_request_parsing.py
    └── unit
        ├── test_advanced_conf.py
        ├── test_cli.py
        ├── test_click_web.py
        └── test_models.py


/.gitattributes:
--------------------------------------------------------------------------------
1 | archivy/static/*.js linguist-vendored
2 | archivy/static/math.css linguist-vendored
3 | archivy/static/monokai.css linguist-vendored
4 | 


--------------------------------------------------------------------------------
/.github/workflows/black.yml:
--------------------------------------------------------------------------------
 1 | name: Black Code Quality
 2 | 
 3 | on: 
 4 |   push:
 5 |     branches:
 6 |       - 'master'
 7 |     paths:
 8 |       - 'archivy/**'
 9 |       - '.github/workflows/black.yml'
10 |       - 'conftest.py'
11 |       - 'tests/**'
12 | 
13 |   pull_request:
14 |     paths:
15 |       - 'archivy/**'
16 |       - '.github/workflows/black.yml'
17 |       - 'conftest.py'
18 |       - 'tests/**'
19 | 
20 | 
21 | jobs:
22 |   quality:
23 |     runs-on: ubuntu-latest
24 |     steps:
25 |     - uses: actions/checkout@v1
26 |     - name: Set up Python 3.8
27 |       uses: actions/setup-python@v1
28 |       with:
29 |         python-version: 3.8
30 |     - name: Install dependencies
31 |       run: |
32 |         python -m pip install --upgrade pip
33 |         python -m pip install wheel
34 |         pip install -r requirements.txt
35 |     - name: Lint with black
36 |       run: |
37 |         pip install black
38 |         black --check .
39 | 
40 | 


--------------------------------------------------------------------------------
/.github/workflows/build-docker-on-push-master.yml:
--------------------------------------------------------------------------------
 1 | name: Building, Testing, and Pushing Archivy Container Image On Push and PRs To Master Branch
 2 | 
 3 | on:
 4 |   workflow_dispatch:
 5 |   push:
 6 |     branches:
 7 |       - 'master'
 8 |     paths:
 9 |       - 'archivy/**'
10 |       - '.github/workflows/build-docker-on-push-master.yml'
11 | 
12 | jobs:
13 |   dockerBuildPush:
14 |     name: Build and push image with release version tag
15 |     runs-on: ubuntu-latest
16 |     steps:
17 |       - name: Checkout files from repository
18 |         uses: actions/checkout@v2
19 |         with:
20 |           repository: archivy/archivy-docker
21 | 
22 |       - name: Setting variables
23 |         if: success()
24 |         run: |
25 |           date -u +'%Y-%m-%dT%H:%M:%SZ' > TIMESTAMP
26 |           echo "${GITHUB_SHA}" | cut -c1-8 > SHORT_SHA
27 |           echo "source" > IMAGE_TAG
28 |           echo "uzayg" > DOCKER_USERNAME
29 |           echo "docker.io/uzayg/archivy" > DOCKER_IMAGE
30 | 
31 |       - name: Set up Docker Buildx
32 |         if: success()
33 |         uses: docker/setup-buildx-action@v1.0.2
34 |         with:
35 |           install: true
36 |           version: latest
37 | 
38 |       - name: Docker login
39 |         if: success()
40 |         env:
41 |           DOCKER_PASSWORD: ${{ secrets.DOCKER_ACCESS_TOKEN }}
42 |         run: |
43 |           echo "${DOCKER_PASSWORD}" | docker login --username "$( cat DOCKER_USERNAME )" --password-stdin docker.io
44 | 
45 |       # Build and push images with the tags
46 |       #   source
47 |       #   hash   - Commit hash(first 8 characters)
48 |       - name: Build and push with Docker Buildx
49 |         if: success()
50 |         run: |
51 |           docker build \
52 |             --output type=image,name="$(cat DOCKER_IMAGE)",push=true \
53 |             --build-arg BUILD_DATE="$( cat TIMESTAMP )" --build-arg VCS_REF="$( cat SHORT_SHA )" \
54 |             --tag "$( cat DOCKER_IMAGE ):$( cat IMAGE_TAG )" \
55 |             --file ./Dockerfile.source .
56 | 


--------------------------------------------------------------------------------
/.github/workflows/build-docker-on-release.yml:
--------------------------------------------------------------------------------
 1 | name: Building and Pushing Archivy Container Image On Version Release
 2 | 
 3 | on:
 4 |   release:
 5 |     types: [published]
 6 | 
 7 | jobs:
 8 |   dockerBuildPush:
 9 |     name: Build and push image with release version tag
10 |     runs-on: ubuntu-latest
11 |     steps:
12 |       - name: Checkout files from repository
13 |         uses: actions/checkout@v2
14 |         with:
15 |           repository: archivy/archivy-docker
16 | 
17 |       - name: Setting variables
18 |         if: success()
19 |         run: |
20 |           date -u +'%Y-%m-%dT%H:%M:%SZ' > TIMESTAMP
21 |           echo "$GITHUB_SHA" | cut -c1-8 > SHORT_SHA
22 |           echo "$GITHUB_REF" | cut -d / -f 3 > VERSION
23 |           echo "uzayg" > DOCKER_USERNAME
24 |           echo "docker.io/uzayg/archivy" > DOCKER_IMAGE
25 | 
26 |       - name: Set up Docker Buildx
27 |         if: success()
28 |         uses: docker/setup-buildx-action@v1.0.2
29 |         with:
30 |           install: true
31 |           version: latest
32 | 
33 |       - name: Docker login
34 |         if: success()
35 |         env:
36 |           DOCKER_PASSWORD: ${{ secrets.DOCKER_ACCESS_TOKEN }}
37 |         run: |
38 |           echo "${DOCKER_PASSWORD}" | docker login --username "$( cat DOCKER_USERNAME )" --password-stdin docker.io
39 | 
40 |       - name: Build and push with Docker Buildx
41 |         if: success()
42 |         run: |
43 |           docker build \
44 |             --output type=image,name="$( cat DOCKER_IMAGE )",push=true \
45 |             --build-arg VERSION="$( cat VERSION )" --build-arg BUILD_DATE="$( cat TIMESTAMP )" \
46 |             --build-arg VCS_REF="$( cat SHORT_SHA )" \
47 |             --tag "$( cat DOCKER_IMAGE ):$( cat VERSION )" \
48 |             --tag "$( cat DOCKER_IMAGE ):latest" \
49 |             --file ./Dockerfile . \
50 |           && docker build \
51 |             --output type=image,name="$( cat DOCKER_IMAGE )",push=true \
52 |             --build-arg VERSION="$( cat VERSION )" --build-arg BUILD_DATE="$( cat TIMESTAMP )" \
53 |             --build-arg VCS_REF="$( cat SHORT_SHA )" \
54 |             --tag "$( cat DOCKER_IMAGE ):$( cat VERSION )-lite" \
55 |             --tag "$( cat DOCKER_IMAGE ):latest-lite" \
56 |             --file ./Dockerfile .
57 | 


--------------------------------------------------------------------------------
/.github/workflows/build-docker-specific-version.yml:
--------------------------------------------------------------------------------
 1 | 
 2 | name: Building and Pushing Archivy Container to DockerHub on manual workflow dispatch
 3 | 
 4 | on:
 5 |   workflow_dispatch:
 6 |     inputs:
 7 |       version:
 8 |         description: Version to deploy to Dockerhub
 9 |         required: true
10 | 
11 | jobs:
12 |   dockerBuildPush:
13 |     name: Build and push image with release version tag
14 |     runs-on: ubuntu-latest
15 |     steps:
16 |       - name: Checkout files from repository
17 |         uses: actions/checkout@v2
18 |         with:
19 |           repository: archivy/archivy-docker
20 | 
21 |       - name: Setting variables
22 |         if: success()
23 |         run: |
24 |           date -u +'%Y-%m-%dT%H:%M:%SZ' > TIMESTAMP
25 |           echo "${{ github.event.inputs.version }}" > VERSION
26 |           echo "uzayg" > DOCKER_USERNAME
27 |           echo "docker.io/uzayg/archivy" > DOCKER_IMAGE
28 | 
29 |       - name: Set up Docker Buildx
30 |         if: success()
31 |         uses: docker/setup-buildx-action@v1.0.2
32 |         with:
33 |           install: true
34 |           version: latest
35 | 
36 |       - name: Docker login
37 |         if: success()
38 |         env:
39 |           DOCKER_PASSWORD: ${{ secrets.DOCKER_ACCESS_TOKEN }}
40 |         run: |
41 |           echo "${DOCKER_PASSWORD}" | docker login --username "$( cat DOCKER_USERNAME )" --password-stdin docker.io
42 | 
43 |       - name: Build and push with Docker Buildx
44 |         if: success()
45 |         run: |
46 |           docker build \
47 |             --output type=image,name="$( cat DOCKER_IMAGE )",push=true \
48 |             --build-arg VERSION="$( cat VERSION )" --build-arg BUILD_DATE="$( cat TIMESTAMP )" \
49 |             --tag "$( cat DOCKER_IMAGE ):$( cat VERSION )" \
50 |             --file ./Dockerfile . \
51 |           && docker build \
52 |             --output type=image,name="$( cat DOCKER_IMAGE )",push=true \
53 |             --build-arg VERSION="$( cat VERSION )" --build-arg BUILD_DATE="$( cat TIMESTAMP )" \
54 |             --tag "$( cat DOCKER_IMAGE ):$( cat VERSION )-lite" \
55 |             --file ./Dockerfile-light .
56 | 


--------------------------------------------------------------------------------
/.github/workflows/codecov.yml:
--------------------------------------------------------------------------------
 1 | name: Testing and automated code coverage
 2 | 
 3 | on:
 4 |   push:
 5 |     branches:
 6 |       - 'master'
 7 |     paths:
 8 |       - 'archivy/**'
 9 |       - 'tests/**'
10 |       - '.github/workflows/codecov.yml'
11 |       - 'requirements.txt'
12 |       - 'requirements-tests.txt'
13 |   pull_request:
14 |     paths:
15 |       - 'archivy/**'
16 |       - 'tests/**'
17 |       - '.github/workflows/codecov.yml'
18 |       - 'requirements.txt'
19 |       - 'requirements-tests.txt'
20 | 
21 | jobs:
22 |   run:
23 |     runs-on: ubuntu-latest
24 |     env:
25 |       PYTHON: '3.9'
26 |     steps:
27 |     - uses: actions/checkout@master
28 |     - name: Setup Python
29 |       uses: actions/setup-python@master
30 |       with:
31 |         python-version: 3.9
32 |     - name: Generate coverage report
33 |       run: |
34 |         sudo apt-get install ripgrep
35 |         pip install -r requirements-tests.txt
36 |         pip install .
37 |         pip install tests/test_plugin
38 |         pytest --cov=./ --cov-report=xml
39 |     - name: Upload coverage to Codecov
40 |       uses: codecov/codecov-action@v1
41 |       with:
42 |         file: ./coverage.xml
43 |         files: ./coverage1.xml,./coverage2.xml
44 |         directory: ./coverage/reports/
45 |         env_vars: PYTHON
46 |         fail_ci_if_error: true
47 | 


--------------------------------------------------------------------------------
/.github/workflows/deploy-docs.yml:
--------------------------------------------------------------------------------
 1 | name: Deploy Docs to archivy.github.io
 2 | on:
 3 |   push:
 4 |     branches:
 5 |       - master
 6 | 
 7 | 
 8 | jobs:
 9 |   deploy:
10 |     name: Deploy website
11 |     runs-on: ubuntu-latest
12 |     steps:
13 |       - uses: actions/checkout@v2
14 |         with:
15 |           path: main
16 |       - name: Set up Python 3.9 and Pandoc
17 |         uses: actions/setup-python@v1
18 |         with:
19 |           python-version: 3.9
20 |       - name: Install doc deps
21 |         run: >-
22 |           python -m pip install -r main/docs/requirements.txt
23 |       - name: Install archivy
24 |         run: >-
25 |           python -m pip install main/
26 |       - name: Clone archivy docs website
27 |         uses: actions/checkout@v2
28 |         with:
29 |           repository: archivy/archivy.github.io
30 |           ssh-key: ${{ secrets.ARCHIVY_ACCESS_KEY }}
31 |           path: docs-website
32 |       - name: Deploy docs
33 |         run: >-
34 |           cd docs-website && mkdocs gh-deploy --config-file ../main/mkdocs.yml --remote-branch main
35 | 


--------------------------------------------------------------------------------
/.github/workflows/pypi-auto-deploy.yml:
--------------------------------------------------------------------------------
 1 | name: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI and create Github Release
 2 | 
 3 | on:
 4 |   push:
 5 |     # remove branchname otherwise github actions will trigger event regardless of tag.
 6 |     tags: v[0-9]+.[0-9]+*
 7 | 
 8 | jobs:
 9 |   builds-n-publish:
10 |     name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI + github release
11 |     runs-on: ubuntu-latest
12 |     steps:
13 |     - uses: actions/checkout@master 
14 |     - name: Set up Python 3.9
15 |       uses: actions/setup-python@v1
16 |       with:
17 |         python-version: 3.9
18 |     - name: Install setuptools
19 |       run: >-
20 |         python -m pip install --user --upgrade setuptools wheel
21 |     - name: Build a binary wheel and source tarball
22 |       run: >-
23 |           python setup.py sdist bdist_wheel
24 |     - name: Publish distribution 📦 to PyPI
25 |       uses: pypa/gh-action-pypi-publish@master
26 |       with:
27 |         user: __token__
28 |         password: ${{ secrets.pypi_password }}
29 |     - name: Upload to release
30 |       uses: marvinpinto/action-automatic-releases@latest
31 |       with:
32 |           repo_token: ${{ secrets.RELEASE_TOKEN }}
33 |           prerelease: false
34 |           draft: true
35 |           files: dist/*
36 | 


--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
 1 | # Source for the following rules: https://raw.githubusercontent.com/github/gitignore/master/Python.gitignore
 2 | # Byte-compiled / optimized / DLL files
 3 | .flaskenv
 4 | __pycache__/
 5 | *.py[cod]
 6 | *$py.class
 7 | 
 8 | # C extensions
 9 | *.so
10 | 
11 | # Distribution / packaging
12 | .Python
13 | build/
14 | develop-eggs/
15 | dist/
16 | downloads/
17 | eggs/
18 | .eggs/
19 | lib/
20 | lib64/
21 | parts/
22 | sdist/
23 | var/
24 | wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 | 
30 | # PyInstaller
31 | #  Usually these files are written by a python script from a template
32 | #  before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 | 
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 | 
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | .hypothesis/
51 | .pytest_cache/
52 | 
53 | # Translations
54 | *.mo
55 | *.pot
56 | 
57 | # Flask stuff:
58 | instance/
59 | .webassets-cache
60 | 
61 | # Scrapy stuff:
62 | .scrapy
63 | 
64 | # Sphinx documentation
65 | docs/_build/
66 | 
67 | # pyenv
68 | .python-version
69 | 
70 | # Environments
71 | .env
72 | .venv
73 | env/
74 | venv/*
75 | ENV/
76 | env.bak/
77 | venv.bak/
78 | db.json
79 | data/
80 | .vim/
81 | .vscode/
82 | 


--------------------------------------------------------------------------------
/API.md:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 | # API documentation
 4 | 
 5 | The archivy api allows you to interact with your archivy instance through HTTP. This allows a complete, programmatic access to the archivy's functionality.
 6 | 
 7 | All these requests must be made by an authenticated user. For example, using the python `requests` module:
 8 | 
 9 | ```python
10 | import requests
11 | # we create a new session that will allow us to login once
12 | s = requests.session()
13 | 
14 | INSTANCE_URL = <your instance url>
15 | s.post(f"{INSTANCE_URL}/api/login", auth=(<username>, <password>))
16 | 
17 | # once you've logged in - you can make authenticated requests to the api, like:
18 | resp = s.get(f"{INSTANCE_URL}/api/dataobjs").content)
19 | ```
20 | 
21 | ## API spec
22 | This is an api specification for the routes you might find useful in your scripts. The url prefix for all the requests is `/api`.
23 | 
24 | ### General routes
25 | 
26 | | Route name    | Parameters                                                   | Description                                          |
27 | | ------------- | ------------------------------------------------------------ | ---------------------------------------------------- |
28 | | POST `/login` | [HTTP Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication): username and password | Logs you in with your archivy username and password  |
29 | | GET `/search` | `query`: search query                                        | Fetches elasticsearch results for your search terms. |
30 | 
31 | 
32 | 
33 | ### Folders
34 | 
35 | | Route name               | Parameters                                                   | Description                                                  |
36 | | ------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
37 | | POST `/folders/new`      | `path`: path of new directory For example, if you want to create the directory `trees` in the existing directory `nature`, `path = "nature/trees"` | Allows you to create new directories                         |
38 | | DELETE `/folders/delete` | `path`: path of directory to delete. For example, if you want to delete the `trees` dir in `nature`, `path = natures/trees` | Deletes existing directories. Also works if the directories contain data, which will be deleted with it. |
39 | 
40 | ### Dataobjs
41 | 
42 | | Route name            | Parameters                                                   | Description                                                  |
43 | | --------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
44 | | POST `/notes`         | `title`, `content`, `desc`, `tags`: array of tags to associate with the note, `path`: string with the relative dir in which the note should be stored. | Creates a new note in the knowledge base. The only required parameter is the title of the note. |
45 | | POST `/bookmarks`     | `url`, `desc`, `tags`: array of tags to associate with the bookmark, `path`: string with the relative dir in which the note should be stored. | Stores a new bookmark. Only required parameter is `url`.     |
46 | | GET `/dataobjs`       |                                                              | Returns an array of all dataobjs with their title, id, contents, url, path etc... This request is resource-heavy so we might need to consider not sending the large contents. |
47 | | GET `/dataobjs/id`    |                                                              | Returns data for **one** dataobj, specified by his id.       |
48 | | DELETE `/dataobjs/id` |                                                              | Deletes specified dataobj.                                   |
49 | 
50 | 


--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
 1 | MIT License
 2 | 
 3 | Copyright (c) 2020 Uzay-G
 4 | 
 5 | Permission is hereby granted, free of charge, to any person obtaining a copy
 6 | of this software and associated documentation files (the "Software"), to deal
 7 | in the Software without restriction, including without limitation the rights
 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 | 
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 | 
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 | 


--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include LICENSE
2 | include README.md
3 | include requirements.txt
4 | recursive-include archivy/static *
5 | recursive-include archivy/templates *
6 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
 1 | ![logo](docs/img/logo.png)
 2 | 
 3 | # Archivy
 4 | 
 5 | Archivy is a self-hostable knowledge repository that allows you to learn and retain information in your own personal and extensible wiki.
 6 | 
 7 | Features:
 8 | 
 9 | - If you add bookmarks, their web-pages contents' will be saved to ensure that you will **always** have access to it, following the idea of [digital preservation](https://jeffhuang.com/designed_to_last/). Archivy is also easily integrated with other services and your online accounts.
10 | - Knowledge base organization with bidirectional links between notes, and embedded tags.
11 | - Everything is a file! For ease of access and editing, all the content is stored in extended markdown files with yaml front matter. This format supports footnotes, LaTeX math rendering, syntax highlighting and more. 
12 | - Extensible plugin system and API for power users to take control of their knowledge process
13 | - [syncing options](https://github.com/archivy/archivy-git)
14 | - Powerful and advanced search. 
15 | - Image upload
16 | 
17 | 
18 | [demo video](https://www.uzpg.me/assets/images/archivy.mov)
19 | 
20 | [Roadmap](https://github.com/archivy/archivy/issues/74#issuecomment-764828063)
21 | 
22 | Upcoming:
23 | 
24 | - Annotations
25 | - Multi User System with permission setup.
26 | 
27 | ## Quickstart
28 | 
29 | 
30 | Install archivy with `pip install archivy`. Other installations methods are listed [here](https://archivy.github.io/install), including Docker.
31 | 
32 | Run the `archivy init` command to setup you installation.
33 | 
34 | Then run this and enter a password to create a new user:
35 | 
36 | ```bash
37 | $ archivy create-admin <username>
38 | ```
39 | 
40 | Finally, execute `archivy run` to serve the app. You can open it at https://localhost:5000 and login with the credentials you entered before.
41 | 
42 | You can then use archivy to create notes, bookmarks and then organize and store information.
43 | 
44 | See the [official docs](https://archivy.github.io) for information on other installation methods.
45 | 
46 | ## Community
47 | 
48 | Archivy is dedicated to building **open and quality knowledge base software** through collaboration and community discussion.
49 | 
50 | To get news and updates on Archivy and its development, you can [watch the archivy repository](https://github.com/archivy/archivy) or follow [@uzpg_ on Twitter](https://twitter.com/uzpg_).
51 | 
52 | You can interact with us through the [issue board](https://github.com/archivy/archivy/issues) and the more casual [discord server](https://discord.gg/uQsqyxB).
53 | 
54 | Note: If you're interested in the applications of AI to knowledge management, we're also working on this with [Espial](https://github.com/Uzay-G/espial).
55 | 


--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | If you think you've found a security issue in Archivy, please contact halcyon@disroot.org before disclosing anything publicly.
2 | 


--------------------------------------------------------------------------------
/archivy/__init__.py:
--------------------------------------------------------------------------------
  1 | import logging
  2 | from pathlib import Path
  3 | from shutil import which
  4 | 
  5 | from elasticsearch.exceptions import RequestError
  6 | from flask import Flask
  7 | from flask_compress import Compress
  8 | from flask_login import LoginManager
  9 | from flask_wtf.csrf import CSRFProtect
 10 | 
 11 | from archivy import helpers
 12 | from archivy.api import api_bp
 13 | from archivy.models import User
 14 | from archivy.config import Config
 15 | from archivy.helpers import load_config, get_elastic_client
 16 | 
 17 | app = Flask(__name__)
 18 | app.logger.setLevel(logging.INFO)
 19 | config = Config()
 20 | try:
 21 |     # if it exists, load user config
 22 |     config.override(load_config(config.INTERNAL_DIR))
 23 | except FileNotFoundError:
 24 |     pass
 25 | 
 26 | app.config.from_object(config)
 27 | (Path(app.config["USER_DIR"]) / "data").mkdir(parents=True, exist_ok=True)
 28 | (Path(app.config["USER_DIR"]) / "images").mkdir(parents=True, exist_ok=True)
 29 | 
 30 | with app.app_context():
 31 |     app.config["RG_INSTALLED"] = which("rg") != None
 32 |     app.config["HOOKS"] = helpers.load_hooks()
 33 |     app.config["SCRAPING_PATTERNS"] = helpers.load_scraper()
 34 | if app.config["SEARCH_CONF"]["enabled"]:
 35 |     with app.app_context():
 36 |         search_engines = ["elasticsearch", "ripgrep"]
 37 |         es = None
 38 |         if (
 39 |             "engine" not in app.config["SEARCH_CONF"]
 40 |             or app.config["SEARCH_CONF"]["engine"] not in search_engines
 41 |         ):
 42 |             # try to guess desired search engine if present
 43 |             app.logger.warning(
 44 |                 "Search is enabled but engine option is invalid or absent. Archivy will"
 45 |                 " try to guess preferred search engine."
 46 |             )
 47 |             app.config["SEARCH_CONF"]["engine"] = "none"
 48 | 
 49 |             es = get_elastic_client(error_if_invalid=False)
 50 |             if es:
 51 |                 app.config["SEARCH_CONF"]["engine"] = "elasticsearch"
 52 |             else:
 53 |                 if which("rg"):
 54 |                     app.config["SEARCH_CONF"]["engine"] = "ripgrep"
 55 |             engine = app.config["SEARCH_CONF"]["engine"]
 56 |             if engine == "none":
 57 |                 app.logger.warning("No working search engine found. Disabling search.")
 58 |                 app.config["SEARCH_CONF"]["enabled"] = 0
 59 |             else:
 60 |                 app.logger.info(f"Running {engine} installation found.")
 61 | 
 62 |         if app.config["SEARCH_CONF"]["engine"] == "elasticsearch":
 63 |             es = es or get_elastic_client()
 64 |             try:
 65 |                 es.indices.create(
 66 |                     index=app.config["SEARCH_CONF"]["index_name"],
 67 |                     body=app.config["SEARCH_CONF"]["es_processing_conf"],
 68 |                 )
 69 |             except RequestError:
 70 |                 app.logger.info("Elasticsearch index already created")
 71 |         if app.config["SEARCH_CONF"]["engine"] == "ripgrep" and not which("rg"):
 72 |             app.logger.info("Ripgrep not found on system. Disabling search.")
 73 |             app.config["SEARCH_CONF"]["enabled"] = 0
 74 | 
 75 | 
 76 | # login routes / setup
 77 | login_manager = LoginManager()
 78 | login_manager.login_view = "login"
 79 | login_manager.init_app(app)
 80 | app.register_blueprint(api_bp, url_prefix="/api")
 81 | csrf = CSRFProtect(app)
 82 | csrf.exempt(api_bp)
 83 | 
 84 | # compress files
 85 | Compress(app)
 86 | 
 87 | 
 88 | @login_manager.user_loader
 89 | def load_user(user_id):
 90 |     db = helpers.get_db()
 91 |     res = db.get(doc_id=int(user_id))
 92 |     if res and res["type"] == "user":
 93 |         return User.from_db(res)
 94 |     return None
 95 | 
 96 | 
 97 | app.jinja_env.add_extension("jinja2.ext.do")
 98 | 
 99 | 
100 | @app.template_filter("pluralize")
101 | def pluralize(number, singular="", plural="s"):
102 |     if number == 1:
103 |         return singular
104 |     else:
105 |         return plural
106 | 
107 | 
108 | from archivy import routes  # noqa:
109 | 


--------------------------------------------------------------------------------
/archivy/api.py:
--------------------------------------------------------------------------------
  1 | from flask import Response, jsonify, request, Blueprint, current_app
  2 | from werkzeug.security import check_password_hash
  3 | from flask_login import login_user
  4 | from tinydb import Query
  5 | 
  6 | from archivy import data, tags
  7 | from archivy.search import search
  8 | from archivy.models import DataObj, User
  9 | from archivy.helpers import get_db
 10 | 
 11 | 
 12 | api_bp = Blueprint("api", __name__)
 13 | 
 14 | 
 15 | @api_bp.route("/login", methods=["POST"])
 16 | def login():
 17 |     """
 18 |     Logs in the API client using
 19 |     [HTTP Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication).
 20 |     Pass in the username and password of your account.
 21 |     """
 22 |     db = get_db()
 23 |     user = db.search(Query().username == request.authorization["username"])
 24 |     if user and check_password_hash(
 25 |         user[0]["hashed_password"], request.authorization["password"]
 26 |     ):
 27 |         # user is verified so we can log him in from the db
 28 |         user = User.from_db(user[0])
 29 |         login_user(user, remember=True)
 30 |         return Response(status=200)
 31 |     return Response(status=401)
 32 | 
 33 | 
 34 | @api_bp.route("/bookmarks", methods=["POST"])
 35 | def create_bookmark():
 36 |     """
 37 |     Creates a new bookmark
 38 | 
 39 |     **Parameters:**
 40 | 
 41 |     All parameters are sent through the JSON body.
 42 |     - **url** (required)
 43 |     - **tags**
 44 |     - **path**
 45 |     """
 46 |     json_data = request.get_json()
 47 |     bookmark = DataObj(
 48 |         url=json_data["url"],
 49 |         tags=json_data.get("tags", []),
 50 |         path=json_data.get("path", current_app.config["DEFAULT_BOOKMARKS_DIR"]),
 51 |         type="bookmark",
 52 |     )
 53 |     bookmark.process_bookmark_url()
 54 |     bookmark_id = bookmark.insert()
 55 |     if bookmark_id:
 56 |         return jsonify(
 57 |             bookmark_id=bookmark_id,
 58 |         )
 59 |     return Response(status=400)
 60 | 
 61 | 
 62 | @api_bp.route("/notes", methods=["POST"])
 63 | def create_note():
 64 |     """
 65 |     Creates a new note.
 66 | 
 67 |     **Parameters:**
 68 | 
 69 |     All parameters are sent through the JSON body.
 70 |     - **title** (required)
 71 |     - **content** (required)
 72 |     - **tags**
 73 |     - **path**
 74 |     """
 75 |     json_data = request.get_json()
 76 |     note = DataObj(
 77 |         title=json_data["title"],
 78 |         content=json_data["content"],
 79 |         path=json_data.get("path", ""),
 80 |         tags=json_data.get("tags", []),
 81 |         type="note",
 82 |     )
 83 | 
 84 |     note_id = note.insert()
 85 |     if note_id:
 86 |         return jsonify(note_id=note_id)
 87 |     return Response(status=400)
 88 | 
 89 | 
 90 | @api_bp.route("/dataobjs/<int:dataobj_id>")
 91 | def get_dataobj(dataobj_id):
 92 |     """Returns dataobj of given id"""
 93 |     dataobj = data.get_item(dataobj_id)
 94 | 
 95 |     return (
 96 |         jsonify(
 97 |             dataobj_id=dataobj_id,
 98 |             title=dataobj["title"],
 99 |             content=dataobj.content,
100 |             md_path=dataobj["fullpath"],
101 |         )
102 |         if dataobj
103 |         else Response(status=404)
104 |     )
105 | 
106 | 
107 | @api_bp.route("/dataobjs/<int:dataobj_id>", methods=["DELETE"])
108 | def delete_dataobj(dataobj_id):
109 |     """Deletes object of given id"""
110 |     if not data.get_item(dataobj_id):
111 |         return Response(status=404)
112 |     data.delete_item(dataobj_id)
113 |     return Response(status=204)
114 | 
115 | 
116 | @api_bp.route("/dataobjs/<int:dataobj_id>", methods=["PUT"])
117 | def update_dataobj(dataobj_id):
118 |     """
119 |     Updates object of given id.
120 | 
121 |     Paramter in JSON body:
122 | 
123 |     - **content**: markdown text of new dataobj.
124 |     """
125 |     if request.json.get("content"):
126 |         try:
127 |             data.update_item_md(dataobj_id, request.json.get("content"))
128 |             return Response(status=200)
129 |         except BaseException:
130 |             return Response(status=404)
131 |     return Response("Must provide content parameter", status=401)
132 | 
133 | 
134 | @api_bp.route("/dataobjs/frontmatter/<int:dataobj_id>", methods=["PUT"])
135 | def update_dataobj_frontmatter(dataobj_id):
136 |     """
137 |     Updates frontmatter of object of given id.
138 | 
139 |     Paramter in JSON body:
140 | 
141 |     - **title**: the new title of the dataobj.
142 |     """
143 | 
144 |     new_frontmatter = {
145 |         "title": request.json.get("title"),
146 |     }
147 | 
148 |     try:
149 |         data.update_item_frontmatter(dataobj_id, new_frontmatter)
150 |         return Response(status=200)
151 |     except BaseException:
152 |         return Response(status=404)
153 | 
154 | 
155 | @api_bp.route("/dataobjs", methods=["GET"])
156 | def get_dataobjs():
157 |     """Gets all dataobjs"""
158 |     cur_dir = data.get_items(structured=False, json_format=True)
159 |     return jsonify(cur_dir)
160 | 
161 | 
162 | @api_bp.route("/tags/add_to_index", methods=["PUT"])
163 | def add_tag_to_index():
164 |     """Add a tag to the database."""
165 |     tag = request.json.get("tag", False)
166 |     if tag and type(tag) is str and tags.is_tag_format(tag):
167 |         if tags.add_tag_to_index(tag):
168 |             return Response(status=200)
169 |         else:
170 |             return Response(status=404)
171 | 
172 |     return Response("Must provide valid tag name.", status=401)
173 | 
174 | 
175 | @api_bp.route("/dataobj/local_edit/<dataobj_id>", methods=["GET"])
176 | def local_edit(dataobj_id):
177 |     dataobj = data.get_item(dataobj_id)
178 |     if dataobj:
179 |         data.open_file(dataobj["fullpath"])
180 |         return Response(status=200)
181 |     return Response(status=404)
182 | 
183 | 
184 | @api_bp.route("/folders/new", methods=["POST"])
185 | def create_folder():
186 |     """
187 |     Creates new directory
188 | 
189 |     Parameter in JSON body:
190 |     - **path** (required) - path of newdir
191 |     """
192 |     directory = request.json.get("path")
193 |     try:
194 |         sanitized_name = data.create_dir(directory)
195 |         if not sanitized_name:
196 |             return Response("Invalid dirname", status=400)
197 |     except FileExistsError:
198 |         return Response("Directory already exists", status=400)
199 |     return Response(sanitized_name, status=200)
200 | 
201 | 
202 | @api_bp.route("/folders/delete", methods=["DELETE"])
203 | def delete_folder():
204 |     """
205 |     Deletes directory.
206 | 
207 |     Parameter in JSON body:
208 |     - **path** of dir to delete
209 |     """
210 |     directory = request.json.get("path")
211 |     if directory == "":
212 |         return Response("Cannot delete root dir", status=401)
213 |     if data.delete_dir(directory):
214 |         return Response("Successfully deleted", status=200)
215 |     return Response("Could not delete directory", status=400)
216 | 
217 | 
218 | @api_bp.route("/search", methods=["GET"])
219 | def search_endpoint():
220 |     """
221 |     Searches the instance.
222 | 
223 |     Request URL Parameter:
224 |     - **query**
225 |     """
226 |     if not current_app.config["SEARCH_CONF"]["enabled"]:
227 |         return Response("Search is disabled", status=401)
228 |     query = request.args.get("query")
229 |     search_results = search(query)
230 |     return jsonify(search_results)
231 | 
232 | 
233 | @api_bp.route("/images", methods=["POST"])
234 | def image_upload():
235 |     CONTENT_TYPES = ["image/jpeg", "image/png", "image/gif"]
236 |     if "image" not in request.files:
237 |         return jsonify({"error": "400"}), 400
238 |     image = request.files["image"]
239 |     if (
240 |         data.valid_image_filename(image.filename)
241 |         and image.headers["Content-Type"].strip() in CONTENT_TYPES
242 |     ):
243 |         saved_to = data.save_image(image)
244 |         return jsonify({"data": {"filePath": f"/images/{saved_to}"}}), 200
245 |     return jsonify({"error": "415"}), 415
246 | 


--------------------------------------------------------------------------------
/archivy/cli.py:
--------------------------------------------------------------------------------
  1 | from pathlib import Path
  2 | from os import environ
  3 | from pkg_resources import iter_entry_points
  4 | 
  5 | import click
  6 | from click_plugins import with_plugins
  7 | from flask.cli import FlaskGroup, load_dotenv, shell_command
  8 | 
  9 | from archivy import app
 10 | from archivy.config import Config
 11 | from archivy.click_web import create_click_web_app
 12 | from archivy.data import open_file, format_file, unformat_file
 13 | from archivy.helpers import load_config, write_config, create_plugin_dir
 14 | from archivy.models import User, DataObj
 15 | 
 16 | 
 17 | def create_app():
 18 |     return app
 19 | 
 20 | 
 21 | @with_plugins(iter_entry_points("archivy.plugins"))
 22 | @click.group(cls=FlaskGroup, create_app=create_app)
 23 | def cli():
 24 |     pass
 25 | 
 26 | 
 27 | @cli.command("init", short_help="Initialise your archivy application")
 28 | @click.pass_context
 29 | def init(ctx):
 30 |     try:
 31 |         load_config()
 32 |         click.confirm(
 33 |             "Config already found. Do you wish to reset it? "
 34 |             "Otherwise run `archivy config`",
 35 |             abort=True,
 36 |         )
 37 |     except FileNotFoundError:
 38 |         pass
 39 | 
 40 |     config = Config()
 41 | 
 42 |     click.echo("This is the archivy installation initialization wizard.")
 43 |     data_dir = click.prompt(
 44 |         "Enter the full path of the " "directory where you'd like us to store data.",
 45 |         type=str,
 46 |         default=str(Path(".").resolve()),
 47 |     )
 48 | 
 49 |     desires_search = click.confirm(
 50 |         "Would you like to enable search on your knowledge base contents?"
 51 |     )
 52 | 
 53 |     if desires_search:
 54 |         search_engine = click.prompt(
 55 |             "Then go to https://archivy.github.io/setup-search/ to see the different backends you can use for search and how you can configure them.",
 56 |             type=click.Choice(["elasticsearch", "ripgrep", "cancel"]),
 57 |             show_choices=True,
 58 |         )
 59 |         if search_engine != "cancel":
 60 |             config.SEARCH_CONF["enabled"] = 1
 61 |             config.SEARCH_CONF["engine"] = search_engine
 62 | 
 63 |     create_new_user = click.confirm("Would you like to create a new admin user?")
 64 |     if create_new_user:
 65 |         username = click.prompt("Username")
 66 |         password = click.prompt("Password", hide_input=True, confirmation_prompt=True)
 67 |         if not ctx.invoke(create_admin, username=username, password=password):
 68 |             return
 69 | 
 70 |     config.HOST = click.prompt(
 71 |         "Host [localhost (127.0.0.1)]",
 72 |         type=str,
 73 |         default="127.0.0.1",
 74 |         show_default=False,
 75 |     )
 76 | 
 77 |     config.override({"USER_DIR": data_dir})
 78 |     app.config["USER_DIR"] = data_dir
 79 | 
 80 |     # create data dir
 81 |     (Path(data_dir) / "data").mkdir(exist_ok=True, parents=True)
 82 | 
 83 |     write_config(vars(config))
 84 |     click.echo(
 85 |         "Config successfully created at "
 86 |         + str((Path(app.config["INTERNAL_DIR"]) / "config.yml").resolve())
 87 |     )
 88 | 
 89 | 
 90 | @cli.command("config", short_help="Open archivy config.")
 91 | def config():
 92 |     open_file(str(Path(app.config["INTERNAL_DIR"]) / "config.yml"))
 93 | 
 94 | 
 95 | @cli.command("hooks", short_help="Creates hook file if it is not setup and opens it.")
 96 | def hooks():
 97 |     hook_path = Path(app.config["USER_DIR"]) / "hooks.py"
 98 |     if not hook_path.exists():
 99 |         with hook_path.open("w") as f:
100 |             f.write(
101 |                 "from archivy.config import BaseHooks\n"
102 |                 "class Hooks(BaseHooks):\n"
103 |                 "   # see available hooks at https://archivy.github.io/reference/hooks/\n"
104 |                 "   def on_dataobj_create(self, dataobj): # for example\n"
105 |                 "       pass"
106 |             )
107 |     open_file(hook_path)
108 | 
109 | 
110 | @cli.command("run", short_help="Runs archivy web application")
111 | def run():
112 |     click.echo("Running archivy...")
113 |     load_dotenv()
114 |     environ["FLASK_RUN_FROM_CLI"] = "false"
115 |     app_with_cli = create_click_web_app(click, cli, app)
116 |     app_with_cli.run(host=app.config["HOST"], port=app.config["PORT"])
117 | 
118 | 
119 | @cli.command(short_help="Creates a new admin user")
120 | @click.argument("username")
121 | @click.password_option()
122 | def create_admin(username, password):
123 |     if len(password) < 8:
124 |         click.echo("Password length too short")
125 |         return False
126 |     else:
127 |         user = User(username=username, password=password, is_admin=True)
128 |         if user.insert():
129 |             click.echo(f"User {username} successfully created.")
130 |             return True
131 |         else:
132 |             click.echo("User with given username already exists.")
133 |             return False
134 | 
135 | 
136 | @cli.command(short_help="Format normal markdown files for archivy.")
137 | @click.argument("filenames", type=click.Path(exists=True), nargs=-1)
138 | def format(filenames):
139 |     for path in filenames:
140 |         format_file(path)
141 | 
142 | 
143 | @cli.command(short_help="Convert archivy-formatted files back to normal markdown.")
144 | @click.argument("filenames", type=click.Path(exists=True), nargs=-1)
145 | @click.argument("output_dir", type=click.Path(exists=True, file_okay=False))
146 | def unformat(filenames, output_dir):
147 |     for path in filenames:
148 |         unformat_file(path, output_dir)
149 | 
150 | 
151 | @cli.command(short_help="Sync content to Elasticsearch")
152 | def index():
153 |     data_dir = Path(app.config["USER_DIR"]) / "data"
154 | 
155 |     if not app.config["SEARCH_CONF"]["enabled"]:
156 |         click.echo("Search must be enabled for this command.")
157 |         return
158 | 
159 |     for filename in data_dir.rglob("*.md"):
160 |         cur_file = open(filename)
161 |         dataobj = DataObj.from_md(cur_file.read())
162 |         cur_file.close()
163 | 
164 |         if dataobj.index():
165 |             click.echo(f"Indexed {dataobj.title}...")
166 |         else:
167 |             click.echo(f"Failed to index {dataobj.title}")
168 | 
169 | 
170 | @cli.command(
171 |     short_help="Helper command to auto-generate plugin directory with structure"
172 | )
173 | @click.argument("name")
174 | def plugin_new(name):
175 |     if create_plugin_dir(name):
176 |         click.echo(f"Successfully Created the plugin directory structure at '{name}'/.")
177 |     else:
178 |         click.echo(f"Directory with name '{name}' already exists.")
179 | 
180 | 
181 | if __name__ == "__main__":
182 |     cli()
183 | 


--------------------------------------------------------------------------------
/archivy/click_web/__init__.py:
--------------------------------------------------------------------------------
 1 | import tempfile
 2 | from pathlib import Path
 3 | 
 4 | import click
 5 | import jinja2
 6 | from flask import Blueprint
 7 | 
 8 | from archivy.click_web.resources import cmd_exec, cmd_form, index
 9 | 
10 | jinja_env = jinja2.Environment(extensions=["jinja2.ext.do"])
11 | 
12 | "The full path to the click script file to execute."
13 | script_file = None
14 | "The click root command to serve"
15 | click_root_cmd = None
16 | 
17 | 
18 | def _get_output_folder():
19 |     _output_folder = Path(tempfile.gettempdir()) / "click-web"
20 |     if not _output_folder.exists():
21 |         _output_folder.mkdir()
22 |     return _output_folder
23 | 
24 | 
25 | "Where to place result files for download"
26 | OUTPUT_FOLDER = str(_get_output_folder())
27 | 
28 | _flask_app = None
29 | logger = None
30 | 
31 | 
32 | def create_click_web_app(module, command: click.BaseCommand, flask_app):
33 |     """
34 |     Create a Flask app that wraps a click command. (Call once)
35 | 
36 |     :param module: the module that contains the click command,
37 |     needed to get the path to the script.
38 |     :param command: The actual click root command, needed
39 |     to be able to read the command tree and arguments
40 |     in order to generate the index page and the html forms
41 |     """
42 |     global _flask_app, logger
43 |     assert _flask_app is None, "Flask App already created."
44 | 
45 |     _register(module, command)
46 | 
47 |     _flask_app = flask_app
48 | 
49 |     # add the "do" extension needed by our jinja templates
50 |     _flask_app.jinja_env.add_extension("jinja2.ext.do")
51 | 
52 |     _flask_app.add_url_rule("/plugins", "cli_index", index.index)
53 |     _flask_app.add_url_rule(
54 |         "/cli/<path:command_path>", "command", cmd_form.get_form_for
55 |     )
56 |     _flask_app.add_url_rule(
57 |         "/cli/<path:command_path>", "command_execute", cmd_exec.exec, methods=["POST"]
58 |     )
59 | 
60 |     _flask_app.logger.info(f"OUTPUT_FOLDER: {OUTPUT_FOLDER}")
61 |     results_blueprint = Blueprint(
62 |         "results",
63 |         __name__,
64 |         static_url_path="/static/results",
65 |         static_folder=OUTPUT_FOLDER,
66 |     )
67 |     _flask_app.register_blueprint(results_blueprint)
68 | 
69 |     logger = _flask_app.logger
70 | 
71 |     return _flask_app
72 | 
73 | 
74 | def _register(module, command: click.BaseCommand):
75 |     """
76 |     :param module: the module that contains the command, needed to get the path to the script.
77 |     :param command: The actual click root command,
78 |                     needed to be able to read the command tree and arguments
79 |                     in order to generate the index page and the html forms
80 |     """
81 |     global click_root_cmd, script_file
82 |     script_file = str(Path(module.__file__).absolute())
83 |     click_root_cmd = command
84 | 


--------------------------------------------------------------------------------
/archivy/click_web/exceptions.py:
--------------------------------------------------------------------------------
1 | class ClickWebException(Exception):
2 |     pass
3 | 
4 | 
5 | class CommandNotFound(ClickWebException):
6 |     pass
7 | 


--------------------------------------------------------------------------------
/archivy/click_web/resources/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/archivy/archivy/bdcdd39ac6cf9f7b3709b984d8be2f0fa898139e/archivy/click_web/resources/__init__.py


--------------------------------------------------------------------------------
/archivy/click_web/resources/cmd_form.py:
--------------------------------------------------------------------------------
  1 | from html import escape
  2 | from typing import List, Tuple
  3 | 
  4 | import click
  5 | from flask import abort, render_template
  6 | 
  7 | from archivy import click_web
  8 | from archivy.click_web.exceptions import CommandNotFound
  9 | from archivy.click_web.resources.input_fields import get_input_field
 10 | 
 11 | 
 12 | def get_form_for(command_path: str):
 13 |     try:
 14 |         ctx_and_commands = _get_commands_by_path(command_path)
 15 |     except CommandNotFound as err:
 16 |         return abort(404, str(err))
 17 | 
 18 |     levels = _generate_form_data(ctx_and_commands)
 19 |     return render_template(
 20 |         "click_web/command_form.html",
 21 |         levels=levels,
 22 |         command=levels[-1]["command"],
 23 |         command_path=command_path,
 24 |     )
 25 | 
 26 | 
 27 | def _get_commands_by_path(command_path: str) -> Tuple[click.Context, click.Command]:
 28 |     """
 29 |     Take a (slash separated) string and generate (context, command) for each level.
 30 |     :param command_path: "some_group/a_command"
 31 |     :return: Return a list from root to leaf commands.
 32 |     """
 33 |     command_path = "cli/" + command_path
 34 |     command_path_items = command_path.split("/")
 35 |     command_name, *command_path_items = command_path_items
 36 |     command = click_web.click_root_cmd
 37 |     if command.name != command_name:
 38 |         raise CommandNotFound(
 39 |             "Failed to find root command {}. There is a root commande named: {}".format(
 40 |                 command_name, command.name
 41 |             )
 42 |         )
 43 |     result = []
 44 |     with click.Context(command, info_name=command, parent=None) as ctx:
 45 |         result.append((ctx, command))
 46 |         # dig down the path parts to find the leaf command
 47 |         parent_command = command
 48 |         for command_name in command_path_items:
 49 |             command = parent_command.get_command(ctx, command_name)
 50 |             if command:
 51 |                 # create sub context for command
 52 |                 ctx = click.Context(command, info_name=command, parent=ctx)
 53 |                 parent_command = command
 54 |             else:
 55 |                 raise CommandNotFound(
 56 |                     """Failed to find command for path "{}".
 57 |                                        Command "{}" not found. Must be one of {}""".format(
 58 |                         command_path, command_name, parent_command.list_commands(ctx)
 59 |                     )
 60 |                 )
 61 |             result.append((ctx, command))
 62 |     return result
 63 | 
 64 | 
 65 | def _generate_form_data(ctx_and_commands: List[Tuple[click.Context, click.Command]]):
 66 |     """
 67 |     Construct a list of contexts and commands generate a
 68 |     python data structure for rendering jinja form
 69 |     :return: a list of dicts
 70 |     """
 71 |     levels = []
 72 |     for command_index, (ctx, command) in enumerate(ctx_and_commands):
 73 |         # force help option off, no need in web.
 74 |         command.add_help_option = False
 75 |         command.html_help = _process_help(command.help)
 76 | 
 77 |         input_fields = [
 78 |             get_input_field(ctx, param, command_index, param_index)
 79 |             for param_index, param in enumerate(command.get_params(ctx))
 80 |         ]
 81 |         levels.append({"command": command, "fields": input_fields})
 82 | 
 83 |     return levels
 84 | 
 85 | 
 86 | def _process_help(help_text):
 87 |     """
 88 |     Convert click command help into html to be presented to browser.
 89 |     Respects the '\b' char used by click to mark pre-formatted blocks.
 90 |     Also escapes html reserved characters in the help text.
 91 | 
 92 |     :param help_text: str
 93 |     :return: A html formatted help string.
 94 |     """
 95 |     help = []
 96 |     in_pre = False
 97 |     html_help = ""
 98 |     if not help_text:
 99 |         return html_help
100 | 
101 |     line_iter = iter(help_text.splitlines())
102 |     while True:
103 |         try:
104 |             line = next(line_iter)
105 |             if in_pre and not line.strip():
106 |                 # end of code block
107 |                 in_pre = False
108 |                 html_help += "\n".join(help)
109 |                 help = []
110 |                 help.append("</pre>")
111 |                 continue
112 |             elif line.strip() == "\b":
113 |                 # start of code block
114 |                 in_pre = True
115 |                 html_help += "<br>\n".join(help)
116 |                 help = []
117 |                 help.append("<pre>")
118 |                 continue
119 |             help.append(escape(line))
120 |         except StopIteration:
121 |             break
122 | 
123 |     html_help += "\n".join(help) if in_pre else "<br>\n".join(help)
124 |     return html_help
125 | 


--------------------------------------------------------------------------------
/archivy/click_web/resources/index.py:
--------------------------------------------------------------------------------
 1 | from collections import OrderedDict
 2 | 
 3 | import click
 4 | from flask import render_template
 5 | 
 6 | from archivy import click_web
 7 | 
 8 | 
 9 | def index():
10 |     with click.Context(
11 |         click_web.click_root_cmd, info_name=click_web.click_root_cmd.name, parent=None
12 |     ) as ctx:
13 |         return render_template(
14 |             "click_web/show_tree.html",
15 |             ctx=ctx,
16 |             tree=_click_to_tree(ctx, click_web.click_root_cmd),
17 |             title="Plugins",
18 |         )
19 | 
20 | 
21 | def _click_to_tree(ctx: click.Context, node: click.BaseCommand, ancestors=[]):
22 |     """
23 |     Convert a click root command to a tree of dicts and lists
24 |     :return: a json like tree
25 |     """
26 |     res_childs = []
27 |     res = OrderedDict()
28 |     res["is_group"] = isinstance(node, click.core.MultiCommand)
29 |     omitted = ["shell", "run", "routes", "create-admin"]
30 |     if res["is_group"]:
31 |         # a group, recurse for e    very child
32 |         subcommand_names = set(click.Group.list_commands(node, ctx))
33 |         children = [
34 |             node.get_command(ctx, key) for key in subcommand_names if key not in omitted
35 |         ]
36 |         # Sort so commands comes before groups
37 |         children = sorted(
38 |             children, key=lambda c: isinstance(c, click.core.MultiCommand)
39 |         )
40 |         for child in children:
41 |             res_childs.append(
42 |                 _click_to_tree(
43 |                     ctx,
44 |                     child,
45 |                     ancestors[:]
46 |                     + [
47 |                         node,
48 |                     ],
49 |                 )
50 |             )
51 | 
52 |     res["name"] = node.name
53 | 
54 |     # Do not include any preformatted block (\b) for the short help.
55 |     res["short_help"] = node.get_short_help_str().split("\b")[0]
56 |     res["help"] = node.help
57 |     path_parts = ancestors + [node]
58 |     res["path"] = "/" + "/".join(p.name for p in path_parts)
59 |     if res_childs:
60 |         res["childs"] = res_childs
61 |     return res
62 | 


--------------------------------------------------------------------------------
/archivy/click_web/web_click_types.py:
--------------------------------------------------------------------------------
 1 | """
 2 | Extra click types that could be useful in a web context as
 3 | they have corresponding HTML form input type.
 4 | The custom web click types need only be imported
 5 | into the main script, not the app.py that flask runs.
 6 | 
 7 | Example usage in your click command:
 8 | \b
 9 |     from click_web.web_click_types import EMAIL_TYPE
10 |     @cli.command()
11 |     @click.option("--the_email", type=EMAIL_TYPE)
12 |     def email(the_email):
13 |         click.echo(f"{the_email} is a valid email syntax.")
14 | 
15 | """
16 | import re
17 | 
18 | import click
19 | 
20 | 
21 | class EmailParamType(click.ParamType):
22 |     name = "email"
23 |     EMAIL_REGEX = re.compile(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)")
24 | 
25 |     def convert(self, value, param, ctx):
26 |         if self.EMAIL_REGEX.match(value):
27 |             return value
28 |         else:
29 |             self.fail(f"{value} is not a valid email", param, ctx)
30 | 
31 | 
32 | class PasswordParamType(click.ParamType):
33 |     name = "password"
34 | 
35 |     def convert(self, value, param, ctx):
36 |         return value
37 | 
38 | 
39 | EMAIL_TYPE = EmailParamType()
40 | PASSWORD_TYPE = PasswordParamType()
41 | 


--------------------------------------------------------------------------------
/archivy/config.py:
--------------------------------------------------------------------------------
  1 | import os
  2 | import appdirs
  3 | 
  4 | 
  5 | class Config(object):
  6 |     """Configuration object for the application"""
  7 | 
  8 |     def __init__(self):
  9 |         self.SECRET_KEY = os.urandom(32)
 10 |         self.PORT = 5000
 11 |         self.HOST = "127.0.0.1"
 12 | 
 13 |         overridden_internal_dir = os.environ.get("ARCHIVY_INTERNAL_DIR_PATH")
 14 |         self.INTERNAL_DIR = overridden_internal_dir or appdirs.user_data_dir("archivy")
 15 | 
 16 |         self.USER_DIR = self.INTERNAL_DIR
 17 |         self.DEFAULT_BOOKMARKS_DIR = ""
 18 |         self.SITE_TITLE = "Archivy"
 19 |         os.makedirs(self.INTERNAL_DIR, exist_ok=True)
 20 | 
 21 |         self.PANDOC_HIGHLIGHT_THEME = "pygments"
 22 |         self.SCRAPING_CONF = {"save_images": False}
 23 | 
 24 |         self.THEME_CONF = {
 25 |             "use_theme_dark": False,
 26 |             "use_custom_css": False,
 27 |             "custom_css_file": "",
 28 |         }
 29 |         self.DATAOBJ_JS_EXTENSION = ""
 30 |         self.EDITOR_CONF = {
 31 |             "autosave": False,
 32 |             "settings": {
 33 |                 "html": False,
 34 |                 "xhtmlOut": False,
 35 |                 "breaks": False,
 36 |                 "linkify": True,
 37 |                 "typographer": False,
 38 |             },
 39 |             "plugins": {
 40 |                 "markdownitFootnote": {},
 41 |                 "markdownitMark": {},
 42 |                 "markdownItAnchor": {"permalink": True, "permalinkSymbol": "¶"},
 43 |                 "markdownItTocDoneRight": {},
 44 |             },
 45 |             "spellcheck": False,
 46 |             "toolbar_icons": [
 47 |                 "bold",
 48 |                 "italic",
 49 |                 "link",
 50 |                 "upload-image",
 51 |                 "heading",
 52 |                 "code",
 53 |                 "strikethrough",
 54 |                 "quote",
 55 |                 "table",
 56 |             ],  # https://github.com/Ionaru/easy-markdown-editor#toolbar-icons
 57 |         }
 58 | 
 59 |         self.SEARCH_CONF = {
 60 |             "enabled": 0,
 61 |             "url": "http://localhost:9200",
 62 |             "index_name": "dataobj",
 63 |             "engine": "",
 64 |             "es_user": "",
 65 |             "es_password": "",
 66 |             "es_processing_conf": {
 67 |                 "settings": {
 68 |                     "highlight": {"max_analyzed_offset": 100000000},
 69 |                     "analysis": {
 70 |                         "analyzer": {
 71 |                             "rebuilt_standard": {
 72 |                                 "stopwords": "_english_",
 73 |                                 "tokenizer": "standard",
 74 |                                 "filter": ["lowercase", "kstem", "trim", "unique"],
 75 |                             }
 76 |                         }
 77 |                     },
 78 |                 },
 79 |                 "mappings": {
 80 |                     "properties": {
 81 |                         "title": {
 82 |                             "type": "text",
 83 |                             "analyzer": "rebuilt_standard",
 84 |                             "term_vector": "with_positions_offsets",
 85 |                         },
 86 |                         "tags": {"type": "text", "analyzer": "rebuilt_standard"},
 87 |                         "body": {"type": "text", "analyzer": "rebuilt_standard"},
 88 |                     }
 89 |                 },
 90 |             },
 91 |         }
 92 | 
 93 |     def override(self, user_conf: dict, nested_dict=None):
 94 |         """
 95 |         This function enables an override of the default configuration with user values.
 96 | 
 97 |         Acts smartly so as to only set options already set in the default config.
 98 | 
 99 |         - user_conf: current (nested) dictionary of user config key/values
100 |         - nested_dict: reference to the current object that should be modified.
101 |             If none it's just a reference to the current Config itself, otherwise it's a nested dict of the Config
102 |         """
103 |         for k, v in user_conf.items():
104 |             if (nested_dict and not k in nested_dict) or (
105 |                 not nested_dict and not hasattr(self, k)
106 |             ):
107 |                 # check the key is indeed defined in our defaults
108 |                 continue
109 |             curr_default_val = nested_dict[k] if nested_dict else getattr(self, k)
110 |             if type(v) is dict:
111 |                 # pass on override to sub configuration dictionary if the current type of value being traversed is dict
112 |                 self.override(v, curr_default_val)
113 |             else:
114 |                 # otherwise just set
115 |                 if type(curr_default_val) == list and type(v) == str:
116 |                     v = v.split(", ")
117 |                 if nested_dict:
118 |                     nested_dict[k] = v
119 |                 else:
120 |                     setattr(self, k, v)
121 | 
122 | 
123 | class BaseHooks:
124 |     """
125 |     Class of methods users can inherit to configure and extend archivy with hooks.
126 | 
127 | 
128 |     ## Usage:
129 | 
130 |     Archivy checks for the presence of a `hooks.py` file in the
131 |     user directory that stores the `data/` directory with your notes and bookmarks.
132 |     This location is usually set during `archivy init`.
133 | 
134 |     Example `hooks.py` file:
135 | 
136 |     ```python
137 |     from archivy.config import BaseHooks
138 | 
139 |     class Hooks(BaseHooks):
140 |         def on_edit(self, dataobj):
141 |             print(f"Edit made to {dataobj.title}")
142 | 
143 |         def before_dataobj_create(self, dataobj):
144 |             from random import randint
145 |             dataobj.content += f"\\nThis note's random number is {randint(1, 10)}"
146 | 
147 |         # ...
148 |     ```
149 | 
150 |     If you have ideas for any other hooks you'd find useful if they were supported,
151 |     please open an [issue](https://github.com/archivy/archivy/issues).
152 |     """
153 | 
154 |     def on_dataobj_create(self, dataobj):
155 |         """Hook for dataobj creation."""
156 | 
157 |     def before_dataobj_create(self, dataobj):
158 |         """Hook called immediately before dataobj creation."""
159 | 
160 |     def on_user_create(self, user):
161 |         """Hook called after a new user is created."""
162 | 
163 |     def on_edit(self, dataobj):
164 |         """Hook called whenever a user edits through the web interface or the API."""
165 | 


--------------------------------------------------------------------------------
/archivy/forms.py:
--------------------------------------------------------------------------------
  1 | from flask_wtf import FlaskForm
  2 | from wtforms import (
  3 |     StringField,
  4 |     SubmitField,
  5 |     SelectField,
  6 |     PasswordField,
  7 |     HiddenField,
  8 |     BooleanField,
  9 |     FormField,
 10 | )
 11 | from wtforms.fields.html5 import IntegerField
 12 | from wtforms.validators import DataRequired, URL
 13 | from archivy.config import Config
 14 | 
 15 | 
 16 | class NewBookmarkForm(FlaskForm):
 17 |     url = StringField("url", validators=[DataRequired(), URL()])
 18 |     tags = StringField("tags")
 19 |     path = SelectField("Folder")
 20 |     submit = SubmitField("Save")
 21 | 
 22 | 
 23 | class NewNoteForm(FlaskForm):
 24 |     title = StringField("title", validators=[DataRequired()])
 25 |     tags = StringField("tags")
 26 |     path = SelectField("Folder")
 27 |     submit = SubmitField("Save")
 28 | 
 29 | 
 30 | class NewFolderForm(FlaskForm):
 31 |     parent_dir = HiddenField()
 32 |     new_dir = StringField("New folder", validators=[DataRequired()])
 33 |     submit = SubmitField("Create sub directory")
 34 | 
 35 | 
 36 | class MoveItemForm(FlaskForm):
 37 |     path = SelectField("Move to")
 38 |     submit = SubmitField("✓")
 39 | 
 40 | 
 41 | class RenameDirectoryForm(FlaskForm):
 42 |     new_name = StringField("New name", validators=[DataRequired()])
 43 |     current_path = HiddenField()
 44 |     submit = SubmitField("Rename current folder")
 45 | 
 46 | 
 47 | class DeleteDataForm(FlaskForm):
 48 |     submit = SubmitField("Delete")
 49 | 
 50 | 
 51 | class DeleteFolderForm(FlaskForm):
 52 |     dir_name = HiddenField(validators=[DataRequired()])
 53 | 
 54 | 
 55 | class UserForm(FlaskForm):
 56 |     username = StringField("username")
 57 |     password = PasswordField("password")
 58 |     submit = SubmitField("Submit")
 59 | 
 60 | 
 61 | class TitleForm(FlaskForm):
 62 |     title = StringField("title")
 63 |     submit = SubmitField("✓")
 64 | 
 65 | 
 66 | def config_form(current_conf, sub=0, allowed=vars(Config())):
 67 |     """
 68 |     This function defines a Config form that loads default configuration values and creates inputs for each field option
 69 | 
 70 |     - current_conf: object of current configuration that we will use to set the defaults
 71 |     - sub: this function is recursive and can return a sub form to represent the nesting of the config.
 72 |         Sub is a boolean value indicating whether the current form is nested.
 73 | 
 74 |     - allowed represents a dictionary of the keys that are allowed in our current level of nesting.
 75 |         It's fetched from the default config.
 76 |     """
 77 | 
 78 |     class ConfigForm(FlaskForm):
 79 |         pass
 80 | 
 81 |     def process_conf_value(name, val):
 82 |         """
 83 |         Create and set different form fields.
 84 | 
 85 |         """
 86 |         val_type = type(val)
 87 |         if not name in allowed:
 88 |             return
 89 |         if val_type is dict:
 90 |             sub_form = config_form(val, 1, allowed[name])
 91 |             setattr(ConfigForm, name, FormField(sub_form))
 92 |         elif val_type is int:
 93 |             setattr(ConfigForm, name, IntegerField(name, default=val))
 94 |         elif val_type is str:
 95 |             setattr(ConfigForm, name, StringField(name, default=val))
 96 |         elif val_type is bool:
 97 |             setattr(ConfigForm, name, BooleanField(name, default=val))
 98 |         elif val_type is list:
 99 |             setattr(ConfigForm, name, StringField(name, default=", ".join(val)))
100 | 
101 |     for key, val in current_conf.items():
102 |         process_conf_value(key, val)
103 |     if sub:
104 |         return ConfigForm
105 |     else:
106 |         ConfigForm.submit = SubmitField("Submit")
107 |         return ConfigForm()
108 | 


--------------------------------------------------------------------------------
/archivy/helpers.py:
--------------------------------------------------------------------------------
  1 | from pathlib import Path
  2 | import sys
  3 | import os
  4 | 
  5 | import elasticsearch
  6 | import yaml
  7 | from elasticsearch import Elasticsearch
  8 | from flask import current_app, g, request
  9 | from tinydb import TinyDB, Query, operations
 10 | from urllib.parse import urlparse, urljoin
 11 | 
 12 | from archivy.config import BaseHooks, Config
 13 | 
 14 | 
 15 | def load_config(path=""):
 16 |     """Loads `config.yml` file safely and deserializes it to a python dict."""
 17 |     path = path or current_app.config["INTERNAL_DIR"]
 18 |     with (Path(path) / "config.yml").open() as f:
 19 |         return yaml.load(f.read(), Loader=yaml.SafeLoader)
 20 | 
 21 | 
 22 | def config_diff(curr_key, curr_val, parent_dict, defaults):
 23 |     """
 24 |     This function diffs the user config with the defaults to only save what is actually different.
 25 | 
 26 |     Returns 1 if the current element or its nested elements are different and have been preserved.
 27 |     """
 28 |     if type(curr_val) is dict:
 29 |         # the any call here diffs all nested children of the current dict and returns whether any have modifications
 30 |         if not any(
 31 |             [
 32 |                 config_diff(k, v, curr_val, defaults[curr_key])
 33 |                 for k, v in list(curr_val.items())
 34 |             ]
 35 |         ):
 36 |             parent_dict.pop(curr_key)
 37 |             return 0
 38 |     else:
 39 |         if defaults[curr_key] == curr_val:
 40 |             parent_dict.pop(curr_key)
 41 |             return 0
 42 |     return 1
 43 | 
 44 | 
 45 | def write_config(config: dict):
 46 |     """
 47 |     Writes a new config dict to a `config.yml` file that will override defaults.
 48 |     Compares user config with defaults to only save changes.
 49 |     """
 50 |     defaults = vars(Config())
 51 |     for k, v in list(config.items()):
 52 |         if k != "SECRET_KEY":
 53 |             config_diff(k, v, config, defaults)
 54 |     with (Path(current_app.config["INTERNAL_DIR"]) / "config.yml").open("w") as f:
 55 |         yaml.dump(config, f)
 56 | 
 57 | 
 58 | def load_hooks():
 59 |     try:
 60 |         user_hooks = (Path(current_app.config["USER_DIR"]) / "hooks.py").open()
 61 |     except FileNotFoundError:
 62 |         return BaseHooks()
 63 | 
 64 |     user_locals = {}
 65 |     exec(user_hooks.read(), globals(), user_locals)
 66 |     user_hooks.close()
 67 |     return user_locals.get("Hooks", BaseHooks)()
 68 | 
 69 | 
 70 | def load_scraper():
 71 |     try:
 72 |         user_scraping = (Path(current_app.config["USER_DIR"]) / "scraping.py").open()
 73 |     except FileNotFoundError:
 74 |         return {}
 75 |     user_locals = {}
 76 |     exec(user_scraping.read(), globals(), user_locals)
 77 |     user_scraping.close()
 78 |     return user_locals.get("PATTERNS", {})
 79 | 
 80 | 
 81 | def get_db(force_reconnect=False):
 82 |     """
 83 |     Returns the database object that you can use to
 84 |     store data persistently
 85 |     """
 86 |     if "db" not in g or force_reconnect:
 87 |         g.db = TinyDB(str(Path(current_app.config["INTERNAL_DIR"]) / "db.json"))
 88 | 
 89 |     return g.db
 90 | 
 91 | 
 92 | def get_max_id():
 93 |     """Returns the current maximum id of dataobjs in the database."""
 94 |     db = get_db()
 95 |     max_id = db.search(Query().name == "max_id")
 96 |     if not max_id:
 97 |         db.insert({"name": "max_id", "val": 0})
 98 |         return 0
 99 |     return max_id[0]["val"]
100 | 
101 | 
102 | def set_max_id(val):
103 |     """Sets a new max_id"""
104 |     db = get_db()
105 |     db.update(operations.set("val", val), Query().name == "max_id")
106 | 
107 | 
108 | def test_es_connection(es):
109 |     """Tests health and presence of connection to elasticsearch."""
110 |     try:
111 |         health = es.cluster.health()
112 |     except elasticsearch.exceptions.ConnectionError:
113 |         current_app.logger.error(
114 |             "Elasticsearch does not seem to be running on "
115 |             f"{current_app.config['SEARCH_CONF']['url']}. Please start "
116 |             "it, for example with: sudo service elasticsearch restart"
117 |         )
118 |         current_app.logger.error(
119 |             "You can disable Elasticsearch by modifying the `enabled` variable "
120 |             f"in {str(Path(current_app.config['INTERNAL_DIR']) / 'config.yml')}"
121 |         )
122 |         sys.exit(1)
123 | 
124 |     if health["status"] not in ("yellow", "green"):
125 |         current_app.logger.warning(
126 |             "Elasticsearch reports that it is not working "
127 |             "properly. Search might not work. You can disable "
128 |             "Elasticsearch by setting ELASTICSEARCH_ENABLED to 0."
129 |         )
130 | 
131 | 
132 | def get_elastic_client(error_if_invalid=True):
133 |     """Returns the elasticsearch client you can use to search and insert / delete data"""
134 |     if (
135 |         not current_app.config["SEARCH_CONF"]["enabled"]
136 |         or current_app.config["SEARCH_CONF"]["engine"] != "elasticsearch"
137 |     ) and error_if_invalid:
138 |         return None
139 | 
140 |     auth_setup = (
141 |         current_app.config["SEARCH_CONF"]["es_user"]
142 |         and current_app.config["SEARCH_CONF"]["es_password"]
143 |     )
144 |     if auth_setup:
145 |         es = Elasticsearch(
146 |             current_app.config["SEARCH_CONF"]["url"],
147 |             http_auth=(
148 |                 current_app.config["SEARCH_CONF"]["es_user"],
149 |                 current_app.config["SEARCH_CONF"]["es_password"],
150 |             ),
151 |         )
152 |     else:
153 |         es = Elasticsearch(current_app.config["SEARCH_CONF"]["url"])
154 |     if error_if_invalid:
155 |         test_es_connection(es)
156 |     else:
157 |         try:
158 |             es.cluster.health()
159 |         except elasticsearch.exceptions.ConnectionError:
160 |             return False
161 |     return es
162 | 
163 | 
164 | def create_plugin_dir(name):
165 |     """Creates a sample plugin directory"""
166 |     raw_name = name.replace("archivy_", "").replace("archivy-", "")
167 |     try:
168 |         os.makedirs(f"{name}/{name}")
169 | 
170 |         # Creates requirements.txt.
171 |         with open(f"{name}/requirements.txt", "w") as fp:
172 |             fp.writelines(["archivy", "\nclick"])
173 | 
174 |         # Creates an empty readme file to be filled
175 |         with open(f"{name}/README.md", "w+") as fp:
176 |             fp.writelines(
177 |                 [
178 |                     f"# {name}",
179 |                     "\n\n## Install",
180 |                     "\n\nYou need to have `archivy` already installed.",
181 |                     f"\n\nRun `pip install archivy_{name}`",
182 |                     "\n\n## Usage",
183 |                 ]
184 |             )
185 | 
186 |         # Creates a setup.py file
187 |         with open(f"{name}/setup.py", "w") as setup_f:
188 |             setup_f.writelines(
189 |                 [
190 |                     "from setuptools import setup, find_packages",
191 |                     '\n\nwith open("README.md", "r") as fh:',
192 |                     "\n\tlong_description = fh.read()",
193 |                     '\n\nwith open("requirements.txt", encoding="utf-8") as f:',
194 |                     '\n\tall_reqs = f.read().split("\\n")',
195 |                     "\n\tinstall_requires = [x.strip() for x in all_reqs]",
196 |                     "\n\n#Fill in the details below for distribution purposes"
197 |                     f'\nsetup(\n\tname="{name}",',
198 |                     '\n\tversion="0.0.1",',
199 |                     '\n\tauthor="",',
200 |                     '\n\tauthor_email="",',
201 |                     '\n\tdescription="",',
202 |                     "\n\tlong_description=long_description,",
203 |                     '\n\tlong_description_content_type="text/markdown",',
204 |                     '\n\tclassifiers=["Programming Language :: Python :: 3"],'
205 |                     "\n\tpackages=find_packages(),",
206 |                     "\n\tinstall_requires=install_requires,",
207 |                     f'\n\tentry_points="""\n\t\t[archivy.plugins]'
208 |                     f'\n\t\t{raw_name}={name}:{raw_name}"""\n)',
209 |                 ]
210 |             )
211 | 
212 |         # Creating a basic __init__.py file where the main function of the plugin goes
213 |         with open(f"{name}/{name}/__init__.py", "w") as fp:
214 |             fp.writelines(
215 |                 [
216 |                     "import archivy",
217 |                     "\nimport click",
218 |                     "\n\n# Fill in the functionality for the commands (see https://archivy.github.io/plugins/)",
219 |                     "\n@click.group()",
220 |                     f"\ndef {raw_name}():",
221 |                     "\n\tpass",
222 |                     f"\n\n@{raw_name}.command()",
223 |                     "\ndef command1():",
224 |                     "\n\tpass",
225 |                     f"\n\n@{raw_name}.command()",
226 |                     "\ndef command2():",
227 |                     "\n\tpass",
228 |                 ]
229 |             )
230 | 
231 |         return True
232 |     except FileExistsError:
233 |         return False
234 | 
235 | 
236 | def is_safe_redirect_url(target):
237 |     host_url = urlparse(request.host_url)
238 |     redirect_url = urlparse(urljoin(request.host_url, target))
239 |     return (
240 |         redirect_url.scheme in ("http", "https")
241 |         and host_url.netloc == redirect_url.netloc
242 |     )
243 | 


--------------------------------------------------------------------------------
/archivy/search.py:
--------------------------------------------------------------------------------
  1 | from pathlib import Path
  2 | from shutil import which
  3 | from subprocess import run, PIPE
  4 | import json
  5 | 
  6 | from flask import current_app
  7 | 
  8 | from archivy.helpers import get_elastic_client
  9 | 
 10 | 
 11 | # Example command ["rg", RG_MISC_ARGS, RG_FILETYPE, RG_REGEX_ARG, query, str(get_data_dir())]
 12 | #  rg -il -t md -e query files
 13 | # -i -> case insensitive
 14 | # -l -> only output filenames
 15 | # -t -> file type
 16 | # -e -> regexp
 17 | RG_MISC_ARGS = "-it"
 18 | RG_REGEX_ARG = "-e"
 19 | RG_FILETYPE = "md"
 20 | 
 21 | 
 22 | def add_to_index(model):
 23 |     """
 24 |     Adds dataobj to given index. If object of given id already exists, it will be updated.
 25 | 
 26 |     Params:
 27 | 
 28 |     - **index** - String of the ES Index. Archivy uses `dataobj` by default.
 29 |     - **model** - Instance of `archivy.models.Dataobj`, the object you want to index.
 30 |     """
 31 |     es = get_elastic_client()
 32 |     if not es:
 33 |         return
 34 |     payload = {}
 35 |     for field in model.__searchable__:
 36 |         payload[field] = getattr(model, field)
 37 |     es.index(
 38 |         index=current_app.config["SEARCH_CONF"]["index_name"], id=model.id, body=payload
 39 |     )
 40 |     return True
 41 | 
 42 | 
 43 | def remove_from_index(dataobj_id):
 44 |     """Removes object of given id"""
 45 |     es = get_elastic_client()
 46 |     if not es:
 47 |         return
 48 |     es.delete(index=current_app.config["SEARCH_CONF"]["index_name"], id=dataobj_id)
 49 | 
 50 | 
 51 | def query_es_index(query, strict=False):
 52 |     """
 53 |     Returns search results for your given query
 54 | 
 55 |     Specify strict=True if you want only exact result (in case you're using ES.
 56 |     """
 57 |     es = get_elastic_client()
 58 |     if not es:
 59 |         return []
 60 |     search = es.search(
 61 |         index=current_app.config["SEARCH_CONF"]["index_name"],
 62 |         body={
 63 |             "query": {
 64 |                 "multi_match": {
 65 |                     "query": query,
 66 |                     "fields": ["*"],
 67 |                     "analyzer": "rebuilt_standard",
 68 |                 }
 69 |             },
 70 |             "highlight": {
 71 |                 "fragment_size": 0,
 72 |                 "fields": {
 73 |                     "content": {
 74 |                         "pre_tags": "",
 75 |                         "post_tags": "",
 76 |                     }
 77 |                 },
 78 |             },
 79 |         },
 80 |     )
 81 | 
 82 |     hits = []
 83 |     for hit in search["hits"]["hits"]:
 84 |         formatted_hit = {"id": hit["_id"], "title": hit["_source"]["title"]}
 85 |         if "highlight" in hit:
 86 |             formatted_hit["matches"] = hit["highlight"]["content"]
 87 |             reformatted_match = " ".join(formatted_hit["matches"])
 88 |             if strict and not (query in reformatted_match):
 89 |                 continue
 90 |         hits.append(formatted_hit)
 91 |     return hits
 92 | 
 93 | 
 94 | def parse_ripgrep_line(line):
 95 |     """Parses a line of ripgrep JSON output"""
 96 |     hit = json.loads(line)
 97 |     data = {}
 98 |     if hit["type"] == "begin":
 99 |         curr_file = (
100 |             Path(hit["data"]["path"]["text"]).parts[-1].replace(".md", "").split("-")
101 |         )  # parse target note data from path
102 |         curr_id = int(curr_file[0])
103 |         title = curr_file[-1].replace("_", " ")
104 |         data = {"title": title, "matches": [], "id": curr_id}
105 |     elif hit["type"] == "match":
106 |         data = hit["data"]["lines"]["text"].strip()
107 |     else:
108 |         return None  # only process begin and match events, we don't care about endings
109 |     return (data, hit["type"])
110 | 
111 | 
112 | def query_ripgrep(query):
113 |     """
114 |     Uses ripgrep to search data with a simpler setup than ES.
115 |     Returns a list of dicts with detailed matches.
116 |     """
117 | 
118 |     from archivy.data import get_data_dir
119 | 
120 |     if not which("rg"):
121 |         return []
122 | 
123 |     rg_cmd = ["rg", RG_MISC_ARGS, RG_FILETYPE, "--json", query, str(get_data_dir())]
124 |     rg = run(rg_cmd, stdout=PIPE, stderr=PIPE, timeout=60)
125 |     output = rg.stdout.decode().splitlines()
126 |     hits = []
127 |     for line in output:
128 |         parsed = parse_ripgrep_line(line)
129 |         if not parsed:
130 |             continue
131 |         if parsed[1] == "begin":
132 |             hits.append(parsed[0])
133 |         if parsed[1] == "match":
134 |             if not (parsed[0].startswith("tags: [") or parsed[0].startswith("title:")):
135 |                 hits[-1]["matches"].append(parsed[0])
136 |     return sorted(
137 |         hits, key=lambda x: len(x["matches"]), reverse=True
138 |     )  # sort by number of matches
139 | 
140 | 
141 | def search_frontmatter_tags(tag=None):
142 |     """
143 |     Returns a list of dataobj ids that have the given tag.
144 |     """
145 |     from archivy.data import get_data_dir
146 | 
147 |     if not which("rg"):
148 |         return []
149 |     META_PATTERN = r"(^|\n)tags:(\n- [_a-zA-ZÀ-ÖØ-öø-ÿ0-9]+)+"
150 |     hits = []
151 |     rg_cmd = [
152 |         "rg",
153 |         "-Uo",
154 |         RG_MISC_ARGS,
155 |         RG_FILETYPE,
156 |         "--json",
157 |         RG_REGEX_ARG,
158 |         META_PATTERN,
159 |         str(get_data_dir()),
160 |     ]
161 |     rg = run(rg_cmd, stdout=PIPE, stderr=PIPE, timeout=60)
162 |     output = rg.stdout.decode().splitlines()
163 |     for line in output:
164 |         parsed = parse_ripgrep_line(line)
165 |         if not parsed:  # the event doesn't interest us
166 |             continue
167 |         if parsed[1] == "begin":
168 |             hits.append(parsed[0])  # append current hit data
169 |         if parsed[1] == "match":
170 |             sanitized = parsed[0].replace("- ", "").split("\n")[2:]
171 |             hits[-1]["tags"] = hits[-1].get("tags", []) + sanitized  # get tags
172 |     if tag:
173 |         hits = list(filter(lambda x: tag in x["tags"], hits))
174 |     return hits
175 | 
176 | 
177 | def query_ripgrep_tags():
178 |     """
179 |     Uses ripgrep to search for tags.
180 |     Mandatory reference: https://xkcd.com/1171/
181 |     """
182 | 
183 |     EMB_PATTERN = r"(^|\n| )#([-_a-zA-ZÀ-ÖØ-öø-ÿ0-9]+)#"
184 |     from archivy.data import get_data_dir
185 | 
186 |     if not which("rg"):
187 |         return []
188 | 
189 |     # embedded tags
190 |     # io: case insensitive
191 |     rg_cmd = ["rg", "-Uio", RG_FILETYPE, RG_REGEX_ARG, EMB_PATTERN, str(get_data_dir())]
192 |     rg = run(rg_cmd, stdout=PIPE, stderr=PIPE, timeout=60)
193 |     hits = set()
194 |     for line in rg.stdout.splitlines():
195 |         tag = Path(line.decode()).parts[-1].split(":")[-1]
196 |         tag = tag.replace("#", "").lstrip()
197 |         hits.add(tag)
198 |     # metadata tags
199 |     for item in search_frontmatter_tags():
200 |         for tag in item["tags"]:
201 |             hits.add(tag)
202 |     return hits
203 | 
204 | 
205 | def search(query, strict=False):
206 |     """
207 |     Wrapper to search methods for different engines.
208 | 
209 |     If using ES, specify strict=True if you only want results that strictly match the query, without parsing / tokenization.
210 |     """
211 |     if current_app.config["SEARCH_CONF"]["engine"] == "elasticsearch":
212 |         return query_es_index(query, strict=strict)
213 |     elif current_app.config["SEARCH_CONF"]["engine"] == "ripgrep" or which("rg"):
214 |         return query_ripgrep(query)
215 | 


--------------------------------------------------------------------------------
/archivy/static/HIGHLIGHTJS-LICENSE:
--------------------------------------------------------------------------------
 1 | BSD 3-Clause License
 2 | 
 3 | Copyright (c) 2006, Ivan Sagalaev.
 4 | All rights reserved.
 5 | 
 6 | Redistribution and use in source and binary forms, with or without
 7 | modification, are permitted provided that the following conditions are met:
 8 | 
 9 | * Redistributions of source code must retain the above copyright notice, this
10 |   list of conditions and the following disclaimer.
11 | 
12 | * Redistributions in binary form must reproduce the above copyright notice,
13 |   this list of conditions and the following disclaimer in the documentation
14 |   and/or other materials provided with the distribution.
15 | 
16 | * Neither the name of the copyright holder nor the names of its
17 |   contributors may be used to endorse or promote products derived from
18 |   this software without specific prior written permission.
19 | 
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 | 


--------------------------------------------------------------------------------
/archivy/static/accessibility.css:
--------------------------------------------------------------------------------
1 | .sr-only {
2 |     position: absolute;
3 |     left: -10000px;
4 |     top: auto;
5 |     width: 1px;
6 |     height: 1px;
7 |     overflow: hidden;
8 | }
9 | 


--------------------------------------------------------------------------------
/archivy/static/archivy.svg:
--------------------------------------------------------------------------------
1 | <svg id="svg" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="400" viewBox="0, 0, 400,400"><g id="svgg"><path id="path0" d="M184.820 33.521 L 169.625 48.704 215.670 94.815 C 259.477 138.685,263.490 142.764,266.111 146.084 C 266.722 146.858,267.472 147.793,267.778 148.161 C 272.630 154.015,278.372 165.138,281.442 174.630 C 286.675 190.810,286.300 212.739,280.526 228.057 C 280.009 229.431,279.429 231.056,279.239 231.667 C 278.098 235.337,271.460 247.218,268.514 250.861 C 268.321 251.100,267.118 252.584,265.841 254.159 C 264.563 255.733,255.978 264.566,246.761 273.788 L 230.003 290.556 245.194 305.741 L 260.384 320.926 279.005 302.222 C 298.049 283.094,300.833 279.981,306.663 271.296 C 310.824 265.100,314.859 257.751,317.465 251.626 C 317.802 250.834,318.427 249.395,318.854 248.430 C 319.280 247.465,319.630 246.456,319.630 246.189 C 319.630 245.922,319.773 245.545,319.949 245.352 C 320.459 244.790,323.025 236.740,324.281 231.759 C 325.159 228.279,325.892 224.920,326.119 223.341 C 326.250 222.429,326.408 221.595,326.471 221.489 C 326.533 221.383,326.692 220.357,326.824 219.209 C 326.955 218.061,327.140 216.728,327.234 216.246 C 328.604 209.211,328.162 187.092,326.477 178.333 C 324.648 168.828,323.773 165.418,321.327 158.277 C 316.129 143.102,308.494 129.636,297.841 116.852 C 296.313 115.019,273.677 92.103,247.539 65.928 L 200.015 18.337 184.820 33.521 M114.353 104.082 C 104.297 114.233,103.266 115.315,101.006 118.089 C 100.351 118.893,99.315 120.164,98.704 120.913 C 96.953 123.058,91.582 130.791,90.849 132.222 C 90.484 132.935,90.072 133.604,89.935 133.709 C 89.405 134.111,84.366 143.693,82.734 147.402 C 62.859 192.561,69.860 243.830,101.102 281.914 C 104.420 285.959,138.750 320.741,139.424 320.741 C 139.915 320.741,169.630 291.057,169.630 290.566 C 169.630 290.370,161.740 282.287,152.096 272.605 C 132.851 253.281,130.401 250.421,125.230 241.231 C 119.940 231.831,116.191 219.923,114.992 208.704 C 113.972 199.171,114.944 187.322,117.544 177.593 C 119.171 171.505,125.315 156.991,126.240 157.053 C 126.373 157.062,133.359 163.854,141.765 172.146 L 157.048 187.222 156.474 189.444 C 155.650 192.633,155.460 201.591,156.131 205.600 C 160.025 228.853,183.508 245.337,205.926 240.554 C 206.435 240.446,207.435 240.260,208.148 240.141 C 208.861 240.023,209.736 239.770,210.093 239.579 C 210.449 239.389,210.741 239.315,210.741 239.415 C 210.741 240.224,219.553 235.954,223.333 233.312 C 253.396 212.305,243.000 162.729,207.222 156.481 C 206.407 156.339,205.324 156.139,204.815 156.038 C 201.157 155.311,192.223 155.726,188.148 156.813 C 187.800 156.905,175.808 145.191,156.020 125.427 L 124.448 93.894 114.353 104.082 M202.603 188.773 C 210.617 192.319,210.976 203.825,203.197 207.839 C 194.647 212.251,184.916 203.215,188.773 194.444 C 191.222 188.874,197.246 186.404,202.603 188.773 M184.726 335.837 L 169.444 351.107 184.631 366.295 L 199.817 381.483 215.094 366.203 L 230.370 350.923 215.189 335.745 L 200.008 320.567 184.726 335.837 " stroke="none" fill="#5cbc74" fill-rule="evenodd"></path><path id="path1" d="M179.259 58.704 C 184.655 64.102,189.152 68.519,189.254 68.519 C 189.356 68.519,185.025 64.102,179.630 58.704 C 174.234 53.306,169.737 48.889,169.635 48.889 C 169.533 48.889,173.864 53.306,179.259 58.704 M225.370 104.815 C 245.129 124.574,261.378 140.741,261.480 140.741 C 261.582 140.741,245.499 124.574,225.741 104.815 C 205.982 85.056,189.733 68.889,189.631 68.889 C 189.529 68.889,205.612 85.056,225.370 104.815 M149.634 180.185 C 153.604 184.259,156.928 187.544,157.021 187.485 C 157.114 187.426,153.866 184.093,149.803 180.078 L 142.417 172.778 149.634 180.185 M284.926 194.444 C 284.926 195.565,284.995 196.023,285.080 195.463 C 285.165 194.903,285.165 193.986,285.080 193.426 C 284.995 192.866,284.926 193.324,284.926 194.444 M155.296 198.519 C 155.296 199.639,155.366 200.097,155.450 199.537 C 155.535 198.977,155.535 198.060,155.450 197.500 C 155.366 196.940,155.296 197.398,155.296 198.519 M284.926 205.185 C 284.926 206.306,284.995 206.764,285.080 206.204 C 285.165 205.644,285.165 204.727,285.080 204.167 C 284.995 203.606,284.926 204.065,284.926 205.185 M284.530 209.630 C 284.530 210.343,284.607 210.634,284.700 210.278 C 284.793 209.921,284.793 209.338,284.700 208.981 C 284.607 208.625,284.530 208.917,284.530 209.630 M166.667 227.523 C 166.667 227.587,167.208 228.129,167.870 228.727 L 169.074 229.815 167.986 228.611 C 166.972 227.489,166.667 227.237,166.667 227.523 M244.815 305.741 C 253.063 313.991,259.895 320.741,259.997 320.741 C 260.098 320.741,253.433 313.991,245.185 305.741 C 236.937 297.491,230.105 290.741,230.003 290.741 C 229.902 290.741,236.567 297.491,244.815 305.741 " stroke="none" fill="#5cbc7c" fill-rule="evenodd"></path><path id="path2" d="M275.924 305.463 L 260.185 321.296 276.019 305.557 C 284.727 296.901,291.852 289.776,291.852 289.724 C 291.852 289.463,290.553 290.746,275.924 305.463 M214.998 366.389 L 199.815 381.667 215.093 366.483 C 223.495 358.132,230.370 351.257,230.370 351.205 C 230.370 350.945,229.111 352.189,214.998 366.389 " stroke="none" fill="#60bc74" fill-rule="evenodd"></path><path id="path3" d="M275.924 305.463 L 260.185 321.296 276.019 305.557 C 284.727 296.901,291.852 289.776,291.852 289.724 C 291.852 289.463,290.553 290.746,275.924 305.463 M214.998 366.389 L 199.815 381.667 215.093 366.483 C 223.495 358.132,230.370 351.257,230.370 351.205 C 230.370 350.945,229.111 352.189,214.998 366.389 " stroke="none" fill="#60bc74" fill-rule="evenodd"></path><path id="path4" d="M275.924 305.463 L 260.185 321.296 276.019 305.557 C 284.727 296.901,291.852 289.776,291.852 289.724 C 291.852 289.463,290.553 290.746,275.924 305.463 M214.998 366.389 L 199.815 381.667 215.093 366.483 C 223.495 358.132,230.370 351.257,230.370 351.205 C 230.370 350.945,229.111 352.189,214.998 366.389 " stroke="none" fill="#60bc74" fill-rule="evenodd"></path></g></svg>


--------------------------------------------------------------------------------
/archivy/static/delete.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/archivy/archivy/bdcdd39ac6cf9f7b3709b984d8be2f0fa898139e/archivy/static/delete.png


--------------------------------------------------------------------------------
/archivy/static/editor_dark.css:
--------------------------------------------------------------------------------
 1 | .EasyMDEContainer .CodeMirror {
 2 |     color: var(--mid-light-grey);
 3 |     background: var(--dark-grey);
 4 |     border: 1px solid var(--mid-grey);
 5 | }
 6 | 
 7 | .EasyMDEContainer .CodeMirror-fullscreen {
 8 |     background: var(--dark-grey);
 9 | }
10 | 
11 | .EasyMDEContainer .CodeMirror-focused .CodeMirror-selected, .CodeMirror-selectedtext, .CodeMirror-line::selection, .CodeMirror-line > span::selection, .CodeMirror-line > span > span::selection {
12 |     background: #3297fd;
13 |     color: white;
14 | }
15 | 
16 | .editor-toolbar {
17 |     background-color: var(--mid-dark-grey);
18 |     border-top: 1px solid var(--mid-grey);
19 |     border-left: 1px solid var(--mid-grey);
20 |     border-right: 1px solid var(--mid-grey);
21 | }
22 | 
23 | .editor-toolbar button {
24 |     color: var(--mid-light-grey);
25 | }
26 | 
27 | .editor-toolbar.fullscreen {
28 |     background: var(--dark-grey);
29 | }
30 | 
31 | .editor-preview {
32 |     background: var(--dark-grey);
33 | }
34 | 
35 | .editor-preview-side {
36 |     border: 1px solid var(--mid-grey);
37 | }
38 | 


--------------------------------------------------------------------------------
/archivy/static/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/archivy/archivy/bdcdd39ac6cf9f7b3709b984d8be2f0fa898139e/archivy/static/logo.png


--------------------------------------------------------------------------------
/archivy/static/main_dark.css:
--------------------------------------------------------------------------------
  1 | :root {
  2 |     --light-green: #f6efa6;
  3 |     --mid-green: #48BB78;
  4 |     --rose: #ffe3e6;
  5 |     --mid-rose: #f97583;
  6 |     --dark-rose: #e5534b;
  7 |     --grape: #7848bb;
  8 |     --dark-grey: #22272e;
  9 |     --mid-dark-grey: #373e47;
 10 |     --mid-grey: #636e7b;
 11 |     --mid-light-grey: #909dab;
 12 |     --light-grey: #cdd9e5;
 13 |     --hyperlink-color: #539bf5;
 14 | }
 15 | 
 16 | 
 17 | body {
 18 |     color: var(--mid-light-grey);
 19 |     background: var(--dark-grey);
 20 | }
 21 | 
 22 | 
 23 | a {
 24 |     color: var(--hyperlink-color);
 25 | }
 26 | 
 27 | mark {
 28 |     background-color: var(--light-green);
 29 | }
 30 | 
 31 | /* default form stylings */
 32 | input, select {
 33 |     color: var(--mid-light-grey);
 34 |     background-color: var(--mid-dark-grey);
 35 |     border-bottom: 3px var(--mid-grey) solid;
 36 | }
 37 | 
 38 | input:focus:not([type="submit"]), input:hover:not([type="submit"]), select:focus, select:hover {
 39 |     border-color: var(--mid-green);
 40 | }
 41 | 
 42 | .folder-cont {
 43 |     border-left: 2px var(--mid-green) solid;
 44 | }
 45 | .folder-cont h3 {
 46 |     color: var(--mid-light-grey);
 47 | }
 48 | .folder-cont a {
 49 |     color: var(--mid-light-grey);
 50 | }
 51 | .sidebar {
 52 |     border-right: 5px solid var(--mid-dark-grey);
 53 | }
 54 | 
 55 | #main-links .btn {
 56 |     color: var(--mid-light-grey);
 57 |     background-color: var(--mid-dark-grey);
 58 |     border-color: var(--mid-grey);
 59 | }
 60 | 
 61 | #main-links .btn:hover, .btn.hover, [open] > .btn {
 62 |     background-color: var(--mid-grey);
 63 |     transition-duration: 0.1s;
 64 | }
 65 | 
 66 | #current-path a {
 67 |     color: var(--hyperlink-color);
 68 | }
 69 | #files ul li {
 70 |     border-top: 1px var(--mid-grey) solid;
 71 | }
 72 | 
 73 | #files ul a {
 74 |     color: var(--mid-light-grey);
 75 | }
 76 | #post-btns button {
 77 |     color: var(--mid-light-grey);
 78 |     background-color: var(--mid-dark-grey);
 79 |     border-color: var(--mid-grey);
 80 | }
 81 | 
 82 | #post-btns .btn:hover, .btn.hover, [open] > .btn {
 83 |     background-color: var(--mid-grey);
 84 |     transition-duration: 0.1s;
 85 | }
 86 | .post-title-form-sucess {
 87 |     color: var(--mid-green);
 88 | }
 89 | 
 90 | .post-tags span {
 91 |     color: var(--mid-dark-grey);
 92 |     background: var(--light-green);
 93 | }
 94 | 
 95 | /*Partly inspired by Primer -> https://github.com/primer/css*/
 96 | .Header {
 97 |     color: var(--mid-light-grey);
 98 |     background-color: var(--mid-dark-grey) !important;
 99 | }
100 | 
101 | .Header-link {
102 |     color: var(--light-grey);
103 | }
104 | .flash {
105 |     color: var(--mid-dark-grey);
106 | }
107 | 
108 | .flash-error {
109 |     background-color: var(--rose);
110 |     border-color: var(--dark-rose);
111 | }
112 | 
113 | .flash-success {
114 |     color: var(--mid-light-grey);
115 |     background-color: var(--mid-dark-grey);
116 |     border-color: var(--mid-grey);
117 | }
118 | 
119 | 
120 | .btn {
121 |     color: var(--mid-light-grey);
122 |     background-color: var(--mid-dark-grey);
123 |     border-color: var(--mid-grey);
124 |     box-shadow: 0 1px 0 var(--dark-grey),inset 0 1px 0 var(--mid-dark-grey);
125 | }
126 | 
127 | .btn:hover, .btn.hover, [open] > .btn {
128 |     background-color: var(--mid-grey);
129 | }
130 | 
131 | #link-form {
132 |     border: 1px var(--light-grey) solid;
133 |     background-color: var(--light-grey);
134 | }
135 | 


--------------------------------------------------------------------------------
/archivy/static/markdown.css:
--------------------------------------------------------------------------------
  1 | /* GitHub's markdown rendering stylesheet from https://github.com/primer/css */
  2 | 
  3 | .markdown-body kbd {
  4 |     display: inline-block;
  5 |     padding: 3px 5px;
  6 |     font: 11px "SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;
  7 |     line-height: 10px;
  8 |     color: #444d56;
  9 |     vertical-align: middle;
 10 |     background-color: #fafbfc;
 11 |     border: solid 1px #d1d5da;
 12 |     border-bottom-color: #d1d5da;
 13 |     border-radius: 6px;
 14 |     box-shadow: inset 0 -1px 0 #d1d5da;
 15 | }
 16 | .markdown-body::before {
 17 |     display: table;
 18 |     content: "";
 19 | }
 20 | .markdown-body::after {
 21 |     display: table;
 22 |     clear: both;
 23 |     content: "";
 24 | }
 25 | .markdown-body > *:first-child {
 26 |     margin-top: 0 !important;
 27 | }
 28 | .markdown-body > *:last-child {
 29 |     margin-bottom: 0 !important;
 30 | }
 31 | .markdown-body a:not([href]) {
 32 |     color: inherit;
 33 |     text-decoration: none;
 34 | }
 35 | .markdown-body .absent {
 36 |     color: #cb2431;
 37 | }
 38 | .markdown-body .anchor {
 39 |     float: left;
 40 |     padding-right: 4px;
 41 |     margin-left: -20px;
 42 |     line-height: 1;
 43 | }
 44 | .markdown-body .anchor:focus {
 45 |     outline: none;
 46 | }
 47 | blockquote
 48 | .markdown-body details,
 49 | .markdown-body dl,
 50 | .markdown-body ol,
 51 | .markdown-body p,
 52 | .markdown-body pre,
 53 | .markdown-body table,
 54 | .markdown-body ul {
 55 |     margin-top: 0;
 56 |     margin-bottom: 16px;
 57 | }
 58 | .markdown-body h1,
 59 | .markdown-body h2,
 60 | .markdown-body h3,
 61 | .markdown-body h4,
 62 | .markdown-body h5,
 63 | .markdown-body h6 {
 64 |     margin-top: 24px;
 65 |     margin-bottom: 16px;
 66 |     font-weight: 600;
 67 |     line-height: 1.25;
 68 | }
 69 | .markdown-body h1 .octicon-link,
 70 | .markdown-body h2 .octicon-link,
 71 | .markdown-body h3 .octicon-link,
 72 | .markdown-body h4 .octicon-link,
 73 | .markdown-body h5 .octicon-link,
 74 | .markdown-body h6 .octicon-link {
 75 |     color: #1b1f23;
 76 |     vertical-align: middle;
 77 |     visibility: hidden;
 78 | }
 79 | .markdown-body h1:hover .anchor,
 80 | .markdown-body h2:hover .anchor,
 81 | .markdown-body h3:hover .anchor,
 82 | .markdown-body h4:hover .anchor,
 83 | .markdown-body h5:hover .anchor,
 84 | .markdown-body h6:hover .anchor {
 85 |     text-decoration: none;
 86 | }
 87 | .markdown-body h1:hover .anchor .octicon-link,
 88 | .markdown-body h2:hover .anchor .octicon-link,
 89 | .markdown-body h3:hover .anchor .octicon-link,
 90 | .markdown-body h4:hover .anchor .octicon-link,
 91 | .markdown-body h5:hover .anchor .octicon-link,
 92 | .markdown-body h6:hover .anchor .octicon-link {
 93 |     visibility: visible;
 94 | }
 95 | .markdown-body h1 code,
 96 | .markdown-body h1 tt,
 97 | .markdown-body h2 code,
 98 | .markdown-body h2 tt,
 99 | .markdown-body h3 code,
100 | .markdown-body h3 tt,
101 | .markdown-body h4 code,
102 | .markdown-body h4 tt,
103 | .markdown-body h5 code,
104 | .markdown-body h5 tt,
105 | .markdown-body h6 code,
106 | .markdown-body h6 tt {
107 |     font-size: inherit;
108 | }
109 | .markdown-body h1 {
110 |     padding-bottom: 0.3em;
111 |     font-size: 2em;
112 |     border-bottom: 1px solid #eaecef;
113 | }
114 | .markdown-body h2 {
115 |     padding-bottom: 0.3em;
116 |     font-size: 1.5em;
117 |     border-bottom: 1px solid #eaecef;
118 | }
119 | .markdown-body h3 {
120 |     font-size: 1.25em;
121 | }
122 | .markdown-body h4 {
123 |     font-size: 1em;
124 | }
125 | .markdown-body h5 {
126 |     font-size: 0.875em;
127 | }
128 | .markdown-body h6 {
129 |     font-size: 0.85em;
130 |     color: #6a737d;
131 | }
132 | .markdown-body ol,
133 | .markdown-body ul {
134 |     padding-left: 2em;
135 | }
136 | .markdown-body ol.no-list,
137 | .markdown-body ul.no-list {
138 |     padding: 0;
139 |     list-style-type: none;
140 | }
141 | .markdown-body ol ol,
142 | .markdown-body ol ul,
143 | .markdown-body ul ol,
144 | .markdown-body ul ul {
145 |     margin-top: 0;
146 |     margin-bottom: 0;
147 | }
148 | .markdown-body li {
149 |     word-wrap: break-all;
150 | }
151 | .markdown-body li > p {
152 |     margin-top: 16px;
153 | }
154 | .markdown-body li+li {
155 |     margin-top: 0.25em;
156 | }
157 | .markdown-body dl {
158 |     padding: 0;
159 | }
160 | .markdown-body dl dt {
161 |     padding: 0;
162 |     margin-top: 16px;
163 |     font-size: 1em;
164 |     font-style: italic;
165 |     font-weight: 600;
166 | }
167 | .markdown-body dl dd {
168 |     padding: 0 16px;
169 |     margin-bottom: 16px;
170 | }
171 | .markdown-body table {
172 |     display: block;
173 |     width: 100%;
174 |     width: -webkit-max-content;
175 |     width: -moz-max-content;
176 |     width: max-content;
177 |     max-width: 100%;
178 |     overflow: auto;
179 | }
180 | .markdown-body table th {
181 |     font-weight: 600;
182 | }
183 | .markdown-body table td,
184 | .markdown-body table th {
185 |     padding: 6px 13px;
186 |     border: 1px solid #dfe2e5;
187 | }
188 | .markdown-body table tr {
189 |     background-color: #fff;
190 |     border-top: 1px solid #c6cbd1;
191 | }
192 | .markdown-body table tr:nth-child(2n) {
193 |     background-color: #f6f8fa;
194 | }
195 | .markdown-body table img {
196 |     background-color: transparent;
197 | }
198 | .markdown-body img {
199 |     max-width: 100%;
200 |     box-sizing: content-box;
201 |     background-color: #fff;
202 | }
203 | .markdown-body img[align=right] {
204 |     padding-left: 20px;
205 | }
206 | .markdown-body img[align=left] {
207 |     padding-right: 20px;
208 | }
209 | .markdown-body .emoji {
210 |     max-width: none;
211 |     vertical-align: text-top;
212 |     background-color: transparent;
213 | }
214 | .markdown-body span.frame {
215 |     display: block;
216 |     overflow: hidden;
217 | }
218 | .markdown-body span.frame > span {
219 |     display: block;
220 |     float: left;
221 |     width: auto;
222 |     padding: 7px;
223 |     margin: 13px 0 0;
224 |     overflow: hidden;
225 |     border: 1px solid #dfe2e5;
226 | }
227 | .markdown-body span.frame span img {
228 |     display: block;
229 |     float: left;
230 | }
231 | .markdown-body span.frame span span {
232 |     display: block;
233 |     padding: 5px 0 0;
234 |     clear: both;
235 |     color: #24292e;
236 | }
237 | .markdown-body span.align-center {
238 |     display: block;
239 |     overflow: hidden;
240 |     clear: both;
241 | }
242 | .markdown-body span.align-center > span {
243 |     display: block;
244 |     margin: 13px auto 0;
245 |     overflow: hidden;
246 |     text-align: center;
247 | }
248 | .markdown-body span.align-center span img {
249 |     margin: 0 auto;
250 |     text-align: center;
251 | }
252 | .markdown-body span.align-right {
253 |     display: block;
254 |     overflow: hidden;
255 |     clear: both;
256 | }
257 | .markdown-body span.align-right > span {
258 |     display: block;
259 |     margin: 13px 0 0;
260 |     overflow: hidden;
261 |     text-align: right;
262 | }
263 | .markdown-body span.align-right span img {
264 |     margin: 0;
265 |     text-align: right;
266 | }
267 | .markdown-body span.float-left {
268 |     display: block;
269 |     float: left;
270 |     margin-right: 13px;
271 |     overflow: hidden;
272 | }
273 | .markdown-body span.float-left span {
274 |     margin: 13px 0 0;
275 | }
276 | .markdown-body span.float-right {
277 |     display: block;
278 |     float: right;
279 |     margin-left: 13px;
280 |     overflow: hidden;
281 | }
282 | .markdown-body span.float-right > span {
283 |     display: block;
284 |     margin: 13px auto 0;
285 |     overflow: hidden;
286 |     text-align: right;
287 | }
288 | .markdown-body code,
289 | .markdown-body tt {
290 |     padding: 0.2em 0.4em;
291 |     margin: 0;
292 |     font-size: 85%;
293 |     background-color: rgba(27,31,35,0.05);
294 |     border-radius: 6px;
295 | }
296 | .markdown-body code br,
297 | .markdown-body tt br {
298 |     display: none;
299 | }
300 | .markdown-body del code {
301 |     text-decoration: inherit;
302 | }
303 | .markdown-body pre {
304 |     word-wrap: normal;
305 | }
306 | .markdown-body pre > code {
307 |     padding: 0;
308 |     margin: 0;
309 |     font-size: 100%;
310 |     word-break: normal;
311 |     white-space: pre;
312 |     border: 0;
313 |     background: transparent;
314 | }
315 | .markdown-body .highlight {
316 |     margin-bottom: 16px;
317 | }
318 | .markdown-body .highlight pre {
319 |     margin-bottom: 0;
320 |     word-break: normal;
321 | }
322 | .markdown-body .highlight pre,
323 | .markdown-body pre {
324 |     padding: 16px;
325 |     overflow: auto;
326 |     font-size: 85%;
327 |     line-height: 1.45;
328 |     border-radius: 6px;
329 | }
330 | .markdown-body pre code,
331 | .markdown-body pre tt {
332 |     display: inline;
333 |     max-width: auto;
334 |     padding: 0;
335 |     margin: 0;
336 |     overflow: visible;
337 |     line-height: inherit;
338 |     word-wrap: normal;
339 |     background-color: transparent;
340 |     border: 0;
341 | }
342 | .markdown-body .csv-data td,
343 | .markdown-body .csv-data th {
344 |     padding: 5px;
345 |     overflow: hidden;
346 |     font-size: 12px;
347 |     line-height: 1;
348 |     text-align: left;
349 |     white-space: nowrap;
350 | }
351 | .markdown-body .csv-data .blob-num {
352 |     padding: 10px 8px 9px;
353 |     text-align: right;
354 |     background: #fff;
355 |     border: 0;
356 | }
357 | .markdown-body .csv-data tr {
358 |     border-top: 0;
359 | }
360 | .markdown-body .csv-data th {
361 |     font-weight: 600;
362 |     background: #f6f8fa;
363 |     border-top: 0;
364 | }
365 | 


--------------------------------------------------------------------------------
/archivy/static/markdown_dark.css:
--------------------------------------------------------------------------------
1 | .markdown-body h1 {
2 |     padding-bottom: 0.3em;
3 |     font-size: 2em;
4 |     border-bottom: 1px solid var(--mid-grey);
5 | }
6 | 


--------------------------------------------------------------------------------
/archivy/static/monokai.css:
--------------------------------------------------------------------------------
 1 | /*
 2 | 
 3 | Monokai Sublime style. Derived from Monokai by noformnocontent http://nn.mit-license.org/
 4 | 
 5 | */
 6 | 
 7 | .hljs {
 8 |   display: block;
 9 |   overflow-x: auto;
10 |   padding: 0.5em;
11 |   background: #23241f;
12 | }
13 | 
14 | .hljs,
15 | .hljs-tag,
16 | .hljs-subst {
17 |   color: #f8f8f2;
18 | }
19 | 
20 | .hljs-strong,
21 | .hljs-emphasis {
22 |   color: #a8a8a2;
23 | }
24 | 
25 | .hljs-bullet,
26 | .hljs-quote,
27 | .hljs-number,
28 | .hljs-regexp,
29 | .hljs-literal,
30 | .hljs-link {
31 |   color: #ae81ff;
32 | }
33 | 
34 | .hljs-code,
35 | .hljs-title,
36 | .hljs-section,
37 | .hljs-selector-class {
38 |   color: #a6e22e;
39 | }
40 | 
41 | .hljs-strong {
42 |   font-weight: bold;
43 | }
44 | 
45 | .hljs-emphasis {
46 |   font-style: italic;
47 | }
48 | 
49 | .hljs-keyword,
50 | .hljs-selector-tag,
51 | .hljs-name,
52 | .hljs-attr {
53 |   color: #f92672;
54 | }
55 | 
56 | .hljs-symbol,
57 | .hljs-attribute {
58 |   color: #66d9ef;
59 | }
60 | 
61 | .hljs-params,
62 | .hljs-class .hljs-title {
63 |   color: #f8f8f2;
64 | }
65 | 
66 | .hljs-string,
67 | .hljs-type,
68 | .hljs-built_in,
69 | .hljs-builtin-name,
70 | .hljs-selector-id,
71 | .hljs-selector-attr,
72 | .hljs-selector-pseudo,
73 | .hljs-addition,
74 | .hljs-variable,
75 | .hljs-template-variable {
76 |   color: #e6db74;
77 | }
78 | 
79 | .hljs-comment,
80 | .hljs-deletion,
81 | .hljs-meta {
82 |   color: #75715e;
83 | }
84 | 


--------------------------------------------------------------------------------
/archivy/static/mvp.css:
--------------------------------------------------------------------------------
  1 | /* MVP.css v1.6.2 - https://github.com/andybrewer/mvp */
  2 | 
  3 | :root {
  4 |     --border-radius: 5px;
  5 |     --box-shadow: 2px 2px 10px;
  6 |     --color: #118bee;
  7 |     --color-accent: #118bee15;
  8 |     --color-bg: #fff;
  9 |     --color-bg-secondary: #e9e9e9;
 10 |     --color-secondary: #920de9;
 11 |     --color-secondary-accent: #920de90b;
 12 |     --color-shadow: #f4f4f4;
 13 |     --color-text: #000;
 14 |     --color-text-secondary: #999;
 15 |     --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
 16 |     --hover-brightness: 1.2;
 17 |     --justify-important: center;
 18 |     --justify-normal: left;
 19 |     --line-height: 1.5;
 20 |     --width-card: 285px;
 21 |     --width-card-medium: 460px;
 22 |     --width-card-wide: 800px;
 23 |     --width-content: 1080px;
 24 | }
 25 | 
 26 | /*
 27 | @media (prefers-color-scheme: dark) {
 28 |     :root {
 29 |         --color: #0097fc;
 30 |         --color-accent: #0097fc4f;
 31 |         --color-bg: #333;
 32 |         --color-bg-secondary: #555;
 33 |         --color-secondary: #e20de9;
 34 |         --color-secondary-accent: #e20de94f;
 35 |         --color-shadow: #bbbbbb20;
 36 |         --color-text: #f7f7f7;
 37 |         --color-text-secondary: #aaa;
 38 |     }
 39 | }
 40 | */
 41 | 
 42 | /* Layout */
 43 | article aside {
 44 |     background: var(--color-secondary-accent);
 45 |     border-left: 4px solid var(--color-secondary);
 46 |     padding: 0.01rem 0.8rem;
 47 | }
 48 | 
 49 | body {
 50 |     background: var(--color-bg);
 51 |     color: var(--color-text);
 52 |     font-family: var(--font-family);
 53 |     line-height: var(--line-height);
 54 |     margin: 0;
 55 |     overflow-x: hidden;
 56 |     padding: 1rem 0;
 57 | }
 58 | 
 59 | footer,
 60 | header,
 61 | main {
 62 |     margin: 0 auto;
 63 |     max-width: var(--width-content);
 64 |     padding: 2rem 1rem;
 65 | }
 66 | 
 67 | hr {
 68 |     background-color: var(--color-bg-secondary);
 69 |     border: none;
 70 |     height: 1px;
 71 |     margin: 4rem 0;
 72 | }
 73 | 
 74 | section {
 75 |     display: flex;
 76 |     flex-wrap: wrap;
 77 |     justify-content: var(--justify-important);
 78 | }
 79 | 
 80 | section aside {
 81 |     border: 1px solid var(--color-bg-secondary);
 82 |     border-radius: var(--border-radius);
 83 |     box-shadow: var(--box-shadow) var(--color-shadow);
 84 |     margin: 1rem;
 85 |     padding: 1.25rem;
 86 |     width: var(--width-card);
 87 | }
 88 | 
 89 | section aside:hover {
 90 |     box-shadow: var(--box-shadow) var(--color-bg-secondary);
 91 | }
 92 | 
 93 | section aside img {
 94 |     max-width: 100%;
 95 | }
 96 | 
 97 | [hidden] {
 98 |     display: none;
 99 | }
100 | 
101 | /* Headers */
102 | article header,
103 | div header,
104 | main header {
105 |     padding-top: 0;
106 | }
107 | 
108 | header {
109 |     text-align: var(--justify-important);
110 | }
111 | 
112 | header a b,
113 | header a em,
114 | header a i,
115 | header a strong {
116 |     margin-left: 0.5rem;
117 |     margin-right: 0.5rem;
118 | }
119 | 
120 | header nav img {
121 |     margin: 1rem 0;
122 | }
123 | 
124 | section header {
125 |     padding-top: 0;
126 |     width: 100%;
127 | }
128 | 
129 | /* Nav */
130 | nav {
131 |     align-items: center;
132 |     display: flex;
133 |     font-weight: bold;
134 |     justify-content: space-between;
135 | }
136 | 
137 | nav ul {
138 |     list-style: none;
139 |     padding: 0;
140 | }
141 | 
142 | nav ul li {
143 |     display: inline-block;
144 |     margin: 0 0.5rem;
145 |     position: relative;
146 |     text-align: left;
147 | }
148 | 
149 | /* Nav Dropdown */
150 | nav ul li:hover ul {
151 |     display: block;
152 | }
153 | 
154 | nav ul li ul {
155 |     background: var(--color-bg);
156 |     border: 1px solid var(--color-bg-secondary);
157 |     border-radius: var(--border-radius);
158 |     box-shadow: var(--box-shadow) var(--color-shadow);
159 |     display: none;
160 |     height: auto;
161 |     left: -2px;
162 |     padding: .5rem 1rem;
163 |     position: absolute;
164 |     top: 1.7rem;
165 |     white-space: nowrap;
166 |     width: auto;
167 | }
168 | 
169 | nav ul li ul li,
170 | nav ul li ul li a {
171 |     display: block;
172 | }
173 | 
174 | /* Typography */
175 | code,
176 | samp {
177 |     background-color: var(--color-accent);
178 |     border-radius: var(--border-radius);
179 |     display: inline-block;
180 |     margin: 0 0.1rem;
181 |     padding: 0 0.5rem;
182 | }
183 | 
184 | details {
185 |     margin: 1.3rem 0;
186 | }
187 | 
188 | details summary {
189 |     font-weight: bold;
190 |     cursor: pointer;
191 | }
192 | 
193 | h1,
194 | h2,
195 | h3,
196 | h4,
197 | h5,
198 | h6 {
199 |     line-height: var(--line-height);
200 | }
201 | 
202 | mark {
203 |     padding: 0.1rem;
204 | }
205 | 
206 | ol li,
207 | ul li {
208 |     padding: 0.2rem 0;
209 | }
210 | 
211 | p {
212 |     margin: 0.75rem 0;
213 |     padding: 0;
214 | }
215 | 
216 | pre {
217 |     margin: 1rem 0;
218 |     max-width: var(--width-card-wide);
219 |     padding: 1rem 0;
220 | }
221 | 
222 | pre code,
223 | pre samp {
224 |     display: block;
225 |     max-width: var(--width-card-wide);
226 |     padding: 0.5rem 2rem;
227 |     white-space: pre-wrap;
228 | }
229 | 
230 | small {
231 |     color: var(--color-text-secondary);
232 | }
233 | 
234 | sup {
235 |     border-radius: var(--border-radius);
236 |     color: var(--color-bg);
237 |     font-size: xx-small;
238 |     font-weight: bold;
239 |     margin: 0.2rem;
240 |     padding: 0.2rem 0.3rem;
241 |     position: relative;
242 |     top: -2px;
243 | }
244 | 
245 | /* Links */
246 | a {
247 |     color: var(--color-secondary);
248 |     display: inline-block;
249 |     font-weight: bold;
250 |     text-decoration: none;
251 | }
252 | 
253 | a:hover {
254 |     filter: brightness(var(--hover-brightness));
255 |     text-decoration: underline;
256 | }
257 | 
258 | button {
259 |     border-radius: var(--border-radius);
260 |     display: inline-block;
261 |     font-size: medium;
262 |     font-weight: bold;
263 |     line-height: var(--line-height);
264 |     margin: 0.5rem 0;
265 |     padding: 1rem 2rem;
266 | }
267 | 
268 | button {
269 |     font-family: var(--font-family);
270 | }
271 | 
272 | button:hover {
273 |     cursor: pointer;
274 |     filter: brightness(var(--hover-brightness));
275 | }
276 | 
277 | /* Images */
278 | figure {
279 |     margin: 0;
280 |     padding: 0;
281 | }
282 | 
283 | figure img {
284 |     max-width: 100%;
285 | }
286 | 
287 | figure figcaption {
288 |     color: var(--color-text-secondary);
289 | }
290 | 
291 | /* Forms */
292 | 
293 | button:disabled,
294 | input:disabled {
295 |     background: var(--color-bg-secondary);
296 |     border-color: var(--color-bg-secondary);
297 |     color: var(--color-text-secondary);
298 |     cursor: not-allowed;
299 | }
300 | 
301 | button[disabled]:hover {
302 |     filter: none;
303 | }
304 | 
305 | form {
306 |     border: 1px solid var(--color-bg-secondary);
307 |     border-radius: var(--border-radius);
308 |     box-shadow: var(--box-shadow) var(--color-shadow);
309 |     display: block;
310 |     max-width: var(--width-card-wide);
311 |     min-width: var(--width-card);
312 |     padding: 1.5rem;
313 |     text-align: var(--justify-normal);
314 | }
315 | 
316 | form header {
317 |     margin: 1.5rem 0;
318 |     padding: 1.5rem 0;
319 | }
320 | 
321 | input,
322 | label,
323 | select,
324 | textarea {
325 |     display: block;
326 |     font-size: inherit;
327 |     max-width: var(--width-card-wide);
328 | }
329 | 
330 | input[type="checkbox"],
331 | input[type="radio"] {
332 |     display: inline-block;
333 | }
334 | 
335 | input[type="checkbox"]+label,
336 | input[type="radio"]+label {
337 |     display: inline-block;
338 |     font-weight: normal;
339 |     position: relative;
340 |     top: 1px;
341 | }
342 | 
343 | input,
344 | select,
345 | textarea {
346 |     border: 1px solid var(--color-bg-secondary);
347 |     border-radius: var(--border-radius);
348 |     margin-bottom: 1rem;
349 |     padding: 0.4rem 0.8rem;
350 | }
351 | 
352 | input[readonly],
353 | textarea[readonly] {
354 |     background-color: var(--color-bg-secondary);
355 | }
356 | 
357 | label {
358 |     font-weight: bold;
359 |     margin-bottom: 0.2rem;
360 | }
361 | 
362 | /* Tables */
363 | table {
364 |     border: 1px solid var(--color-bg-secondary);
365 |     border-radius: var(--border-radius);
366 |     border-spacing: 0;
367 |     display: inline-block;
368 |     max-width: 100%;
369 |     overflow-x: auto;
370 |     padding: 0;
371 |     white-space: nowrap;
372 | }
373 | 
374 | table td,
375 | table th,
376 | table tr {
377 |     padding: 0.4rem 0.8rem;
378 |     text-align: var(--justify-important);
379 | }
380 | 
381 | table thead {
382 |     background-color: var(--color);
383 |     border-collapse: collapse;
384 |     border-radius: var(--border-radius);
385 |     color: var(--color-bg);
386 |     margin: 0;
387 |     padding: 0;
388 | }
389 | 
390 | table thead th:first-child {
391 |     border-top-left-radius: var(--border-radius);
392 | }
393 | 
394 | table thead th:last-child {
395 |     border-top-right-radius: var(--border-radius);
396 | }
397 | 
398 | table thead th:first-child,
399 | table tr td:first-child {
400 |     text-align: var(--justify-normal);
401 | }
402 | 
403 | table tr:nth-child(even) {
404 |     background-color: var(--color-accent);
405 | }
406 | 
407 | /* Quotes */
408 | blockquote {
409 |     display: block;
410 |     line-height: var(--line-height);
411 |     max-width: var(--width-card-medium);
412 |     text-align: var(--justify-important);
413 |     border-left: 5px solid #ff4301;
414 |     padding: 10px;
415 |     margin: 10px 0px;
416 |     color: #555555;
417 | }
418 | blockquote footer {
419 |     color: var(--color-text-secondary);
420 |     display: block;
421 |     font-size: small;
422 |     line-height: var(--line-height);
423 |     padding: 1.5rem 0;
424 | }
425 | 


--------------------------------------------------------------------------------
/archivy/static/open_form.js:
--------------------------------------------------------------------------------
 1 | function openCommand(cmdUrl, updateState, menuItem) {
 2 |     if (REQUEST_RUNNING) {
 3 |         let leave = confirm("Command is still running. Leave anyway?");
 4 |         if (leave) {
 5 |             // hack as this is global on page and if we leave the other command cannot run.
 6 |             // TODO: fetch will continue to run.
 7 |             //  Do a real abort of fetch: https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort
 8 |             REQUEST_RUNNING = false;
 9 |         } else {
10 |             return;
11 |         }
12 |     }
13 | 
14 |     let formDiv = document.getElementById('form-div');
15 |     // if (updateState) {
16 |     //
17 |     //     history.pushState({'cmdUrl': cmdUrl}, null, cmdUrl);
18 |     // }
19 |     fetch(cmdUrl)
20 |         .then(function(response) {
21 |             selectMenuItem(menuItem);
22 |             return response.text();
23 |         })
24 |         .then(function(theFormHtml) {
25 |             formDiv.innerHTML = theFormHtml;
26 | 			formDiv.scrollIntoView();
27 |         });
28 | }
29 | 
30 | function selectMenuItem(menuItem) {
31 |     var x = document.getElementsByClassName("command-selected");
32 |     var i;
33 |     for (i = 0; i < x.length; i++) {
34 |         x[i].classList.remove('command-selected');
35 |     }
36 |     menuItem.classList.add('command-selected');
37 | }
38 | 


--------------------------------------------------------------------------------
/archivy/static/post_and_read.js:
--------------------------------------------------------------------------------
  1 | let REQUEST_RUNNING = false;
  2 | 
  3 | function postAndRead(commandUrl) {
  4 |     if (REQUEST_RUNNING) {
  5 |         return false;
  6 |     }
  7 | 
  8 |     try {
  9 |         TextDecoder
 10 |     } catch (e) {
 11 |         console.error(e);
 12 |         // Browser missing Text decoder (Edge?)
 13 |         // post form the normal way.
 14 |         return true;
 15 | 
 16 |     }
 17 | 
 18 |     input_form = document.getElementById("inputform");
 19 |     let submit_btn = document.getElementById("submit_btn");
 20 | 
 21 | 
 22 |     try {
 23 |         REQUEST_RUNNING = true;
 24 |         submit_btn.disabled = true;
 25 |         let runner = new ExecuteAndProcessOutput(input_form, commandUrl);
 26 |         runner.run();
 27 |     } catch (e) {
 28 |         console.error(e);
 29 | 
 30 |     } finally {
 31 |         // if we executed anything never post form
 32 |         // as we do not know if form already was submitted.
 33 |         return false;
 34 | 
 35 |     }
 36 | }
 37 | 
 38 | class ExecuteAndProcessOutput {
 39 |     constructor(form, commandPath) {
 40 |         this.form = form;
 41 |         this.commandUrl = commandPath;
 42 |         this.decoder = new TextDecoder('utf-8');
 43 |         this.output_header_div = document.getElementById("output-header")
 44 |         this.output_div = document.getElementById("output")
 45 |         this.output_footer_div = document.getElementById("output-footer")
 46 |         // clear old content
 47 |         this.output_header_div.innerHTML = '';
 48 |         this.output_div.innerHTML = '';
 49 |         this.output_footer_div.innerHTML = '';
 50 |         // show script output
 51 |         this.output_header_div.hidden = false;
 52 |         this.output_div.hidden = false;
 53 |         this.output_footer_div.hidden = false;
 54 |     }
 55 | 
 56 |     run() {
 57 |         let submit_btn = document.getElementById("submit_btn");
 58 |         this.post(this.commandUrl)
 59 |             .then(response => {
 60 |                 this.form.disabled = true;
 61 |                 if (response.body === undefined) {
 62 |                     firefoxFallback(response)
 63 |                     return;
 64 |                 } else {
 65 |                     let reader = response.body.getReader();
 66 |                     return this.processStreamReader(reader);
 67 |                 }
 68 |             })
 69 |             .then(_ => {
 70 |                 REQUEST_RUNNING = false
 71 |                 submit_btn.disabled = false;
 72 |             })
 73 |             .catch(error => {
 74 |                     console.error(error);
 75 |                     REQUEST_RUNNING = false;
 76 |                     submit_btn.disabled = false;
 77 |                 }
 78 |             );
 79 |     }
 80 | 
 81 |     firefoxFallback(response) {
 82 |         console.log('Firefox < 65 body streams are experimental and not enabled by default.');
 83 |         console.warn('Falling back to reading full response.');
 84 |         response.text()
 85 |             .then(text => this.output_div.innerHTML = text);
 86 | 
 87 |         submit_btn.disabled = false;
 88 | 
 89 |     }
 90 | 
 91 |     post() {
 92 |         console.log("Posting to " + this.commandUrl);
 93 |         return fetch(this.commandUrl, {
 94 |             method: "POST",
 95 |             body: new FormData(this.form),
 96 |             // for fetch streaming only accept plain text, we wont handle html
 97 |             headers: {Accept: 'text/plain'}
 98 |         });
 99 |     }
100 | 
101 |     async processStreamReader(reader) {
102 |         while (true) {
103 |             const result = await reader.read();
104 |             let chunk = this.decoder.decode(result.value);
105 |             let insert_func = this.output_div.insertAdjacentText;
106 |             let elem = this.output_div;
107 | 
108 |             // Split the read chunk into sections if needed.
109 |             // Below implementation is not perfect as it expects the CLICK_WEB section markers to be
110 |             // complete and not in separate chunks. However it seems to work fine
111 |             // as long as the generating server yields the CLICK_WEB section in one string as they should be
112 |             // quite small.
113 | 			chunk = chunk.replace("<br>", "\n")
114 |             if (chunk.includes('<!-- CLICK_WEB')) {
115 |                 // there are one or more click web special sections, use regexp split chunk into parts
116 |                 // and process them individually
117 |                 let parts = chunk.split(/(<!-- CLICK_WEB [A-Z]+ [A-Z]+ -->)/);
118 |                 for (let part of parts) {
119 |                     [elem, insert_func] = this.getInsertFunc(part, elem, insert_func);
120 |                     if (part.startsWith('<!-- ')) {
121 |                         // no not display end section comments.
122 |                         continue;
123 |                     } else {
124 |                         insert_func.call(elem, 'beforeend', part);
125 |                     }
126 |                 }
127 |             } else {
128 |                 insert_func.call(elem, 'beforeend', chunk);
129 |             }
130 | 
131 |             if (result.done) {
132 |                 break
133 |             }
134 |         }
135 |     }
136 | 
137 |     getInsertFunc(part, current_elem, current_func) {
138 |         // If we enter new section modify output method accordingly.
139 |         if (part.includes(' START ')) {
140 |             if (part.includes(' HEADER ')) {
141 |                 return [this.output_header_div, this.output_header_div.insertAdjacentHTML];
142 |             } else if (part.includes(' FOOTER ')) {
143 |                 return [this.output_footer_div, this.output_footer_div.insertAdjacentHTML];
144 |             } else {
145 |                 throw new Error("Unknown part:" + part);
146 |             }
147 |         } else if (part.includes(' END ')) {
148 |             // plain text again
149 |             return [this.output_div, this.output_div.insertAdjacentText];
150 |         } else {
151 |             // no change
152 |             return [current_elem, current_func];
153 |         }
154 |     }
155 | }
156 | 
157 | 


--------------------------------------------------------------------------------
/archivy/static/profile.svg:
--------------------------------------------------------------------------------
1 | <svg width="25" height="41" viewBox="0 0 25 41" fill="none" xmlns="http://www.w3.org/2000/svg">
2 | <circle cx="13" cy="7" r="7" fill="black"/>
3 | <path d="M25 28.5C25 35.4036 19.4036 41 12.5 41C5.59644 41 0 35.4036 0 28.5C0 21.5964 5.59644 16 12.5 16C19.4036 16 25 21.5964 25 28.5Z" fill="black"/>
4 | </svg>
5 | 


--------------------------------------------------------------------------------
/archivy/tags.py:
--------------------------------------------------------------------------------
 1 | import re
 2 | 
 3 | from flask import current_app
 4 | from archivy import helpers, data
 5 | from tinydb import Query, operations
 6 | from archivy.search import query_ripgrep_tags
 7 | 
 8 | 
 9 | def is_tag_format(tag_name):
10 |     return re.match("^[a-zA-Z0-9_-]+
quot;, tag_name)
11 | 
12 | 
13 | def get_all_tags(force=False):
14 |     db = helpers.get_db()
15 |     list_query = db.search(Query().name == "tag_list")
16 | 
17 |     # If the "tag_list" doesn't exist in the database: create it.
18 |     newly_created = list_query == []
19 |     if newly_created:
20 |         db.insert({"name": "tag_list", "val": []})
21 | 
22 |     # Then update it if needed
23 |     tags = []
24 |     if newly_created or force:
25 |         tags = list(query_ripgrep_tags())
26 |         db.update(operations.set("val", tags), Query().name == "tag_list")
27 |     else:
28 |         tags = list_query[0]["val"]
29 |     return tags
30 | 
31 | 
32 | def add_tag_to_index(tag_name):
33 |     all_tags = get_all_tags()
34 |     if tag_name not in all_tags:
35 |         all_tags.append(tag_name)
36 |         db = helpers.get_db()
37 |         db.update(operations.set("val", all_tags), Query().name == "tag_list")
38 |     return True
39 | 


--------------------------------------------------------------------------------
/archivy/templates/bookmarklet.html:
--------------------------------------------------------------------------------
 1 | {% extends "base.html" %}
 2 | {% block content %}
 3 | 
 4 | <h2>Bookmarklet</h2>
 5 | 
 6 | Using the bookmarklet allows you to quickly add bookmarks to archivy.
 7 | 
 8 | All you need to do is drage the link below to your browser toolbar. Then, when you find an interesting link, just click the button on your toolbar named "Add to archivy".
 9 | 
10 | <h3><a href="javascript: (async function() {
11 |   document.body.insertAdjacentHTML('beforeend', `
12 |     <form style='display: none' method='post' target='_blank' id='archivy_bookmarklet' action='{{ request.host_url | safe }}/save_from_bookmarklet'>
13 |       <input type='hidden' name='url'>
14 |       <input type='text' name='html'>
15 |     </form>
16 |   `);
17 |   const form = document.getElementById('archivy_bookmarklet');
18 |   form.querySelector('input[name=url]').value = window.location.href;
19 |   form.querySelector('input[name=html]').value = document.documentElement.outerHTML;
20 |   form.submit();})();">
21 | 	Add to {{ config.SITE_TITLE }}
22 | </a></h3>
23 | 
24 | {% endblock %}
25 | 


--------------------------------------------------------------------------------
/archivy/templates/click_web/command_form.html:
--------------------------------------------------------------------------------
 1 | {% import 'click_web/form_macros.html' as macros %}
 2 | <h2 class="command-title-parents">{{ levels[1:-1]| join(' - ', attribute='command.name')|title }}</h2>
 3 | <h3 class="command-title">{{ command.name|title }}</h3>
 4 | <div class="command-help">{{ command.html_help }}</div>
 5 | 
 6 | <form id="inputform"
 7 |       method="post"
 8 |       action="{{ request.url }}"
 9 |       onsubmit="return postAndRead('{{ request.url }}');"
10 |       class="pure-form pure-form-aligned"
11 |       enctype="multipart/form-data">
12 |     {% set command_list = [] %}
13 |     {% for level in levels %}
14 |         {% for field in level.fields %}
15 |             <div class="parameter {{ field.param }} {{ field.type }}">
16 |                 <div class="pure-control-group">
17 |                     {% if field.nargs == -1 %}
18 |                         {{ macros.add_variadic_field_input(field) }}
19 |                     {% else %}
20 |                         <label for="{{ field.name }}">
21 |                             {{ field.human_readable_name|capitalize }}
22 |                         </label>
23 |                         {% for arg in range(field.nargs) %}
24 |                             {{ macros.add_field_input(field) }}
25 |                         {% endfor %}
26 | 
27 |                     {% endif %}
28 |                 </div>
29 |             </div>
30 |         {% endfor %}
31 |     {% endfor %}
32 |   <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
33 |     <button type="submit" id="submit_btn" class="btn btn-primary m-2">Run</button>
34 | </form>
35 | 
36 | <div id="output-header" hidden="true"></div>
37 | <div class="script-output" id="output" hidden="true"></div>
38 | <div id="output-footer" hidden="true"></div>
39 | 


--------------------------------------------------------------------------------
/archivy/templates/click_web/form_macros.html:
--------------------------------------------------------------------------------
 1 | {% macro add_field_input(field) -%}
 2 |     {% if field.type == 'option' %}
 3 |         {{ add_option_field(field) }}
 4 |     {% else %}
 5 |         {% if field.type == "checkbox" and field.checked %}
 6 |             {#
 7 |             Hack to work with flags that are default on.
 8 |             As checkbox is only sent down when checked we duplicate
 9 |             hidden field that is always sent but with empty value.
10 |             https://stackoverflow.com/a/1992745/1306577 #}
11 |             <input name="{{ field.name }}" type='hidden' value='{{ field.off_flag }}'>
12 |         {% endif %}
13 |         <input name="{{ field.name }}"
14 |                id="{{ field.name }}"
15 |                class="form-control input-lg {% if field.type != 'checkbox' %}d-block{% endif %} mt-2 mb-2"
16 |                placeholder="{{ field.desc |default('', true)|capitalize}}"
17 |                type="{{ field.type }}"
18 |                 {% if field.step %} step="{{ field.step }}"{% endif %}
19 |                 {% if field.accept %} accept="{{ field.accept }}"{% endif %}
20 |                value="{{ field.value|default('', true) }}"
21 |                 {{ field.checked }}
22 |                 {{ 'required' if field.required else '' }}
23 | 
24 | 
25 |         >
26 |     {% endif %}
27 | 
28 | {%- endmacro %}
29 | 
30 | {% macro add_option_field(field) -%}
31 |     <select name="{{ field.name }}">
32 |         {% for option in field.options %}
33 |             <option value="{{ option }}"
34 |                     {{ 'selected' if field.default == option else '' }}>
35 |                 {{ option }}
36 |             </option>
37 |         {% endfor %}
38 |     </select>
39 | {%- endmacro %}
40 | 
41 | 
42 | {% macro add_variadic_field_input(field) -%}
43 |     <label for="{{ field.name }}">
44 |         {{ field.human_readable_name|capitalize }}
45 |     </label>
46 |     {% if field.type == 'option' or field.type == "checkbox" %}
47 |         VARIARDIC OPTIONS OR CHECKBOXES ARE NOT SUPPORTED
48 |     {% else %}
49 |         <textarea class="d-block mt-2 mb-2" name="{{ field.name }}" id="{{ field.name }}" cols="40" rows="5"></textarea>
50 |     {% endif %}
51 |     <div class="help" style="display: inline-block">
52 |         (Each line will be passed as separate argument)
53 |         {{ field.desc|default('', true)|capitalize }}
54 |     </div>
55 | 
56 | {%- endmacro %}
57 | 


--------------------------------------------------------------------------------
/archivy/templates/click_web/show_tree.html:
--------------------------------------------------------------------------------
 1 | {% extends "base.html" %}
 2 | 
 3 | {% block content %}
 4 | <script src="{{ url_for('static', filename='open_form.js') }}"></script>
 5 | <script src="{{ url_for('static', filename='post_and_read.js') }}"></script>
 6 | <div>
 7 |     <div class="command-tree" id="files">
 8 |         <h1>Plugins</h1>
 9 |         <ul>
10 |             {%- for command in tree.childs recursive %}
11 |                 <li class="file" title="{{ command.help|replace('\b', '')|escape }}">
12 |                     {% if command.is_group %}
13 |                         <p>{{ command.name|title }} {% if command.short_help %} | {{ command.short_help }} {% endif %}</p>
14 |                     {% else %}
15 |                         <a title="{{ command.help|replace('\b', '')|escape }}"
16 |                            href="#" onclick="openCommand('{{ command.path }}', true, this);">{{ command.name }}</a>
17 |                     {% endif %}
18 |                     {%- if command.childs -%}
19 |                         <ul>{{ loop(command.childs) }}</ul>
20 |                     {%- endif %}</li>
21 |             {%- endfor %}
22 |         </ul>
23 | 
24 |     </div>
25 | </div>
26 | 
27 | <div class="p-4">
28 |     <div id="form-div">
29 |         <p>Select a command to run above.</p>
30 |     </div>
31 | </div>
32 | 
33 | {% endblock %}
34 | 


--------------------------------------------------------------------------------
/archivy/templates/config.html:
--------------------------------------------------------------------------------
 1 | {% extends "base.html" %}
 2 | {% macro render_form(form, depth) %}
 3 |   {% for field in form %}
 4 |     {% if field.type == "FormField"%}
 5 |     <div>
 6 |       {% if field.data | length > 1 %}
 7 |         <h{{ [4, depth + 1] | min}} id="{{ field.id }}">{{ field.label.text }}</h{{ [4, depth + 1] | min}}>
 8 |       {% endif %}
 9 |       {{ render_form(field, depth + 1) }}
10 |     </div>
11 |     {% elif not field.type in ["CSRFTokenField", "FormField", "SubmitField"] %}
12 |       <div>  
13 |       {{ field.label }}
14 |       {{ field() }}
15 |       </div>
16 |     {% elif field.type in ["CSRFTokenField", "SubmitField"] %}
17 |       {{ field() }}
18 |     {% endif %}
19 |   {% endfor %}
20 | {%- endmacro -%}
21 | {% macro render_toc(form) %}
22 |   <ul>
23 |   {% for field in form %}
24 |     {% if field.type == "FormField" and field.data | length > 1 %}
25 |     <li>
26 |       <a href="#{{ field.id }}">{{ field.label.text }}</a>
27 |       {{ render_toc(field) }}
28 |     </li>
29 |     {% endif %}
30 |   {% endfor %}
31 |   </ul>
32 | {%- endmacro -%}
33 | {% block content %}
34 |   <h1>Config</h1>
35 |   <p>Check out <a href="https://archivy.github.io/config" target="_blank" rel="noopener">the documentation</a> for more info on these options.</p>
36 |   {{ render_toc(conf) }}
37 |    <form action="/config" method="POST">
38 |     {{ render_form(conf, 1) }}
39 |   </form>
40 |   
41 | {% endblock %}
42 | 


--------------------------------------------------------------------------------
/archivy/templates/dataobjs/new.html:
--------------------------------------------------------------------------------
 1 | {% extends "base.html" %}
 2 | 
 3 | {% block content %}
 4 |     <form action="" method="post" novalidate>
 5 |         <h1>{{ title }}</h1>
 6 |         {{ form.hidden_tag() }}
 7 |         {% for field in form %}
 8 |             {% if field.name != "csrf_token" and field.name != "submit" %}
 9 |                 <p>
10 |                     {% if field.name == "path" %}
11 |                         {{ field.label }}
12 |                     {% endif %}
13 |                     {{ field(placeholder=field.name, **{"aria-describedby":"username-input-validation"}) }}
14 | 
15 |                     {% for error in field.errors %}
16 |                         <div class="error-wrapper">
17 |                             <p class="note-error" id="validation-{{ field.name }}">{{ error }}</p>
18 |                         </div>
19 |                     {% endfor %}
20 |                 </p>
21 |             {% endif %}
22 |         {% endfor %}
23 |         <p>{{ form.submit() }}</p>
24 |     </form>
25 | {% endblock %}
26 | 


--------------------------------------------------------------------------------
/archivy/templates/home.html:
--------------------------------------------------------------------------------
  1 | {% extends "base.html" %}
  2 | {% block content %}
  3 | 
  4 | {% set search_enabled = config['SEARCH_CONF']['enabled'] %}
  5 | {% if search_enabled %}
  6 |     <input type="text" id="searchBar" placeholder="Search">
  7 |     <ul id="searchHits"></ul>
  8 | {% endif %}
  9 | 
 10 | <div id="files">
 11 | 
 12 |     {% if not view_only %}
 13 |         <div class="d-flex" id="main-links">
 14 |             <div style="width: 100%;" class="d-flex">
 15 |                 <a class="btn" href="/notes/new?path={{current_path}}">
 16 |                     New Note
 17 |                     <svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0114.25 14H1.75A1.75 1.75 0 010 12.25v-8.5zm1.75-.25a.25.25 0 00-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25v-8.5a.25.25 0 00-.25-.25H1.75zM3.5 6.25a.75.75 0 01.75-.75h7a.75.75 0 010 1.5h-7a.75.75 0 01-.75-.75zm.75 2.25a.75.75 0 000 1.5h4a.75.75 0 000-1.5h-4z"></path></svg>
 18 |                 </a>
 19 |                 <a class="btn" href="/bookmarks/new?path={{current_path}}">
 20 |                     New Bookmark
 21 |                     <svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M4.75 2.5a.25.25 0 00-.25.25v9.91l3.023-2.489a.75.75 0 01.954 0l3.023 2.49V2.75a.25.25 0 00-.25-.25h-6.5zM3 2.75C3 1.784 3.784 1 4.75 1h6.5c.966 0 1.75.784 1.75 1.75v11.5a.75.75 0 01-1.227.579L8 11.722l-3.773 3.107A.75.75 0 013 14.25V2.75z"></path></svg>
 22 |                 </a>
 23 |                 <form action="/folders/delete" method="post" onsubmit="return confirm('Delete this folder and its items permanently?')" style="margin: 0px;">
 24 |                     {{ delete_form.csrf_token }}
 25 |                     {{ delete_form.dir_name(value=current_path) }}
 26 |                     <button class="btn btn-delete">
 27 |                         Delete folder
 28 |                     <svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19c.9 0 1.652-.681 1.741-1.576l.66-6.6a.75.75 0 00-1.492-.149l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"></path></svg>
 29 |                     </button>
 30 |                 </form>
 31 |             </div>
 32 |             <div class="d-flex">
 33 |                 <form action="/folders/create" method="post" class="d-flex">
 34 |                     {{ new_folder_form.csrf_token }}
 35 |                     {{ new_folder_form.parent_dir(value=current_path) }}
 36 |                     {{ new_folder_form.new_dir(placeholder="New folder name") }}
 37 |                     {{ new_folder_form.submit() }}
 38 |                 </form>
 39 |                 {% if current_path != "" %}
 40 |                     <form action="/folders/rename" method="post" class="d-flex">
 41 |                         {{ rename_form.csrf_token }}
 42 |                         {{ rename_form.current_path(value=current_path) }}
 43 |                         {{ rename_form.new_name(placeholder="Name") }}
 44 |                         {{ rename_form.submit() }}
 45 |                     </form>
 46 |                 {% endif %}
 47 |             </div>
 48 |         </div>
 49 |     {% endif %}
 50 | 
 51 | 	<h3>Recently modified</h3>
 52 | 	<ul>
 53 |       {% for d in most_recent %}
 54 |         <li style="display: flex; justify-content: space-between"><a href="/dataobj/{{ d['id'] }}">{{ d['title'] }}</a><p style="color: #696969; margin: 0">Modified at {{ d['modified_at'] }}</p></li>
 55 |       {% endfor %}
 56 | 	</ul>
 57 |     <ul>
 58 |         
 59 |         <div class="folder-tags">
 60 |             {% for tag in tag_cloud %}
 61 |             <a href="/tags/{{ tag }}">
 62 |                 <span class="post-tag">{{ tag }}</span>
 63 |             </a>
 64 |             {% endfor %}
 65 |         </div>
 66 | 
 67 |         {% for subdir in dir.child_dirs.values() | sort(attribute="name") %}
 68 |             <li class="dir">
 69 |                 <svg class="octicon ml-2 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.75 1A1.75 1.75 0 000 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0016 13.25v-8.5A1.75 1.75 0 0014.25 3h-6.5a.25.25 0 01-.2-.1l-.9-1.2c-.33-.44-.85-.7-1.4-.7h-3.5z"></path></svg>
 70 |                 <a href="/?path={{current_path}}{{subdir.name}}/">{{ subdir.name }}</a>
 71 |             </li>
 72 |         {% endfor %}
 73 |         {% for file in dir.child_files | sort(attribute="title") %}
 74 |             <li class="file">
 75 |                 <svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M3.75 1.5a.25.25 0 00-.25.25v11.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V6H9.75A1.75 1.75 0 018 4.25V1.5H3.75zm5.75.56v2.19c0 .138.112.25.25.25h2.19L9.5 2.06zM2 1.75C2 .784 2.784 0 3.75 0h5.086c.464 0 .909.184 1.237.513l3.414 3.414c.329.328.513.773.513 1.237v8.086A1.75 1.75 0 0112.25 15h-8.5A1.75 1.75 0 012 13.25V1.75z"></path></svg>
 76 |                 <a href="/dataobj/{{file.id}}">{{ file.title }}</a>
 77 |             </li>
 78 |         {% endfor %}
 79 |     </ul>
 80 | </div>
 81 | 
 82 | {% if search_enabled %}
 83 |     {% include "markdown-parser.html" %}
 84 | {% endif %}
 85 | <script>
 86 |   // search functionality
 87 |   {% if search_enabled %}  
 88 |     function sanitize_match_html(rendered, q)
 89 |     {
 90 |       let stripped_tags = new RegExp("(?:<img.*>|<mark>|</mark>|<bold>|<bold>)", "ig"); // remove unnecessary tags
 91 |       let replace_headings = new RegExp("<h[0-9][^>]*>(.*)</h[0-9]>", "ig"); // replace headings with <p>
 92 |       let highlight_regex = new RegExp(`(?![^<>]*>)(${q})`, "ig"); // replace query in html text, not attributes, with highlights
 93 |       let output = rendered.replace(stripped_tags, "").replace(replace_headings, "<p>$1</p>").replace(highlight_regex, "<mark>$1</mark>");
 94 |       if (!output.match("(?![^<>]*>)\\w+")) { output = ""; } // check there is actual text, not html, in the output
 95 |       return output;
 96 |     }
 97 |     function appendHit(hit, q) {
 98 |       let hitsDiv = document.getElementById("searchHits");
 99 |       let hitLi = document.createElement("li");
100 |       let a = document.createElement("a");
101 |       a.href = `/dataobj/${hit["id"]}`, a.textContent = hit["title"];
102 |       hitLi.append(a);
103 |       let body = document.createElement("div");
104 |       if ("matches" in hit)
105 |       {
106 |         let max_matches = 4, rendered_matches = "<p>Matches:</p>", n_rendered_matches = 0;
107 |         hit["matches"].every((match) => {
108 |           let curr_match_output = sanitize_match_html(window.parser.customRender(match), q);
109 |           if (curr_match_output != "") { // only render if there is text remaining after sanitization
110 |             rendered_matches += "<hr>" + curr_match_output;
111 |             n_rendered_matches++;
112 |           }
113 |           if (n_rendered_matches == max_matches) {
114 |             rendered_matches += "...";
115 |             return false; // stop looping
116 |           }
117 |           return true; // keep going
118 |         })
119 |         body.innerHTML += rendered_matches;
120 |       }
121 |       hitLi.append(body);
122 |       hitsDiv.appendChild(hitLi);
123 |     }
124 | 
125 |     let input = document.getElementById("searchBar");
126 |     input.addEventListener('input', async function(e) {
127 |       let query = input.value;
128 |       if (input.value !== "")
129 |       {
130 |         let searchQuery = await fetch(`${SCRIPT_ROOT}/search?query=${encodeURIComponent(input.value)}`, {
131 |           "method": "GET"
132 |         });
133 |         if (searchQuery.ok && query == input.value) {  
134 |           let data = await searchQuery.json(), i = 0;
135 |           document.getElementById("searchHits").innerHTML = "";
136 |           data.forEach(function(hit)
137 |           {
138 |             if (query !== input.value) return;
139 |             appendHit(hit, query);
140 |           })
141 |     
142 |         }
143 |       }
144 |       else {
145 |         document.getElementById("searchHits").innerHTML = "";
146 |       }
147 |     });
148 |   {% endif %}
149 | </script>
150 | {% endblock %}
151 | 


--------------------------------------------------------------------------------
/archivy/templates/markdown-parser.html:
--------------------------------------------------------------------------------
 1 | <link rel="stylesheet" href="/static/math.css">
 2 | <link  rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex/dist/katex.min.css">
 3 | <link rel="stylesheet" href="/static/monokai.css">
 4 | <script src="/static/math.js">
 5 | </script>
 6 | <script src="/static/highlight.js"></script> 
 7 | <script src="/static/parser.js"></script>
 8 | <script>
 9 |   window.parser = window.markdownit({
10 |       {# The lower is needed to make JS understand Python bools #}
11 |       {% for name, value in config.EDITOR_CONF.settings.items() %}
12 |         {{ name }}: {{ value | lower }},
13 |       {% endfor %}
14 |       highlight: function (str, lang) {
15 |         if (lang && hljs.getLanguage(lang)) {
16 |           try {
17 |             return '<pre class="hljs"><code>' +
18 |                     hljs.highlight(lang, str, true).value +
19 |                     '</code></pre>';
20 |           } catch (__) {}
21 |         }
22 |       }
23 |   })
24 |   .use(texmath.use(katex), {
25 |     engine: katex,
26 |     delimiters:'dollars',
27 |     katexOptions: { macros: {"\\RR": "\\mathbb{R}"} }
28 |   })
29 |   {% for plugin_name, plugin_params in config.EDITOR_CONF.plugins.items() %}
30 |     .use(window.{{plugin_name}}{% if plugin_params | length %}, {{plugin_params|tojson}}{% endif %})
31 |   {% endfor %}
32 |   window.parser.customRender = function(content) {
33 |     let tag_regex = new RegExp("(^|\\n| )#([-_a-zA-ZÀ-ÖØ-öø-ÿ0-9]+)#", "g");
34 |     let note_link_regex = /\[\[(.+)\|([0-9]+)\]\]/g;
35 |     content = content.replace(tag_regex, "$1[#$2](/tags/$2)");
36 |     content = content.replace(note_link_regex, "[[[$1]]](/dataobj/$2)");
37 |     return window.parser.render(content);
38 |   }
39 | </script>
40 | 


--------------------------------------------------------------------------------
/archivy/templates/tags/all.html:
--------------------------------------------------------------------------------
 1 | {% extends "base.html" %}
 2 | 
 3 | {% block content %}
 4 | <h2>All tags ({{ tags | length }})</h2>
 5 | <ul class="post-tags all-tags">
 6 | {% for tag in tags %}
 7 |   <a href="/tags/{{ tag }}"><span class="post-tag">#{{ tag }}</span></a>
 8 | {% endfor %}
 9 | </ul>
10 | {% endblock %}
11 | 


--------------------------------------------------------------------------------
/archivy/templates/tags/show.html:
--------------------------------------------------------------------------------
 1 | {% extends "base.html" %}
 2 | 
 3 | {% block content %}
 4 | {% include "markdown-parser.html" %}
 5 | <h2>{{ tag_name }} ({{ search_result | length }})</h2>
 6 | <ul>
 7 |   {% for res in search_result %}
 8 |     <li><a href="/dataobj/{{ res['id'] }}">{{ res['title'] }}</li></a>
 9 |     {% if "matches" in res %}
10 |       {% for match in res["matches"] %}
11 |         <div class="markdown-result">{{ match }}</div>
12 |       {% endfor %}
13 |     {% endif %}
14 |   {% endfor %}
15 | </ul>
16 | <script>
17 |   document.querySelectorAll(".markdown-result").forEach((match) => {
18 |     match.innerHTML = window.parser.customRender(`> ${match.innerHTML.trim()}`)
19 |   })
20 | </script>
21 | 
22 | 
23 | {% endblock %}
24 | 


--------------------------------------------------------------------------------
/archivy/templates/users/edit.html:
--------------------------------------------------------------------------------
 1 | {% extends "base.html" %}
 2 | {% block content %}
 3 | <br>
 4 | <div id="user-cont">
 5 |     <form id="user-form" action="" method="post">
 6 |         <h1>Edit Profile</h1>
 7 |         {{ form.hidden_tag() }}
 8 |         {% for error in form.username.errors %}
 9 |             <div class="flash flash-error">{{ error }}</div>
10 |         {% endfor %}
11 |         {{ form.username(placeholder="Username") }}
12 | 
13 |         {% for error in form.password.errors %}
14 |             <span style="color: red;">{{ error }}</span>
15 |         {% endfor %}
16 |         {{ form.password(placeholder="New Password") }}
17 | 
18 |         {{ form.submit(class="btn") }}
19 | 
20 |     <a class="btn" href="/logout">
21 |       Logout
22 |     </a>
23 |     </form>
24 | </div>
25 | {% endblock %}
26 | 


--------------------------------------------------------------------------------
/archivy/templates/users/login.html:
--------------------------------------------------------------------------------
 1 | {% extends "base.html" %}
 2 | {% block content %}
 3 | <br>
 4 | <style>
 5 |   .content {
 6 |       margin: auto;
 7 |   }
 8 | </style>
 9 | <div id="login-cont">
10 |     <img src="https://archivy.github.io/img/logo-2.png" width="100" height="100">
11 |     <form id="login-form" action="" method="post">
12 |         <h1>Sign in</h1>
13 |         {{ form.hidden_tag() }}
14 |         {% for error in form.username.errors %}
15 |             <div class="flash flash-error">{{ error }}</div>
16 |         {% endfor %}
17 |         {{ form.username(placeholder="Username") }}
18 | 
19 | 
20 |         {% for error in form.password.errors %}
21 |             <span style="color: red;">{{ error }}</span>
22 |         {% endfor %}
23 |         {{ form.password(placeholder="Password") }}
24 | 
25 |         {{ form.submit(class="btn") }}
26 |     </form>
27 | </div>
28 | 
29 | {% endblock %}
30 | 


--------------------------------------------------------------------------------
/conftest.py:
--------------------------------------------------------------------------------
  1 | import shutil
  2 | import tempfile
  3 | from pathlib import Path
  4 | 
  5 | import click
  6 | import pytest
  7 | import responses
  8 | 
  9 | from archivy import app, cli
 10 | from archivy.click_web import create_click_web_app, _flask_app
 11 | from archivy.helpers import get_db, load_hooks
 12 | from archivy.models import DataObj, User
 13 | 
 14 | _app = None
 15 | 
 16 | 
 17 | @pytest.fixture
 18 | def test_app():
 19 |     """Instantiate the app for each test with its own temporary data directory
 20 | 
 21 |     Each test using this fixture will use its own db.json and its own data
 22 |     directory, and then delete them.
 23 |     """
 24 |     # create a temporary file to isolate the database for each test
 25 |     global _app
 26 |     if _app is None:
 27 |         _app = create_click_web_app(cli, cli.cli, app)
 28 |     app_dir = Path(tempfile.mkdtemp())
 29 |     _app.config["INTERNAL_DIR"] = str(app_dir)
 30 |     _app.config["USER_DIR"] = str(app_dir)
 31 |     (app_dir / "data").mkdir()
 32 |     (app_dir / "images").mkdir()
 33 | 
 34 |     _app.config["TESTING"] = True
 35 |     _app.config["WTF_CSRF_ENABLED"] = False
 36 |     _app.config["SCRAPING_CONF"]["save_images"] = False
 37 |     # This setups a TinyDB instance, using the `app_dir` temporary
 38 |     # directory defined above
 39 |     # Required so that `flask.current_app` can be called in data.py and
 40 |     # models.py
 41 |     # See https://flask.palletsprojects.com/en/1.1.x/appcontext/ for more
 42 |     # information.
 43 |     with _app.app_context():
 44 |         _ = get_db()
 45 |         user = {"username": "halcyon", "password": "password"}
 46 | 
 47 |         User(**user).insert()
 48 |         yield _app
 49 | 
 50 |     # close and remove the temporary database
 51 |     shutil.rmtree(app_dir)
 52 | 
 53 | 
 54 | @pytest.fixture
 55 | def client(test_app):
 56 |     """HTTP client for calling a test instance of the app"""
 57 |     with test_app.test_client() as client:
 58 |         client.post("/login", data={"username": "halcyon", "password": "password"})
 59 |         yield client
 60 | 
 61 | 
 62 | @pytest.fixture
 63 | def mocked_responses():
 64 |     """
 65 |     Setup mock responses using the `responses` python package.
 66 | 
 67 |     Using https://pypi.org/project/responses/, this fixture will mock out
 68 |     HTTP calls made by the requests library.
 69 | 
 70 |     For example,
 71 |     >>> mocked_responses.add(responses.GET, "http://example.org",
 72 |      json={'key': 'val'}
 73 |      )
 74 |     >>> r = requests.get("http://example.org")
 75 |     >>> print(r.json())
 76 |     {'key': 'val'}
 77 |     """
 78 |     with responses.RequestsMock() as rsps:
 79 |         # this ensure that all requests calls are mocked out
 80 |         rsps.assert_all_requests_are_fired = False
 81 |         yield rsps
 82 | 
 83 | 
 84 | @pytest.fixture
 85 | def note_fixture(test_app):
 86 |     note_dict = {
 87 |         "type": "note",
 88 |         "title": "Test Note",
 89 |         "tags": ["testing", "archivy"],
 90 |         "path": "",
 91 |     }
 92 | 
 93 |     with test_app.app_context():
 94 |         note = DataObj(**note_dict)
 95 |         note.insert()
 96 |     return note
 97 | 
 98 | 
 99 | @pytest.fixture
100 | def bookmark_fixture(test_app, mocked_responses):
101 |     mocked_responses.add(
102 |         responses.GET,
103 |         "https://example.com/",
104 |         body="""<html>
105 |         <head><title>Example</title></head><body><p>
106 |             Lorem ipsum dolor sit amet, consectetur adipiscing elit
107 |         <script>console.log("this should be sanitized")</script>
108 |         <img src="/images/image1.png">
109 |         <a href="/testing-absolute-url">link</a>
110 |         <a href"/empty-link"></a>
111 |          #embedded-tag# #tag2#
112 |         </p></body></html>
113 |     """,
114 |     )
115 | 
116 |     datapoints = {
117 |         "type": "bookmark",
118 |         "title": "Test Bookmark",
119 |         "tags": ["testing", "archivy"],
120 |         "path": "",
121 |         "url": "https://example.com/",
122 |     }
123 | 
124 |     with test_app.app_context():
125 |         bookmark = DataObj(**datapoints)
126 |         bookmark.process_bookmark_url()
127 |         bookmark.insert()
128 |     return bookmark
129 | 
130 | 
131 | @pytest.fixture
132 | def different_bookmark_fixture(test_app, mocked_responses):
133 |     mocked_responses.add(
134 |         responses.GET,
135 |         "https://example2.com/",
136 |         body="""<html>
137 |         <head><title>Example</title></head><body><p>asdsad<div class="nested">aaa</div></body></html>
138 |     """,
139 |     )
140 | 
141 |     datapoints = {
142 |         "type": "bookmark",
143 |         "title": "Test Bookmark2",
144 |         "url": "https://example2.com/",
145 |     }
146 | 
147 |     with test_app.app_context():
148 |         bookmark = DataObj(**datapoints)
149 |         bookmark.process_bookmark_url()
150 |         bookmark.insert()
151 |     return bookmark
152 | 
153 | 
154 | @pytest.fixture()
155 | def user_fixture(test_app):
156 |     user = {"username": "__username__", "password": "__password__"}
157 | 
158 |     user = User(**user)
159 |     user.insert()
160 |     return user
161 | 
162 | 
163 | @pytest.fixture()
164 | def pocket_fixture(test_app, mocked_responses):
165 |     """Sets up pocket key and mocked responses for testing pocket sync
166 | 
167 |     When using this fixture, all calls to https://getpocket.com/v3/get will
168 |     succeed and return a single article whose url is https://example.com.
169 |     """
170 |     with test_app.app_context():
171 |         db = get_db()
172 | 
173 |     mocked_responses.add(
174 |         responses.POST,
175 |         "https://getpocket.com/v3/oauth/authorize",
176 |         json={
177 |             "access_token": "5678defg-5678-defg-5678-defg56",
178 |             "username": "test_user",
179 |         },
180 |     )
181 | 
182 |     # fake /get response from pocket API
183 |     mocked_responses.add(
184 |         responses.POST,
185 |         "https://getpocket.com/v3/get",
186 |         json={
187 |             "status": 1,
188 |             "complete": 1,
189 |             "list": {
190 |                 "3088163616": {
191 |                     "given_url": "https://example.com",
192 |                     "status": "0",
193 |                     "resolved_url": "https://example.com",
194 |                     "excerpt": "Lorem ipsum",
195 |                     "is_article": "1",
196 |                 },
197 |             },
198 |         },
199 |     )
200 | 
201 |     pocket_key = {
202 |         "type": "pocket_key",
203 |         "consumer_key": "1234-abcd1234abcd1234abcd1234",
204 |         "code": "dcba4321-dcba-4321-dcba-4321dc",
205 |     }
206 |     db.insert(pocket_key)
207 |     return pocket_key
208 | 
209 | 
210 | @pytest.fixture()
211 | def click_cli():
212 |     yield cli.cli
213 | 
214 | 
215 | @pytest.fixture()
216 | def ctx(click_cli):
217 |     with click.Context(click_cli, info_name=click_cli, parent=None) as ctx:
218 |         yield ctx
219 | 
220 | 
221 | @pytest.fixture()
222 | def cli_runner():
223 |     yield click.testing.CliRunner()
224 | 


--------------------------------------------------------------------------------
/docs/CONTRIBUTING.md:
--------------------------------------------------------------------------------
 1 | 
 2 | This is a short guide on the things you should know if you'd like to contribute to Archivy.
 3 | 
 4 | ## Setting up a dev environment.
 5 | 
 6 | - Fork the [archivy repo](https://github.com/archivy/archivy) and then clone the fork on your local machine.
 7 | - Create a virtual environment by running `python -m venv venv/`. This will hold all archivy dependencies.
 8 | - Run `source venv/bin/activate` to activate this new environment.
 9 | - Run `pip install -r requirements.txt` to download all dependencies.
10 | 
11 | ## Running the dev server.
12 | ```
13 | # after sourcing the virtualenv
14 | 
15 | $ export FLASK_APP=archivy/__init__.py
16 | $ export FLASK_ENV=development
17 | $ flask run
18 | ```
19 | 
20 | ## Running cli commands
21 | ```
22 | # after sourcing the virtualenv
23 | 
24 | $ python -m archivy.cli --help
25 | ```
26 | 
27 | 
28 | If you'd like to work on an [existing issue](https://github.com/archivy/archivy/issues), please comment on the github thread for the issue to notify that you're working on it, and then create a new branch with a suitable name.
29 | 
30 | For example, if you'd like to work on something about "Improving the UI", you'd call it `improve_ui`. Once you're done with your changes, you can open a pull request and we'll review them.
31 | 
32 | **Do not** begin working on a new feature without first discussing it and opening an issue, as we might not agree with your vision.
33 | 
34 | If your feature is more isolated and specific, it can also be interesting to develop a [plugin](plugins.md) for it, in which case we can help you with any questions related to plugin development, and would be happy to list your plugin on [awesome-archivy](https://github.com/archivy/awesome-archivy).
35 | 
36 | Thanks for contributing!
37 | 


--------------------------------------------------------------------------------
/docs/config.md:
--------------------------------------------------------------------------------
  1 | Once you've [initialized](install.md) your archivy install, an archivy config is automatically generated. You can edit it through the archivy interface by clicking on the gear icon on the top right of the navbar, or by running `archivy config` in your terminal.
  2 | 
  3 | Here's an overview of the different values you can set and modify.
  4 | 
  5 | 
  6 | ### General
  7 | 
  8 | | Variable                | Default                     | Description                           |
  9 | |-------------------------|-----------------------------|---------------------------------------|
 10 | | `USER_DIR`      | System-dependent, see below. It is recommended to set this through `archivy init` | Directory in which markdown data will be saved |
 11 | | `INTERNAL_DIR`  | System-dependent, see below | Directory where archivy internals will be stored (config, db...)
 12 | | `PORT`          | 5000                        | Port on which archivy will run        |
 13 | | `HOST`          | 127.0.0.1                   | Host on which the app will run. |
 14 | | `DEFAULT_BOOKMARKS_DIR` | empty string (represents the root directory) | any subdirectory of the `data/` directory with your notes.
 15 | | `SITE_TITLE`    | Archivy                     | String value to be displayed in page title and headings. |
 16 | 
 17 | ### Scraping
 18 | 
 19 | An in-progress configuration object to customize how you'd like bookmarking / scraping to work. The options are children of the `SCRAPING_CONF` object, like so:
 20 | 
 21 | ```yaml
 22 | SCRAPING_CONF:
 23 |   save_images:
 24 |   ...
 25 | ```
 26 | 
 27 | | Variable                | Default                     | Description                           |
 28 | |-------------------------|-----------------------------|---------------------------------------|
 29 | | `save_images` | False | If true, whenever you save a bookmark, every linked image will also be downloaded locally. |
 30 | 
 31 | If you want to configure the scraping progress more, you can also create a `scraping.py` file in the root of your user directory. This file allows you to override the default bookmarking behavior for certain websites / links, which you can match with regex.
 32 | 
 33 | Once it matches a link, you can either pass your own custom function for parsing, or simply pass a string, which corresponds to the CSS selector for the part of the page you want archivy to scrape. If there are several matches, only the first will be treated.
 34 | 
 35 | Example that processes youtube videos:
 36 | 
 37 | ```python
 38 | def process_videos(data):
 39 | 	url = data.url
 40 | 	# modify whatever you want / set the metadata
 41 | 	data.title = "Video - " + url
 42 | 	data.tags = ["video"]
 43 | 	data.content = "..."
 44 | 
 45 | # declare your patterns in this PATTERNS variable
 46 | PATTERNS = {
 47 | 	"*youtube.com/*": process_videos
 48 | }
 49 | ```
 50 | 
 51 | With this example, whenever you create a bookmark of a youtube video, instead of going through the default archival, your function will be called on the data.
 52 | 
 53 | 
 54 | Example that tells archivy only to scrape the main body of Wikipedia pages:
 55 | 
 56 | ```python
 57 | PATTERNS = {
 58 | 	"https://*.wikipedia.org/wiki/*": "#bodyContent"
 59 | }
 60 | ```
 61 | 
 62 | Example patterns:
 63 | 
 64 | - `*wikipedia*` (`*` matches everything)
 65 | - `https://duckduckg?.com*` (? matches a single character)
 66 | - `https://www.[nl][ya]times.com` ([] matches any character inside the brackets. Here it'll match nytimes or latimes, for example. Use ![] to match any character **not** inside the brackets)
 67 | 
 68 | ### Theming
 69 | 
 70 | Configure the way your Archivy install looks.
 71 | 
 72 | These configuration options are children of the `THEME_CONF` object, like this:
 73 | 
 74 | ```yaml
 75 | THEME_CONF:
 76 |   use_theme_dark:
 77 |   use_custom_css:
 78 |   custom_css_file:
 79 | ```
 80 | 
 81 | | Variable | Default | Description |
 82 | |------|-------|----|
 83 | | `use_theme_dark` | false | Whether or not to load the dark version of the default theme CSS. |
 84 | | `use_custom_css` | false | Whether or not to load custom css from `custom_css_file` |
 85 | | `custom_css_file` | "" | Name of file to load in the `css/` subdirectory of your user directory (the one with your data or hooks). Create `css/` if it doesn't exist. |
 86 | 
 87 | 
 88 | 
 89 | ### Search
 90 | 
 91 | See [Setup Search](setup-search.md) for more information.
 92 | 
 93 | All of these options are children of the `SEARCH_CONF` object, like this in the `config.yml`:
 94 | 
 95 | ```yaml
 96 | SEARCH_CONF:
 97 |   enabled:
 98 |   url:
 99 |   # ...
100 | ```
101 | To use search, you first have to enable it either through the `archivy init` setup script or by modifying the `enabled` variable (see below).
102 | 
103 | Variables marked `ES only` in their description are only relevant when using the Elasticsearch engine.
104 | 
105 | | Variable                | Default                        | Description                           |
106 | |-------------------------|--------------------------------|---------------------------------------|
107 | | `enabled`               | 1                              |                                       |
108 | | `engine`                | empty string                   | search engine you'd like to use. One of `["ripgrep", ["elasticsearch"]`|
109 | | `url`                   | http://localhost:9200          | **[ES only]** Url to the elasticsearch server       |
110 | | `es_user` and `es_password` | None | If you're using authentication, for example with a cloud-hosted ES install, you can specify a user and password |
111 | | `es_processing_conf`           | Long dict of ES config options | **[ES only]** Configuration of Elasticsearch [analyzer](https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis.html), [mappings](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping.html) and general settings. |
112 | 
113 | 
114 | `INTERNAL_DIR` and `USER_DIR` by default will be set by the
115 | [appdirs](https://pypi.org/project/appdirs/) python library:
116 | 
117 | On Linux systems, it follows the [XDG
118 | specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html):
119 | `~/.local/share/archivy`
120 | 
121 | 
122 | ### Editor configuration
123 | 
124 | To enable auto save in the editor, set `EDITOR_CONF -> autosave` to True. 
125 | 
126 | Archivy uses the [markdown-it](https://github.com/markdown-it/markdown-it) parser for its editor. This parser can be configured to change the output according to your needs. The default values of `EDITOR_CONF` are given below. Refer to the [markdown-it docs](https://github.com/markdown-it/markdown-it#init-with-presets-and-options) for a full list of possible options.
127 | 
128 | ```yaml
129 | EDITOR_CONF:
130 |   autosave: False
131 |   settings:
132 |     linkify: true
133 |     html: false
134 |     xhtmlOut: false
135 |     breaks: true
136 |     typographer: false
137 |   plugins: ...
138 |   toolbar_icons: ["bold", "italic", "link", "upload-image", "heading", "code", "strikethrough", "quote", "table"] # see https://github.com/Ionaru/easy-markdown-editor#toolbar-icons for more options
139 | ```
140 | 
141 | Archivy uses several markdown plugins to enhance its functionality:
142 | 
143 | - [markdown-it-anchor](https://github.com/valeriangalliat/markdown-it-anchor)
144 | - [markdown-it-toc-done-right](https://github.com/nagaozen/markdown-it-toc-done-right)
145 | - [markdown-it-mark](https://github.com/markdown-it/markdown-it-mark)
146 | - [markdown-it-footnote](https://github.com/markdown-it/markdown-it-footnote)
147 | - [markdown-it-texmath](https://github.com/goessner/markdown-it-texmath)
148 | 
149 | Some of these plugins (see below) can be configured and modified. Refer to their homepages above to see what you can change. They are set up with the following configuration by default:
150 | 
151 | ```yaml
152 | EDITOR_CONF:
153 |   plugins:
154 |     markdownitFootnote: {}
155 |     markdownitMark: {}
156 |     markdownItAnchor:
157 |       permalink: True
158 |       permalinkSymbol: '¶'
159 |     markdownItTocDoneRight: {}
160 | ```
161 | 


--------------------------------------------------------------------------------
/docs/difference.md:
--------------------------------------------------------------------------------
 1 | 
 2 | There are many great tools out there to create your knowledge base. 
 3 | 
 4 | 
 5 | So why should you use Archivy? 
 6 | 
 7 | Here are the ingredients that make Archivy stand out (of course many tools have other interesting components / focuses, and you should pick the one that resonates with what you want):
 8 | 
 9 | - **Focus on scripting and extensibility**: When I began developing archivy, I had plans for developing **in the app's core** additional features that would allow you to sync up to your digital presence, for exemple a Reddit extension that would download your upvoted posts, etc... I quickly realised that this would be a bad way to go, as many people often want maybe **one** feature, but not a ton of useless included extensions. That's when I decided to instead build a flexible framework for people to build installable [**plugins**](plugins.md), as this allows a) users only download what they want and b) Archivy can focus on its core and users can build their own extensions for themselves and others.
10 | - **Importance of Digital Preservation**: ^ I mentioned above the idea of a plugin for saving all your upvoted reddit posts. This is just an example of how Archivy is intended to be used not only as a knowledge base, but also a resilient stronghold for the data that used to be solely held by third-party services. This idea of automatically syncing and saving content you've found valuable could be expanded to HN upvoted posts, browser bookmarks, etc... [^1]
11 | - **deployable OR you can just run it on your computer**: The way archivy was engineered makes it possible for you to just run it on your laptop or pc, and still use it without problems. On the other hand, it is also very much a possibility to self-host it and expose it publicly for use from anywhere, which is why archivy has auth.
12 | - Archivy supports several [options for its search](setup-search.md), including Elasticsearch. This tool might be considered overkill for one's knowledge base, but as your knowledge base grows also with content from other data sources and automation, it can become a large amount of data. Elasticsearch provides very high quality search on this information at a swift speed and the other alternative [ripgrep](https://github.com/BurntSushi/ripgrep) is much lighter while still reacting well to large amounts of data.
13 | 
14 | [^1]: https://beepb00p.xyz/hpi.html is an intriguing tool on this topic.
15 | 
16 | [^2]: See [this thread](https://github.com/archivy/archivy/issues/13) if you have any tools in mind for this.
17 | 


--------------------------------------------------------------------------------
/docs/editing.md:
--------------------------------------------------------------------------------
 1 | Note: to enable auto save in the editor, see [this](https://archivy.github.io/config/#editor-configuration).
 2 | 
 3 | ## Format
 4 | 
 5 | Archivy files are in the [markdown](https://daringfireball.net/projects/markdown/basics) format following the [commonmark spec](https://spec.commonmark.org/).
 6 | 
 7 | We've also included a few powerful extensions:
 8 | 
 9 | - **Bidirectional links**: You can easily link to a new note in the web editor by typing `[[`: an input box will appear where you can enter the title of the note you want to link to. Otherwise, links between notes are in the format `[[linked note title|linked note id]]`. If the title you wrote doesn't refer to an existing note, you can click enter and Archivy will create a new note.
10 | 
11 | - **Embedded tags**: You can directly add tags inside your notes with this `#tag#` syntax (see below). These tags and their groupings can then be viewed by clicking `Tags` on the navigation bar. Starting to type `#` in the web editor will display an input where you can search existing tags.
12 | 
13 | ```md
14 | I was going to a #python# conference, when I saw a #lion#.
15 | ```
16 | 
17 | - **In-editor bookmarking**: if you'd like to store a local copy of a webpage you're referring to inside an archivy note, simply select the url and click the "bookmark" icon.
18 | 
19 | - **LaTeX**: you can render mathematical expressions like this:
20 | 
21 | ```md
22 | $
23 | \pi = 3.14
24 | $
25 | ```
26 | 
27 | - **Footnotes**:
28 | 	```md
29 | 	What does this describe? [^1]
30 | 
31 | 	[^1]: test foot note.
32 | 	```
33 | 
34 | - **Tables**:
35 | 	```md
36 | 	| Column 1 | Column 2 |
37 | 	| -------- | -------- |
38 | 	| ...      | ...      |
39 | 	```
40 | 
41 | - **Code blocks with syntax highlighting**:
42 | 	````md
43 | 	```python
44 | 	print("this will be highlighted")
45 | 	x = 1337
46 | 	```
47 | 	````
48 | 
49 | There are several ways you can edit content in Archivy.
50 | 
51 | Whenever you open a note or bookmark, at the bottom of the page you'll find a few buttons that allow you to edit it.
52 | 
53 | ## Editing through the web interface
54 | 
55 | You can edit through the web app, by clicking "Toggle web editor" at the bottom. This is the recommended way because the Archivy web editor provides useful functionality. You can save your work, link to other notes with the "Link to a note button", and archive webpages referenced in your note, all inside the editor!
56 | 
57 | ## Locally
58 | 
59 | You can do a **local edit**. This option is only viable if running archivy on your own computer. This will open the concerned file with the default app set to edit markdown.
60 | 
61 | For example like this:
62 | 
63 | ![ex of local editing with marktext](img/local-edit.png)
64 | 


--------------------------------------------------------------------------------
/docs/example-plugin/README.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/archivy/archivy/bdcdd39ac6cf9f7b3709b984d8be2f0fa898139e/docs/example-plugin/README.md


--------------------------------------------------------------------------------
/docs/example-plugin/archivy_extra_metadata/__init__.py:
--------------------------------------------------------------------------------
 1 | import click
 2 | from tinydb import Query
 3 | from archivy.helpers import get_db
 4 | from archivy import app
 5 | 
 6 | 
 7 | @click.group()
 8 | def extra_metadata():
 9 |     """`archivy_extra_metadata` plugin to add metadata to your notes."""
10 |     pass
11 | 
12 | 
13 | @extra_metadata.command()
14 | @click.option("--author", required=True)
15 | @click.option("--location", required=True)
16 | def setup(author, location):
17 |     """Save metadata values."""
18 |     with app.app_context():
19 |         # save data in db
20 |         get_db().insert({"type": "metadata", "author": author, "location": location})
21 |     click.echo("Metadata saved!")
22 | 
23 | 
24 | def add_metadata(dataobj):
25 |     with app.app_context():
26 |         metadata = get_db().search(Query().type == "metadata")[0]
27 |         dataobj.content += f"Made by {metadata['author']} in {metadata['location']}."
28 | 


--------------------------------------------------------------------------------
/docs/example-plugin/setup.py:
--------------------------------------------------------------------------------
 1 | from setuptools import setup, find_packages
 2 | 
 3 | with open("README.md", "r") as fh:
 4 |     long_description = fh.read()
 5 | 
 6 | setup(
 7 |     name="archivy_extra_metadata",
 8 |     version="0.1.0",
 9 |     author="Uzay-G",
10 |     description=(
11 |         "Archivy extension to add some metadata at the end of your notes / bookmarks."
12 |     ),
13 |     long_description=long_description,
14 |     long_description_content_type="text/markdown",
15 |     classifiers=[
16 |         "Programming Language :: Python :: 3",
17 |     ],
18 |     packages=find_packages(),
19 |     entry_points="""
20 |         [archivy.plugins]
21 |         extra-metadata=archivy_extra_metadata:extra_metadata
22 |     """,
23 | )
24 | 


--------------------------------------------------------------------------------
/docs/img/local-edit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/archivy/archivy/bdcdd39ac6cf9f7b3709b984d8be2f0fa898139e/docs/img/local-edit.png


--------------------------------------------------------------------------------
/docs/img/logo-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/archivy/archivy/bdcdd39ac6cf9f7b3709b984d8be2f0fa898139e/docs/img/logo-2.png


--------------------------------------------------------------------------------
/docs/img/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/archivy/archivy/bdcdd39ac6cf9f7b3709b984d8be2f0fa898139e/docs/img/logo.png


--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
 1 | ![logo](img/logo.png)
 2 | 
 3 | # Archivy
 4 | 
 5 | Archivy is a self-hostable knowledge repository that allows you to learn and retain information in your own personal and extensible wiki.
 6 | 
 7 | Features:
 8 | 
 9 | - If you add bookmarks, their web-pages contents' will be saved to ensure that you will **always** have access to it, following the idea of [digital preservation](https://jeffhuang.com/designed_to_last/). Archivy is also easily integrated with other services and your online accounts.
10 | - Knowledge base organization with bidirectional links between notes, and embedded tags.
11 | - Everything is a file! For ease of access and editing, all the content is stored in extended markdown files with yaml front matter. This format supports footnotes, LaTeX math rendering, syntax highlighting and more. 
12 | - Extensible plugin system and API for power users to take control of their knowledge process
13 | - [syncing options](https://github.com/archivy/archivy-git)
14 | - Powerful and advanced search. 
15 | - Image upload
16 | 
17 | 
18 | <video src="https://www.uzpg.me/assets/images/archivy.mov" style="width: 100%" controls>
19 | </video>
20 | 
21 | [Roadmap](https://github.com/archivy/archivy/issues/74#issuecomment-764828063)
22 | 
23 | Upcoming:
24 | 
25 | - Annotations
26 | - Multi User System with permission setup.
27 | 
28 | ## Quickstart
29 | 
30 | 
31 | Install archivy with `pip install archivy`. Other installations methods are listed [here](https://archivy.github.io/install)
32 | 
33 | Then run this and enter a password to create a new user:
34 | 
35 | ```bash
36 | $ archivy create-admin <username>
37 | ```
38 | 
39 | Finally, execute `archivy run` to serve the app. You can open it at https://localhost:5000 and login with the credentials you entered before.
40 | 
41 | You can then use archivy to create notes, bookmarks and then organize and store information.
42 | 
43 | See [the docs](install.md) for information on other installation methods.
44 | 
45 | ## Community
46 | 
47 | Archivy is dedicated at building **open and quality knowledge base software** through collaboration and community discussion.
48 | 
49 | To get news and updates on Archivy and its development, you can [watch the archivy repository](https://github.com/archivy/archivy) or follow [@uzpg_ on Twitter](https://twitter.com/uzpg_).
50 | 
51 | You can interact with us through the [issue board](https://github.com/archivy/archivy/issues) and the more casual [discord server](https://discord.gg/uQsqyxB).
52 | 
53 | If you'd like to support the project and its development, you can also [sponsor](https://github.com/sponsors/Uzay-G/) the Archivy maintainer.
54 | 
55 | 
56 | Note: If you're interested in the applications of AI to knowledge management, we're also working on this with [Espial](https://github.com/Uzay-G/espial).
57 | 
58 | ## License
59 | 
60 | This project is licensed under the MIT License. See [LICENSE](./LICENSE) for more information.
61 | The Archivy Logo is designed by [Roy Quilor](https://www.quilor.com/), licensed under [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0)
62 | 
63 | [Changelog](https://github.com/archivy/archivy/releases)
64 | 
65 | 
66 | 


--------------------------------------------------------------------------------
/docs/install.md:
--------------------------------------------------------------------------------
 1 | ## With pip
 2 | 
 3 | You can easily install archivy with `pip`. (pip  is the default package installer for Python, you can use pip to install many packages and apps, see this [link](https://pypi.org/project/pip/) for more information if needed)
 4 | 
 5 | 
 6 | 1. Make sure your system has Python and pip installed. The Python programming language can also be downloaded from [here](https://www.python.org/downloads/).
 7 | 2. Install the python package with `pip install archivy`
 8 | 3. It's highly recommended to install [ripgrep](https://github.com/BurntSushi/ripgrep), which Archivy uses for some of it's organization features (note links & tags inside notes).
 9 | 3. If you'd like to use search, follow [these docs](setup-search.md) first and then do this part. Run `archivy init` to create a new user and use the setup wizard.
10 | 4. There you go! You should be able to start the app by running `archivy run` in your terminal and then just login.
11 | 
12 | ## With docker
13 | 
14 | You can also use archivy with Docker. 
15 | 
16 | See [the docker documentation](https://github.com/archivy/archivy-docker) for instructions on this.
17 | 
18 | We might implement an AppImage install. Comment [here](https://github.com/archivy/archivy/issues/44) if you'd like to see that happen.
19 | 
20 | ## With Nix
21 | ```ShellSession
22 | $ nix-env -i archivy
23 | ```
24 | 


--------------------------------------------------------------------------------
/docs/overrides/main.html:
--------------------------------------------------------------------------------
1 | 
2 | {% extends "base.html" %}
3 | 
4 | {% block analytics %}
5 |   <!-- privacy respectful analytics -->
6 |   <script async defer src="https://scripts.simpleanalyticscdn.com/latest.js"></script>
7 |   <noscript><img src="https://queue.simpleanalyticscdn.com/noscript.gif" alt=""/></noscript>
8 | {% endblock %}
9 | 


--------------------------------------------------------------------------------
/docs/reference/architecture.md:
--------------------------------------------------------------------------------
 1 | 
 2 | This document is a general overview of how the different pieces of archivy interact and what technologies it uses. Reading this will be useful for people looking to access the inner archivy API to write plugins. Read [this post](https://www.uzpg.me/tech/2020/07/21/architecture-md.html) to understand what the function of an `architecture.md` file is.
 3 | 
 4 | 
 5 | Archivy is:
 6 | 
 7 | - A [Flask](https://flask.palletsprojects.com/) web application.
 8 | - A [click](https://click.palletsprojects.com/) backend command line interface.
 9 | 
10 | 
11 | You use the cli to run the app, and you'll probably be using the web application for direct usage of archivy.
12 | 
13 | 
14 | ## Data Storage
15 | 
16 | - `DataObjs` is the term used to denote a note or bookmark that is stored in your knowledge base (abbreviation for Data Object). These are stored in a directory on your filesystem of which you can [configure the location](../config.md). They are organized in markdown files with `yaml` front matter like this:
17 | 
18 | ```yaml
19 | ---
20 | date: 08-31-20
21 | desc: ''
22 | id: 100
23 | path: ''
24 | tags: []
25 | title: ...
26 | type: note
27 | ---
28 | 
29 | ...
30 | ```
31 | 
32 | Archivy uses the [python-frontmatter](https://python-frontmatter.readthedocs.io/en/latest/) package to handle the parsing of these files. They can be organized into user-specified sub-directories. Check out [the reference](filesystem_layer.md) to see the methods archivy uses for this.
33 | 
34 | - Another storage method Archivy uses is [TinyDB](https://tinydb.readthedocs.io/en/stable/). This is a small, simple document-oriented database archivy gives you access to for persistent data you might want to store in archivy plugins. Use [`helpers.get_db`](/reference/helpers/#archivy.helpers.get_db) to call the database.
35 | 
36 | ## Search
37 | Archivy supports two search engines:
38 | 
39 | 1. [Elasticsearch](https://www.elastic.co/) - an incredibly powerful solution that is however harder to install.
40 | 2. [ripgrep](https://github.com/BurntSushi/ripgrep), much more lightweight but also less powerful.
41 | 
42 | These allow archivy to to index and provide full-text search on their knowledge bases. 
43 | 
44 | 
45 | Elasticsearch requires configuration to have higher quality search results. You can check out the top-notch config archivy already uses by default [here](https://github.com/archivy/archivy/blob/master/archivy/config.py).
46 | 
47 | Check out the [helper methods](search.md) archivy exposes for ES.
48 | 
49 | ## Auth
50 | 
51 | Archivy uses [flask-login](https://flask-login.readthedocs.io/en/latest/) for auth. All endpoints require to be authenticated. You can create an admin user with the `create-admin` command.
52 | 
53 | In our roadmap we plan to extend our permission framework to have a multi-user system, and define configuration for the permissions of non-logged in users. In general we want to make things more flexible on the auth side.
54 | 
55 | 
56 | ## How bookmarks work
57 | 
58 | One of the core features of archivy is being able to save webpages locally. The way this works is the conversion of the html of the page you specify to a simple, markdown file.
59 | 
60 | We might want to extend this to also be able to save PDF, EPUB and other formats. You can find the reference for this part [here](models.md).
61 | 
62 | Further down the road, it'd be nice to add background processing and not only download the webpage, but also save the essential assets it loads for a more complete process. This feature of preserving web content aligns with the mission against [link rot](https://en.wikipedia.org/wiki/Link_rot) [^1].
63 | 
64 | ## Plugins
65 | 
66 | Plugins in archivy function as standalone python packages. The phenomenal [click-plugins](https://github.com/click-contrib/click-plugins) package allows us to do this by basically adding commands to the cli. 
67 | 
68 | So you create a python package where you specify commands to extend your pre-existing archivy cli. Then these added commands will be able to be used through the cli. But what makes plugins interesting is that you can actually also use the plugins through the web interface, without having access to the system running archivy. We use an adaptation of the [click-web](https://github.com/fredrik-corneliusson/click-web) to convert your cli commands to interactive web forms.
69 | 
70 | 
71 | [^1]: See [this manifesto](https://jeffhuang.com/designed_to_last/) to learn more about this phenomenon.
72 | 


--------------------------------------------------------------------------------
/docs/reference/filesystem_layer.md:
--------------------------------------------------------------------------------
1 | This module holds the methods used to access, modify, and delete components of the filesystem where `Dataobjs` are stored in Archivy.
2 | 
3 | ::: archivy.data
4 | 


--------------------------------------------------------------------------------
/docs/reference/helpers.md:
--------------------------------------------------------------------------------
1 | 
2 | This is a series of helper functions that could be useful for you.
3 | 
4 | Notably, the `get_db` and `get_elastic_client` could help with writing an archivy plugin.
5 | 
6 | ::: archivy.helpers
7 | 


--------------------------------------------------------------------------------
/docs/reference/hooks.md:
--------------------------------------------------------------------------------
1 | 
2 | ::: archivy.config.BaseHooks
3 | 


--------------------------------------------------------------------------------
/docs/reference/models.md:
--------------------------------------------------------------------------------
1 | Internal API for the models Archivy uses in the backend that could be useful for writing plugins.
2 | 
3 | ::: archivy.models
4 |     selection:
5 |       filters:
6 |         - "!__"
7 | 


--------------------------------------------------------------------------------
/docs/reference/search.md:
--------------------------------------------------------------------------------
1 | 
2 | These are a few methods to interface between archivy and the elasticsearch instance.
3 | 
4 | ::: archivy.search
5 | 


--------------------------------------------------------------------------------
/docs/reference/web_api.md:
--------------------------------------------------------------------------------
 1 | 
 2 | The Archivy HTTP API allows you to run small scripts in any language that will interact with your archivy instance through HTTP.
 3 | 
 4 | All calls must be first logged in with the [login](/reference/web_api/#archivy.api.login) endpoint.
 5 | 
 6 | ## Small example in Python
 7 | 
 8 | This code uses the `requests` module to interact with the API:
 9 | 
10 | ```python
11 | import requests
12 | # we create a new session that will allow us to login once
13 | s = requests.session()
14 | 
15 | INSTANCE_URL = <your instance url>
16 | s.post(f"{INSTANCE_URL}/api/login", auth=(<username>, <password>))
17 | 
18 | # once you've logged in - you can make authenticated requests to the api, like:
19 | resp = s.get(f"{INSTANCE_URL}/api/dataobjs").content)
20 | ```
21 | 
22 | 
23 | ## Reference
24 | 
25 | ::: archivy.api
26 | 


--------------------------------------------------------------------------------
/docs/reference/web_inputs.md:
--------------------------------------------------------------------------------
 1 | When developing plugins, you may want to use custom HTML input types on the frontend, like `email` or `password`.
 2 | 
 3 | Archivy currently allows you use these two types in your click options.
 4 | 
 5 | For example:
 6 | 
 7 | ```python
 8 | 
 9 | from archivy.click_web.web_click_types import EMAIL_TYPE, PASSWORD_TYPE
10 | @cli.command()
11 | @click.option("--the_email", type=EMAIL_TYPE) # this will validate the email format on the frontend and backend
12 | @click.option("--password", type=PASSWORD_TYPE) # type='password' on the HTML frontend.
13 | def login(the_email, password):
14 | 	...
15 | ```
16 | 


--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | mkdocs-material
2 | mkdocstrings[python-legacy]
3 | mkdocs==1.2.4
4 | pymdown-extensions
5 | 


--------------------------------------------------------------------------------
/docs/setup-search.md:
--------------------------------------------------------------------------------
 1 | Archivy supports two search engines:
 2 | 
 3 | 1. [Elasticsearch](https://www.elastic.co/) - an incredibly powerful solution that is however harder to install.
 4 | 2. [ripgrep](https://github.com/BurntSushi/ripgrep), much more lightweight but also less powerful.
 5 | 
 6 | These allow archivy to index and provide full-text search on their knowledge bases. 
 7 | 
 8 | You can select these in the `archivy init` script, and the [the config docs](config.md) also has more information on this.
 9 | 
10 | ## Elasticsearch
11 | 
12 | Elasticsearch, is a complex and extendable search engine that returns high-quality results.
13 | 
14 | Instructions to install and run the service which needs to be running when you use Elasticsearch can be found [here](https://www.elastic.co/guide/en/elasticsearch/reference/current/install-elasticsearch.html).
15 | 
16 | 
17 | Append these two lines to your [elasticsearch.yml config file](https://www.elastic.co/guide/en/elasticsearch/reference/current/settings.html):
18 | 
19 | ```yaml
20 | http.cors.enabled: true
21 | http.cors.allow-origin: "http://localhost:5000"
22 | ```
23 | 
24 | Then, when you run `archivy init` simply specify you have enabled ES to integrate it with archivy.
25 | 
26 | You will now have full-text search on your knowledge base!
27 | 
28 | If you're adding ES to an existing knowledge base, use `archivy index` to sync any changes.
29 | 
30 | Elasticsearch can be a hefty dependency, so if you have any ideas for something more light-weight that could be used as an alternative, please share on [this thread](https://github.com/archivy/archivy/issues/13).
31 | 
32 | ## Ripgrep
33 | 
34 | [Ripgrep](https://github.com/BurntSushi/ripgrep), on the other hand, is much more lightweight and small, but is also a bit limited in its functionality.
35 | 
36 | Follow [these instructions](https://github.com/BurntSushi/ripgrep#installation) to install ripgrep.
37 | 
38 | Then simply specify you want to use it during the `archivy init` script (or edit the config to add it).
39 | 
40 | 


--------------------------------------------------------------------------------
/docs/usage.md:
--------------------------------------------------------------------------------
 1 | Archivy comes with a simple command line interface that you use on the backend to run archivy:
 2 | 
 3 | ```
 4 | Usage: archivy [OPTIONS] COMMAND [ARGS]...
 5 | 
 6 | Options:
 7 |   --version  Show the flask version
 8 |   --help     Show this message and exit.
 9 | 
10 | Commands:
11 |   config        Open archivy config.
12 |   create-admin  Creates a new admin user
13 |   format        Format normal markdown files for archivy.
14 |   index         Sync content to Elasticsearch
15 |   init          Initialise your archivy application
16 |   run           Runs archivy web application
17 |   shell         Run a shell in the app context.
18 |   unformat      Convert archivy-formatted files back to normal markdown.
19 | ```
20 | 
21 | Make sure you've configured Archivy by running `archivy init`, as outlined in [install](install.md).
22 | 
23 | If you'd like to add users, you can simply create new admin users with the `create-admin` command. Only give credentials to trusted people.
24 | 
25 | If you have normal md files you'd like to migrate to archivy, move your files into your archivy data directory and then run `archivy format <filenames>` to make them conform to [archivy's formatting](/reference/architecture/#data-storage). Run `archivy unformat` to convert the other way around.
26 | 
27 | You can sync changes to files to the Elasticsearch index by running `archivy index` or by simply using the web editor which updates ES when you push a change.
28 | 
29 | The `config` command allows you to play around with [configuration](config.md) and use `shell` if you'd like to play around with the archivy python API.
30 | 
31 | You can then use archivy to create notes, bookmarks and to organize and store information.
32 | 
33 | The [web api](reference/web_api.md) is also useful to extend archivy, or [plugins](plugins.md).
34 | 
35 | These have been recently introduced, but you can check the existing plugins that you can install onto your instance [here](https://github.com/archivy/awesome-archivy).
36 | 


--------------------------------------------------------------------------------
/docs/whats_next.md:
--------------------------------------------------------------------------------
 1 | 
 2 | Now that you have a working installation and you know how to use, where can you go from here?
 3 | 
 4 | If you'd like to write a plugin for archivy with standalone functionality you'd like to see, check out the [plugin tutorial](plugins.md). You can also use our [Web API](reference/web_api.md)!
 5 | 
 6 | If you notice **any** bugs or problems, or if you have any features you'd like to see, **please** open up an issue on [GitHub](https://github.com/archivy/archivy).
 7 | 
 8 | You can also directly talk to us on [the archivy discord server](https://discord.gg/uQsqyxB)!
 9 | 
10 | 


--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
 1 | site_name: Archivy
 2 | theme:
 3 |   name: material
 4 |   logo: img/logo-2.png
 5 |   favicon: img/logo-2.png
 6 |   features:
 7 |     - navigation.tabs
 8 |   custom_dir: docs/overrides
 9 | 
10 | 
11 | plugins:
12 |   - mkdocstrings
13 |   - search
14 | 
15 | 
16 | markdown_extensions:
17 |   - pymdownx.highlight
18 |   - pymdownx.superfences
19 |   - pymdownx.tasklist:
20 |       clickable_checkbox: true
21 |   - footnotes
22 | 
23 | nav:
24 |   - Home: 
25 |     - "index.md"
26 |     - What makes Archivy different: "difference.md"
27 |     - Architecture: "reference/architecture.md"
28 |     - Contributing: "CONTRIBUTING.md"
29 |   - Getting Started:
30 |       - Installing Archivy: "install.md"
31 |       - Usage: "usage.md"
32 |       - Search: "setup-search.md"
33 |       - Config: "config.md"
34 |       - Editing: "editing.md"
35 |       - What's Next: "whats_next.md"
36 |   - Plugins:
37 |       - Index: "plugins.md"
38 |   - API Reference:
39 |     - Architecture: "reference/architecture.md"
40 |     - Web API: "reference/web_api.md"
41 |     - Models for User and DataObj: "reference/models.md"
42 |     - Dataobj Filesystem Layer: "reference/filesystem_layer.md"
43 |     - Helpers: "reference/helpers.md"
44 |     - Hooks: "reference/hooks.md"
45 |     - Search: "reference/search.md" 
46 |     - Web Inputs For Plugins: "reference/web_inputs.md"
47 | 
48 | repo_url: https://github.com/archivy/archivy
49 | repo_name: archivy
50 | 


--------------------------------------------------------------------------------
/plugins.md:
--------------------------------------------------------------------------------
  1 | # Plugins
  2 | 
  3 | Plugins are a newly introduced method to add extensions to the archivy cli and web interface. It relies on the extremely useful [click-plugins](https://github.com/click-contrib/click-plugins) package that is loaded through pip and the [click-web](https://github.com/click-contrib/click-plugins) which has been modified and whose can be found in `archivy/click_web/`, some of the tests, templates and static files.
  4 | 
  5 | 
  6 | To help you understand the way the plugin system works, we're going to build our own plugin that allows users to sync content from `pocket`. We'll even deploy it to Pypi so that other people can install it.
  7 | 
  8 | 
  9 | Prerequisites: A python and pip installation with archivy.
 10 | 
 11 | ## Step 1: Defining what our archivy extension does
 12 | 
 13 | `archivy` source used to have a built-in feature that allowed you to sync up to the bookmarks of your pocket account. We removed this and prefer to replace it with a standalone plugin, so let's build it!
 14 | 
 15 | What it will do is allow a user to download their bookmarks from pocket, without redownloading content that already exists.
 16 | 
 17 | ## Step 2: Setting up the project
 18 | 
 19 | Make a new directory named `archivy_pocket` wherever you want on your system and create a new [`setup.py`](https://stackoverflow.com/questions/1471994/what-is-setup-py) file that will define the characteristics of our package.
 20 | 
 21 | This is what it looks like:
 22 | 
 23 | ```python
 24 | 
 25 | from setuptools import setup, find_packages
 26 | 
 27 | with open("README.md", "r") as fh:
 28 |     long_description = fh.read()
 29 | 
 30 | setup(
 31 |     name='archivy_pocket',
 32 |     version='0.1.0',
 33 |     author="Uzay-G",
 34 |     author_email="halcyon@disroot.org",
 35 |     description=(
 36 |         "Archivy extension to sync content to your pocket account."
 37 |     ),
 38 |     long_description=long_description,
 39 |     long_description_content_type="text/markdown",
 40 |     classifiers=[
 41 |         "Programming Language :: Python :: 3",
 42 |     ],
 43 |     packages=find_packages(),
 44 |     entry_points='''
 45 |         [archivy.plugins]
 46 |         pocket=archivy_pocket:pocket
 47 |     '''
 48 | )
 49 | ```
 50 | 
 51 | Let's walk through what this is doing. We are setting up our package and we define a few characteristics of the package. We specify some metadata you can adapt to your own package. We then load our package by using the `find_packages` function. The `entry_points` part is the most important. The `[archivy.plugins]` tells archivy that this package will extend our CLI and then we define the command we want to add. In our case, people will call the extension using `archivy pocket`. We will actually be creating a group so that people will call subcommands like this: `archivy pocket <subcommand>`. You can do things either way.
 52 | 
 53 | Create a `README.md` file that you can keep empty for now but should haCve information on your project.
 54 | 
 55 | ## Step 3: Writing the core code of our plugin
 56 | 
 57 | Create an `archivy_web` directory inside the current directory where `setup.py` is stored. Create an `__init__.py` file in that directory where we'll store our main project code. For larger projects, it's better to separate concerns but that'll be good for now.
 58 | 
 59 | This is what the skeleton of our code looks will look like.
 60 | 
 61 | ```python
 62 | import click # the package that manages the cli
 63 | 
 64 | @click.group()
 65 | def pocket():
 66 |     pass
 67 | 
 68 | @pocket.command()
 69 | def command1():
 70 | 	...
 71 | 
 72 | @pocket.command()
 73 | def command2():
 74 | 	...
 75 | ```
 76 | 
 77 | With this structure, you'll be able to call `archivy pocket command1` and `archivy pocket command2`. Read the [click docs](https://click.palletsprojects.com/en/7.x/options/) to learn about how to build more intricate
 78 | 
 79 | Let's get into actually writing a command that interacts with the archivy codebase.
 80 | 
 81 | The code below does a few things:
 82 | 
 83 | - It imports the archivy `app` that basically is the interface for the webserver and many essential Flask features (flask is the web framework archivy uses).
 84 | - It imports the `get_db` function that allows us to access and modify the db.
 85 | - We define our pocket group.
 86 | - We create a new command from that group, what's important here is the `with app.app_context` part. We need to run our code inside the archivy `app_context` to be able to call some of the archivy methods. If you call archivy methods in your plugins, it might fail if you don't include this part.
 87 | - Then we just have our command code.
 88 | 
 89 | 
 90 | ```python
 91 | import click
 92 | from archivy.extensions import get_db
 93 | from archivy import app
 94 | from archivy.data import get_items
 95 | 
 96 | @click.group()
 97 | def pocket():
 98 |     pass
 99 | 
100 | @pocket.command()
101 | @click.argument("api_key")
102 | def auth(api_key):
103 |     with app.app_context():
104 |         db = get_db()
105 | 		# ...
106 | ```
107 | 
108 | 
109 | We also added some other commands, but we'll skip them for brevity and you can check out the source code [here](https://github.com/archivy/archivy_pocket).
110 | 
111 | Now you just need to do `pip install .` in the main directory and you'll have access to the commands. Check it out by running `archivy --help`.
112 | 
113 | ## Step 4: Publishing our package to Pypi
114 | 
115 | [Pypi](https://pypi.org) is the Python package repository. Publishing our package to it will allow other users to easily install our code onto their own archivy instance.
116 | 
117 | This is a short overview. Check out [this website](https://packaging.python.org/) for more info.
118 | 
119 | This section is inspired by [this](https://packaging.python.org/tutorials/packaging-projects/#installing-your-newly-uploaded-package).
120 | 
121 | 
122 | Make sure the required utilities are installed:
123 | 
124 | ```python
125 | python3 -m pip install --user --upgrade setuptools wheel
126 | ```
127 | 
128 | Now run this command in the main dir to build the source:
129 | 
130 | ```python
131 | python3 setup.py sdist bdist_wheel
132 | ```
133 | 
134 | Now you need to create an account on [Pypi](https://pypi.org). Then go [here](https://pypi.org/manage/account/#api-tokens) and create a new API token; don’t limit its scope to a particular project, since you are creating a new project.
135 | 
136 | 
137 | Once you've saved your token, install `twine`, the program that will take care of the upload:
138 | 
139 | ```python
140 | python3 -m pip install --user --upgrade twine
141 | ```
142 | 
143 | And you can finally upload your code! The username you should enter is `__token__` and then the password is your API token.
144 | 
145 | ```python
146 | python3 -m twine upload dist/*
147 | ```
148 | 
149 | 


--------------------------------------------------------------------------------
/requirements-tests.txt:
--------------------------------------------------------------------------------
1 | -r requirements.txt
2 | 
3 | pytest==6.0.2
4 | pytest-cov
5 | # for mocking out requests HTTP calls
6 | responses==0.12.0
7 | 


--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
 1 | # Automatically generated by https://github.com/damnever/pigar.
 2 | 
 3 | # archivy/__init__.py: 7
 4 | # archivy/check_changes.py: 5
 5 | # archivy/data.py: 9
 6 | # archivy/extensions.py: 6
 7 | # archivy/models.py: 13
 8 | # archivy/routes.py: 7
 9 | Flask == 2.3.2
10 | werkzeug == 2.3.3
11 | jinja2 == 3.1.2
12 | 
13 | # archivy/forms.py: 2
14 | Flask_WTF == 1.1.1
15 | 
16 | # archivy/forms.py: 4
17 | WTForms == 2.3.1
18 | 
19 | # archivy/config.py: 2
20 | appdirs == 1.4.4
21 | 
22 | # archivy/models.py: 10
23 | attrs == 20.2.0
24 | 
25 | # archivy/models.py: 11
26 | beautifulsoup4 >= 4.8.2
27 | 
28 | 
29 | # archivy/__init__.py: 6
30 | # archivy/extensions.py: 4,5
31 | elasticsearch == 7.7.1
32 | 
33 | 
34 | # archivy/run.py: 1
35 | python_dotenv == 0.13.0
36 | 
37 | # archivy/data.py: 8
38 | # archivy/models.py: 5
39 | 
40 | python_frontmatter == 0.5.0
41 | 
42 | # archivy/models.py: 7
43 | # archivy/routes.py: 4
44 | requests == 2.30.0
45 | 
46 | # archivy/extensions.py: 7
47 | # archivy/routes.py: 5
48 | tinydb == 4.1.1
49 | 
50 | # archivy/models.py: 8
51 | validators == 0.15.0
52 | 
53 | # archivy/__init__.py: 9
54 | # archivy/models.py: 13
55 | # archivy/routes.py: 10
56 | flask-login == 0.6.2
57 | 
58 | click_plugins
59 | html2text
60 | flask_compress
61 | readability-lxml
62 | 
63 | 


--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
 1 | import setuptools
 2 | 
 3 | with open("README.md", "r") as fh:
 4 |     long_description = fh.read()
 5 | 
 6 | with open("requirements.txt", encoding="utf-8") as f:
 7 |     all_reqs = f.read().split("\n")
 8 |     install_requires = [
 9 |         x.strip()
10 |         for x in all_reqs
11 |         if not x.startswith("#") and not x.startswith("-e git")
12 |     ]
13 | 
14 | setuptools.setup(
15 |     name="archivy",
16 |     version="1.7.7",
17 |     author="Uzay-G",
18 |     author_email="halcyon@disroot.org",
19 |     description=(
20 |         "Minimalist knowledge base focused on digital preservation"
21 |         " and building your second brain."
22 |     ),
23 |     long_description=long_description,
24 |     long_description_content_type="text/markdown",
25 |     url="https://github.com/archivy/archivy",
26 |     packages=setuptools.find_packages(),
27 |     classifiers=[
28 |         "Programming Language :: Python :: 3",
29 |         "License :: OSI Approved :: MIT License",
30 |     ],
31 |     entry_points={
32 |         "console_scripts": [
33 |             "archivy = archivy.cli:cli",
34 |         ]
35 |     },
36 |     include_package_data=True,
37 |     zip_safe=False,
38 |     install_requires=install_requires,
39 |     python_requires=">=3.6",
40 | )
41 | 


--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/archivy/archivy/bdcdd39ac6cf9f7b3709b984d8be2f0fa898139e/tests/__init__.py


--------------------------------------------------------------------------------
/tests/functional/test_plugins.py:
--------------------------------------------------------------------------------
 1 | import re
 2 | 
 3 | from flask.testing import FlaskClient
 4 | from flask import request
 5 | from flask_login import current_user
 6 | 
 7 | from responses import RequestsMock, GET
 8 | from archivy.helpers import get_max_id, get_db
 9 | 
10 | 
11 | def test_plugin_index(test_app, client: FlaskClient):
12 |     resp = client.get("/plugins")
13 |     assert resp.status_code == 200
14 |     assert b"Plugins" in resp.data
15 | 
16 |     # random number is one of the seeded plugins we added for testing
17 |     assert b"random-number" in resp.data
18 | 
19 | 
20 | def test_get_command_form(test_app, client: FlaskClient):
21 |     cmd_path = "/cli/test-plugin/random-number"
22 |     resp = client.get(cmd_path)
23 | 
24 |     command_input = b"2.0.argument.text.1.text.upper-bound"
25 |     command_name = b"Random-Number"
26 | 
27 |     assert command_input in resp.data
28 |     assert command_name in resp.data
29 | 
30 | 
31 | def test_exec_random_command(test_app, client: FlaskClient):
32 |     cmd_path = "/cli/test-plugin/random-number"
33 |     upper_bound = 10
34 |     cmd_data = {"2.0.argument.text.1.text.upper-bound": upper_bound}
35 | 
36 |     resp = client.post(cmd_path, data=cmd_data)
37 | 
38 |     assert resp.status_code == 200
39 |     # check for random number <= 10 in response
40 |     assert re.match("(0*(?:[1-9]?|10))", str(resp.data))
41 | 
42 | 
43 | def test_exec_command_app_context(
44 |     test_app, note_fixture, bookmark_fixture, client: FlaskClient
45 | ):
46 |     cmd_path = "/cli/test-plugin/get-random-dataobj-title"
47 |     resp = client.post(cmd_path)
48 | 
49 |     assert resp.status_code == 200
50 |     assert b"Test Note" or b"Example" in resp.data
51 | 


--------------------------------------------------------------------------------
/tests/test_click_web.py:
--------------------------------------------------------------------------------
  1 | import pprint
  2 | from pathlib import Path
  3 | 
  4 | import click
  5 | import pytest
  6 | 
  7 | from archivy import click_web, cli
  8 | from archivy.click_web.resources import cmd_form, input_fields
  9 | 
 10 | 
 11 | @pytest.mark.parametrize(
 12 |     "param, command_index, expected",
 13 |     [
 14 |         (
 15 |             click.Argument(
 16 |                 [
 17 |                     "an_argument",
 18 |                 ]
 19 |             ),
 20 |             0,
 21 |             {
 22 |                 "checked": "",
 23 |                 "click_type": "text",
 24 |                 "help": "",
 25 |                 "human_readable_name": "AN ARGUMENT",
 26 |                 "name": "0.0.argument.text.1.text.an-argument",
 27 |                 "nargs": 1,
 28 |                 "param": "argument",
 29 |                 "required": True,
 30 |                 "type": "text",
 31 |                 "value": None,
 32 |             },
 33 |         ),
 34 |         (
 35 |             click.Argument(
 36 |                 [
 37 |                     "an_argument",
 38 |                 ],
 39 |                 nargs=2,
 40 |             ),
 41 |             1,
 42 |             {
 43 |                 "checked": "",
 44 |                 "click_type": "text",
 45 |                 "help": "",
 46 |                 "human_readable_name": "AN ARGUMENT",
 47 |                 "name": "1.0.argument.text.2.text.an-argument",
 48 |                 "nargs": 2,
 49 |                 "param": "argument",
 50 |                 "required": True,
 51 |                 "type": "text",
 52 |                 "value": None,
 53 |             },
 54 |         ),
 55 |         (
 56 |             click.Option(
 57 |                 [
 58 |                     "--an_option",
 59 |                 ]
 60 |             ),
 61 |             0,
 62 |             {
 63 |                 "checked": "",
 64 |                 "click_type": "text",
 65 |                 "desc": None,
 66 |                 "help": ("--an_option TEXT", ""),
 67 |                 "human_readable_name": "an option",
 68 |                 "name": "0.0.option.text.1.text.--an-option",
 69 |                 "nargs": 1,
 70 |                 "param": "option",
 71 |                 "required": False,
 72 |                 "type": "text",
 73 |                 "value": "",
 74 |             },
 75 |         ),
 76 |         (
 77 |             click.Option(
 78 |                 [
 79 |                     "--an_option",
 80 |                 ],
 81 |                 nargs=2,
 82 |             ),
 83 |             1,
 84 |             {
 85 |                 "checked": "",
 86 |                 "click_type": "text",
 87 |                 "desc": None,
 88 |                 "help": ("--an_option TEXT...", ""),
 89 |                 "human_readable_name": "an option",
 90 |                 "name": "1.0.option.text.2.text.--an-option",
 91 |                 "nargs": 2,
 92 |                 "param": "option",
 93 |                 "required": False,
 94 |                 "type": "text",
 95 |                 "value": "",
 96 |             },
 97 |         ),
 98 |         (
 99 |             click.Option(
100 |                 [
101 |                     "--flag/--no-flag",
102 |                 ],
103 |                 default=True,
104 |                 help="help",
105 |             ),
106 |             3,
107 |             {
108 |                 "checked": 'checked="checked"',
109 |                 "click_type": "bool_flag",
110 |                 "desc": "help",
111 |                 "help": ("--flag / --no-flag", "help"),
112 |                 "human_readable_name": "flag",
113 |                 "name": "3.0.flag.bool_flag.1.checkbox.--flag",
114 |                 "nargs": 1,
115 |                 "off_flag": "--no-flag",
116 |                 "on_flag": "--flag",
117 |                 "param": "option",
118 |                 "required": False,
119 |                 "type": "checkbox",
120 |                 "value": "--flag",
121 |             },
122 |         ),
123 |     ],
124 | )
125 | def test_get_input_field(ctx, click_cli, param, expected, command_index):
126 |     res = input_fields.get_input_field(ctx, param, command_index, 0)
127 |     pprint.pprint(res)
128 |     assert res == expected
129 | 
130 | 
131 | @pytest.mark.parametrize(
132 |     "param, command_index, expected",
133 |     [
134 |         (
135 |             click.Argument(
136 |                 [
137 |                     "an_argument",
138 |                 ],
139 |                 nargs=-1,
140 |             ),
141 |             0,
142 |             {
143 |                 "checked": "",
144 |                 "click_type": "text",
145 |                 "help": "",
146 |                 "human_readable_name": "AN ARGUMENT",
147 |                 "name": "0.0.argument.text.-1.text.an-argument",
148 |                 "nargs": -1,
149 |                 "param": "argument",
150 |                 "required": False,
151 |                 "type": "text",
152 |                 "value": None,
153 |             },
154 |         ),
155 |     ],
156 | )
157 | def test_variadic_arguments(ctx, click_cli, param, expected, command_index):
158 |     res = input_fields.get_input_field(ctx, param, command_index, 0)
159 |     pprint.pprint(res)
160 |     assert res == expected
161 | 
162 | 
163 | @pytest.mark.parametrize(
164 |     "param, command_index, expected",
165 |     [
166 |         (
167 |             click.Argument(
168 |                 [
169 |                     "a_file_argument",
170 |                 ],
171 |                 type=click.File("rb"),
172 |             ),
173 |             0,
174 |             {
175 |                 "checked": "",
176 |                 "click_type": "file[rb]",
177 |                 "help": "",
178 |                 "human_readable_name": "A FILE ARGUMENT",
179 |                 "name": "0.0.argument.file[rb].1.file.a-file-argument",
180 |                 "nargs": 1,
181 |                 "param": "argument",
182 |                 "required": True,
183 |                 "type": "file",
184 |                 "value": None,
185 |             },
186 |         ),
187 |         (
188 |             click.Option(
189 |                 [
190 |                     "--a_file_option",
191 |                 ],
192 |                 type=click.File("rb"),
193 |             ),
194 |             0,
195 |             {
196 |                 "checked": "",
197 |                 "click_type": "file[rb]",
198 |                 "desc": None,
199 |                 "help": ("--a_file_option FILENAME", ""),
200 |                 "human_readable_name": "a file option",
201 |                 "name": "0.0.option.file[rb].1.file.--a-file-option",
202 |                 "nargs": 1,
203 |                 "param": "option",
204 |                 "required": False,
205 |                 "type": "file",
206 |                 "value": "",
207 |             },
208 |         ),
209 |     ],
210 | )
211 | def test_get_file_input_field(ctx, click_cli, param, expected, command_index):
212 |     res = input_fields.get_input_field(ctx, param, command_index, 0)
213 |     pprint.pprint(res)
214 |     assert res == expected
215 | 
216 | 
217 | @pytest.mark.parametrize(
218 |     "param, command_index, expected",
219 |     [
220 |         (
221 |             click.Argument(
222 |                 [
223 |                     "a_file_argument",
224 |                 ],
225 |                 type=click.File("wb"),
226 |             ),
227 |             0,
228 |             {
229 |                 "checked": "",
230 |                 "click_type": "file[wb]",
231 |                 "help": "",
232 |                 "human_readable_name": "A FILE ARGUMENT",
233 |                 "name": "0.0.argument.file[wb].1.hidden.a-file-argument",
234 |                 "nargs": 1,
235 |                 "param": "argument",
236 |                 "required": True,
237 |                 "type": "hidden",
238 |                 "value": None,
239 |             },
240 |         ),
241 |         (
242 |             click.Option(
243 |                 [
244 |                     "--a_file_option",
245 |                 ],
246 |                 type=click.File("wb"),
247 |             ),
248 |             0,
249 |             {
250 |                 "checked": "",
251 |                 "click_type": "file[wb]",
252 |                 "desc": None,
253 |                 "help": ("--a_file_option FILENAME", ""),
254 |                 "human_readable_name": "a file option",
255 |                 "name": "0.0.option.file[wb].1.text.--a-file-option",
256 |                 "nargs": 1,
257 |                 "param": "option",
258 |                 "required": False,
259 |                 "type": "text",
260 |                 "value": "",
261 |             },
262 |         ),
263 |     ],
264 | )
265 | def test_get_output_file_input_field(ctx, click_cli, param, expected, command_index):
266 |     res = input_fields.get_input_field(ctx, param, command_index, 0)
267 |     pprint.pprint(res)
268 |     assert res == expected
269 | 


--------------------------------------------------------------------------------
/tests/test_plugin/plugin.py:
--------------------------------------------------------------------------------
 1 | #!/usr/bin/env python3
 2 | from random import randint
 3 | 
 4 | import click
 5 | 
 6 | from archivy import app
 7 | from archivy.helpers import get_max_id
 8 | from archivy.data import get_items
 9 | 
10 | 
11 | @click.group()
12 | def test_plugin():
13 |     pass
14 | 
15 | 
16 | @test_plugin.command()
17 | @click.argument("upper_bound")
18 | def random_number(upper_bound):
19 |     click.echo(randint(1, int(upper_bound)))
20 | 
21 | 
22 | @test_plugin.command()
23 | def get_random_dataobj_title():
24 |     with app.app_context():
25 |         dataobjs = get_items(structured=False)
26 |         click.echo(dataobjs[randint(0, len(dataobjs))]["title"])
27 | 


--------------------------------------------------------------------------------
/tests/test_plugin/setup.py:
--------------------------------------------------------------------------------
 1 | from setuptools import setup
 2 | 
 3 | 
 4 | setup(
 5 |     name="plugins2",
 6 |     version="0.1dev0",
 7 |     py_modules=["plugin"],
 8 |     entry_points="""
 9 |         [archivy.plugins]
10 |         test_plugin=plugin:test_plugin
11 |     """,
12 | )
13 | 


--------------------------------------------------------------------------------
/tests/test_request_parsing.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/archivy/archivy/bdcdd39ac6cf9f7b3709b984d8be2f0fa898139e/tests/test_request_parsing.py


--------------------------------------------------------------------------------
/tests/unit/test_advanced_conf.py:
--------------------------------------------------------------------------------
  1 | from textwrap import dedent
  2 | 
  3 | import pytest
  4 | from tinydb import Query
  5 | 
  6 | from archivy.helpers import get_db, load_hooks, load_scraper
  7 | from archivy import data
  8 | 
  9 | 
 10 | @pytest.fixture()
 11 | def hooks_cli_runner(test_app, cli_runner, click_cli):
 12 |     """
 13 |     Saves hooks to user config directory for tests.
 14 | 
 15 |     All of the hooks except `before_dataobj_create` store some form of message in
 16 |     the db, whose existence is then checked in the tests.
 17 |     """
 18 |     hookfile = """\
 19 |         from archivy.config import BaseHooks
 20 |         from archivy.helpers import get_db
 21 | 
 22 |         class Hooks(BaseHooks):
 23 |             def on_edit(self, dataobj):
 24 |                 get_db().insert({"type": "edit_message", "content": f"Changes made to content of {dataobj.title}."})
 25 |             def on_user_create(self, user):
 26 |                 get_db().insert({"type": "user_creation_message", "content": f"New user {user.username} created."})
 27 |             def on_dataobj_create(self, dataobj):
 28 |                 get_db().insert({"type": "dataobj_creation_message", "content": f"New dataobj on {dataobj.title} with tags: {dataobj.tags}"})
 29 |             def before_dataobj_create(self, dataobj):
 30 |                 dataobj.content += "Dataobj made for test." """
 31 |     with cli_runner.isolated_filesystem():
 32 |         cli_runner.invoke(click_cli, ["init"], input="\nn\nn\n\n")
 33 |         with open("hooks.py", "w") as f:
 34 |             f.write(dedent(hookfile))
 35 |         with test_app.app_context():
 36 |             test_app.config["HOOKS"] = load_hooks()
 37 |         yield cli_runner
 38 | 
 39 | 
 40 | @pytest.fixture()
 41 | def custom_scraping_setup(test_app, cli_runner, click_cli):
 42 |     scraping_file = """\
 43 |             def test_pattern(data):
 44 |                 data.title = "Overridden note"
 45 |                 data.content = "this note was not processed by default archivy bookmarking, but a user-specified function"
 46 |                 data.tags = ["test"]
 47 |             
 48 |             PATTERNS = {
 49 |                 "https://example.com/": test_pattern,
 50 |                 "https://example2.com/": ".nested"
 51 |             }"""
 52 | 
 53 |     with cli_runner.isolated_filesystem():
 54 |         cli_runner.invoke(click_cli, ["init"], input="\nn\nn\n\n")
 55 |         with open("scraping.py", "w") as f:
 56 |             f.write(dedent(scraping_file))
 57 |         with test_app.app_context():
 58 |             test_app.config["SCRAPING_PATTERNS"] = load_scraper()
 59 |         yield cli_runner
 60 | 
 61 | 
 62 | def test_dataobj_creation_hook(test_app, hooks_cli_runner, note_fixture):
 63 |     creation_message = get_db().search(Query().type == "dataobj_creation_message")[0]
 64 |     assert (
 65 |         creation_message["content"]
 66 |         == f"New dataobj on {note_fixture.title} with tags: {note_fixture.tags}"
 67 |     )
 68 | 
 69 | 
 70 | def test_before_dataobj_creation_hook(
 71 |     test_app, hooks_cli_runner, note_fixture, bookmark_fixture
 72 | ):
 73 |     # check hook that added content at the end of body succeeded.
 74 |     message = "Dataobj made for test."
 75 |     assert message in note_fixture.content
 76 |     assert message in bookmark_fixture.content
 77 | 
 78 | 
 79 | def test_dataobj_edit_hook(test_app, hooks_cli_runner, note_fixture, client):
 80 |     client.put(
 81 |         f"/api/dataobjs/{note_fixture.id}", json={"content": "Updated note content"}
 82 |     )
 83 | 
 84 |     edit_message = get_db().search(Query().type == "edit_message")[0]
 85 |     assert (
 86 |         f"Changes made to content of {note_fixture.title}." == edit_message["content"]
 87 |     )
 88 | 
 89 | 
 90 | def test_user_creation_hook(test_app, hooks_cli_runner, user_fixture):
 91 |     creation_message = get_db().search(Query().type == "user_creation_message")[1]
 92 |     assert f"New user {user_fixture.username} created." == creation_message["content"]
 93 | 
 94 | 
 95 | def test_custom_scraping_patterns(
 96 |     custom_scraping_setup, test_app, bookmark_fixture, different_bookmark_fixture
 97 | ):
 98 |     pattern = "example.com"
 99 |     assert pattern in bookmark_fixture.url
100 |     assert bookmark_fixture.title == "Overridden note"
101 |     assert bookmark_fixture.tags == ["test"]
102 |     pattern = "example2.com"
103 |     assert pattern in different_bookmark_fixture.url
104 |     # check that the CSS selector was parsed and other parts of the document were not selected
105 |     assert different_bookmark_fixture.content.startswith("aaa")
106 |     test_app.config["SCRAPING_PATTERNS"] = {}
107 | 


--------------------------------------------------------------------------------
/tests/unit/test_cli.py:
--------------------------------------------------------------------------------
  1 | import os
  2 | from pathlib import Path
  3 | from tempfile import mkdtemp
  4 | 
  5 | from tinydb import Query
  6 | 
  7 | from archivy.cli import cli
  8 | from archivy.helpers import get_db
  9 | from archivy.models import DataObj
 10 | from archivy.data import get_items, create_dir, get_data_dir
 11 | 
 12 | 
 13 | def test_initialization(test_app, cli_runner, click_cli):
 14 |     conf_path = os.path.join(test_app.config["USER_DIR"], "config.yml")
 15 |     try:
 16 |         # conf shouldn't exist
 17 |         open(conf_path)
 18 |         assert False
 19 |     except FileNotFoundError:
 20 |         pass
 21 |     old_data_dir = test_app.config["USER_DIR"]
 22 | 
 23 |     with cli_runner.isolated_filesystem():
 24 |         # create user, localhost, and don't use ES
 25 |         res = cli_runner.invoke(
 26 |             click_cli, ["init"], input="\nn\ny\nusername\npassword\npassword\n\n"
 27 |         )
 28 |         assert "Config successfully created" in res.output
 29 | 
 30 |         # verify user was created
 31 |         assert len(
 32 |             get_db().search(Query().type == "user" and Query().username == "username")
 33 |         )
 34 | 
 35 |         # verify dataobj creation works
 36 |         assert DataObj(type="note", title="Test note").insert()
 37 |         assert len(get_items(structured=False)) == 1
 38 | 
 39 |     conf = open(conf_path).read()
 40 | 
 41 |     # assert defaults are saved
 42 |     assert f"USER_DIR: {test_app.config['USER_DIR']}" in conf
 43 |     assert "HOST: 127.0.0.1"
 44 |     # check ES config not saved
 45 |     assert "ELASTICSEARCH" not in conf
 46 | 
 47 |     # check initialization in random directory
 48 |     # has resulted in change of user dir
 49 |     assert old_data_dir != test_app.config["USER_DIR"]
 50 | 
 51 | 
 52 | def test_initialization_with_es(test_app, cli_runner, click_cli):
 53 |     conf_path = os.path.join(test_app.config["USER_DIR"], "config.yml")
 54 |     old_data_dir = test_app.config["USER_DIR"]
 55 | 
 56 |     with cli_runner.isolated_filesystem():
 57 |         # use ES, localhost and don't create user
 58 |         res = cli_runner.invoke(click_cli, ["init"], input="\ny\nelasticsearch\nn\n\n")
 59 | 
 60 |     assert "Config successfully created" in res.output
 61 |     conf = open(conf_path).read()
 62 | 
 63 |     # assert ES Config is saved
 64 |     assert "SEARCH_CONF" in conf
 65 |     assert "enabled: 1" in conf
 66 | 
 67 |     # however, check that defaults are not saved, as they have not been modified:
 68 |     assert not "es_password" in conf
 69 |     assert not "EDITOR_CONF" in conf
 70 | 
 71 |     # check initialization in random directory
 72 |     # has resulted in change of user dir
 73 |     assert old_data_dir != test_app.config["USER_DIR"]
 74 | 
 75 | 
 76 | def test_initialization_with_ripgrep(test_app, cli_runner, click_cli):
 77 |     conf_path = os.path.join(test_app.config["USER_DIR"], "config.yml")
 78 |     old_data_dir = test_app.config["USER_DIR"]
 79 | 
 80 |     with cli_runner.isolated_filesystem():
 81 |         # use ES, localhost and don't create user
 82 |         res = cli_runner.invoke(click_cli, ["init"], input="\ny\nripgrep\nn\n\n")
 83 | 
 84 |     assert "Config successfully created" in res.output
 85 |     conf = open(conf_path).read()
 86 | 
 87 |     # assert search Config is saved
 88 |     assert "SEARCH_CONF" in conf
 89 |     assert "enabled: 1" in conf
 90 |     assert "ripgrep" in conf
 91 | 
 92 | 
 93 | def test_initialization_in_diff_than_curr_dir(test_app, cli_runner, click_cli):
 94 |     conf_path = os.path.join(test_app.config["USER_DIR"], "config.yml")
 95 |     data_dir = mkdtemp()
 96 | 
 97 |     with cli_runner.isolated_filesystem():
 98 |         # input data dir - localhost - don't use ES and don't create user
 99 |         res = cli_runner.invoke(cli, ["init"], input=f"{data_dir}\nn\nn\n\n")
100 | 
101 |     assert "Config successfully created" in res.output
102 |     conf = open(conf_path).read()
103 | 
104 |     assert f"USER_DIR: {data_dir}" in conf
105 | 
106 |     # check initialization in random directory
107 |     # has resulted in change of user dir
108 |     assert data_dir == test_app.config["USER_DIR"]
109 | 
110 |     # verify dataobj creation works
111 |     assert DataObj(type="note", title="Test note").insert()
112 |     assert len(get_items(structured=False)) == 1
113 | 
114 | 
115 | def test_initialization_custom_host(test_app, cli_runner, click_cli):
116 |     conf_path = os.path.join(test_app.config["USER_DIR"], "config.yml")
117 |     try:
118 |         # conf shouldn't exist
119 |         open(conf_path)
120 |         assert False
121 |     except FileNotFoundError:
122 |         pass
123 | 
124 |     with cli_runner.isolated_filesystem():
125 |         # create user, localhost, and don't use ES
126 |         res = cli_runner.invoke(click_cli, ["init"], input="\nn\nn\n0.0.0.0")
127 |         assert "Host" in res.output
128 |         assert "Config successfully created" in res.output
129 | 
130 |     conf = open(conf_path).read()
131 | 
132 |     # assert defaults are saved
133 |     print(res.output)
134 |     assert f"HOST: 0.0.0.0" in conf
135 | 
136 | 
137 | def test_create_admin(test_app, cli_runner, click_cli):
138 |     db = get_db()
139 |     nb_users = len(db.search(Query().type == "user"))
140 |     cli_runner.invoke(
141 |         click_cli, ["create-admin", "__username__"], input="password\npassword"
142 |     )
143 | 
144 |     # need to reconnect to db because it has been modified by different processes
145 |     # so the connection needs to be updated for new changes
146 |     db = get_db(force_reconnect=True)
147 |     assert nb_users + 1 == len(db.search(Query().type == "user"))
148 |     assert len(db.search(Query().type == "user" and Query().username == "__username__"))
149 | 
150 | 
151 | def test_create_admin_small_password_fails(test_app, cli_runner, click_cli):
152 |     cli_runner.invoke(click_cli, ["create-admin", "__username__"], input="short\nshort")
153 |     db = get_db()
154 |     assert not len(
155 |         db.search(Query().type == "user" and Query().username == "__username__")
156 |     )
157 | 
158 | 
159 | def test_format_multiple_md_file(test_app, cli_runner, click_cli):
160 |     with cli_runner.isolated_filesystem():
161 |         files = ["test-note-1.md", "test-note-2.md"]
162 |         for filename in files:
163 |             with open(filename, "w") as f:
164 |                 f.write("Unformatted Test Content")
165 | 
166 |         res = cli_runner.invoke(cli, ["format"] + files)
167 | 
168 |         for filename in files:
169 |             assert f"Formatted and moved {filename}" in res.output
170 | 
171 | 
172 | def test_format_entire_directory(test_app, cli_runner, click_cli):
173 |     with cli_runner.isolated_filesystem():
174 |         files = [
175 |             "unformatted/test-note-1.md",
176 |             "unformatted/test-note-2.md",
177 |             "unformatted/nested/test-note-3.md",
178 |         ]
179 |         os.mkdir("data")
180 |         os.mkdir("data/unformatted")
181 |         os.mkdir("data/unformatted/nested")
182 | 
183 |         for filename in files:
184 |             with open("data/" + filename, "w") as f:
185 |                 f.write("Unformatted Test Content")
186 | 
187 |         # set user_dir to current dir by configuring
188 |         res = cli_runner.invoke(cli, ["init"], input="\nn\nn\n\n\n")
189 | 
190 |         # format directory
191 |         res = cli_runner.invoke(cli, ["format", "data/unformatted/"])
192 | 
193 |         for filename in files:
194 |             # assert directory files got moved correctly
195 |             assert f"Formatted and moved {filename}" in res.output
196 |         assert (os.path.abspath("") + "/data/unformatted") in res.output
197 | 
198 | 
199 | def test_unformat_multiple_md_file(
200 |     test_app, cli_runner, click_cli, bookmark_fixture, note_fixture
201 | ):
202 |     out_dir = mkdtemp()
203 |     create_dir("")
204 |     res = cli_runner.invoke(
205 |         cli,
206 |         [
207 |             "unformat",
208 |             str(bookmark_fixture.fullpath),
209 |             str(note_fixture.fullpath),
210 |             out_dir,
211 |         ],
212 |     )
213 |     assert (
214 |         f"Unformatted and moved {bookmark_fixture.fullpath} to {out_dir}/{bookmark_fixture.title}"
215 |         in res.output
216 |     )
217 |     assert (
218 |         f"Unformatted and moved {note_fixture.fullpath} to {out_dir}/{note_fixture.title}"
219 |         in res.output
220 |     )
221 | 
222 | 
223 | def test_unformat_directory(
224 |     test_app, cli_runner, click_cli, bookmark_fixture, note_fixture
225 | ):
226 |     out_dir = mkdtemp()
227 | 
228 |     # create directory to store archivy note
229 |     note_dir = "note-dir"
230 |     create_dir(note_dir)
231 |     nested_note = DataObj(type="note", title="Nested note", path=note_dir)
232 |     nested_note.insert()
233 | 
234 |     # unformat directory
235 |     res = cli_runner.invoke(
236 |         cli, ["unformat", os.path.join(get_data_dir(), note_dir), out_dir]
237 |     )
238 |     assert (
239 |         f"Unformatted and moved {nested_note.fullpath} to {out_dir}/{note_dir}/{nested_note.title}"
240 |         in res.output
241 |     )
242 | 
243 | 
244 | def test_create_plugin_dir(test_app, cli_runner, click_cli):
245 |     with cli_runner.isolated_filesystem():
246 |         res = cli_runner.invoke(cli, ["plugin-new", "archivy_test_plugin"])
247 |         plugin_dir = Path("archivy_test_plugin")
248 |         assert plugin_dir.exists()
249 |         files = [
250 |             "README.md",
251 |             "requirements.txt",
252 |             "archivy_test_plugin/__init__.py",
253 |             "setup.py",
254 |         ]
255 |         for file in files:
256 |             assert (plugin_dir / file).exists()
257 | 


--------------------------------------------------------------------------------
/tests/unit/test_models.py:
--------------------------------------------------------------------------------
 1 | from pathlib import Path
 2 | 
 3 | import frontmatter
 4 | 
 5 | from archivy.models import DataObj
 6 | from archivy.helpers import get_max_id
 7 | from responses import GET
 8 | 
 9 | from archivy.models import DataObj
10 | 
11 | attributes = ["type", "title", "tags", "path", "id"]
12 | 
13 | 
14 | def test_new_bookmark(test_app):
15 |     bookmark = DataObj(
16 |         type="bookmark",
17 |         tags=["example"],
18 |         url="http://example.org",
19 |     )
20 |     bookmark.process_bookmark_url()
21 |     bookmark_id = bookmark.insert()
22 |     assert bookmark_id == 1
23 | 
24 | 
25 | def test_new_note(test_app, note_fixture):
26 |     """
27 |     Check that a new note is correctly saved into the filesystem
28 |     with the right attributes and the right id.
29 |     """
30 | 
31 |     with test_app.app_context():
32 |         max_id = get_max_id()
33 |         assert note_fixture.id == max_id
34 | 
35 |     saved_file = frontmatter.load(note_fixture.fullpath)
36 |     for attr in attributes:
37 |         assert getattr(note_fixture, attr) == saved_file[attr]
38 | 
39 | 
40 | def test_bookmark_sanitization(test_app, client, mocked_responses, bookmark_fixture):
41 |     """Test that bookmark content is correctly saved as correct Markdown"""
42 | 
43 |     with test_app.app_context():
44 |         assert bookmark_fixture.id == get_max_id()
45 | 
46 |     saved_file = frontmatter.load(bookmark_fixture.fullpath)
47 |     for attr in attributes:
48 |         assert getattr(bookmark_fixture, attr) == saved_file[attr]
49 |     assert bookmark_fixture.url == saved_file["url"]
50 | 
51 |     # remove buggy newlines that interfere with checks:
52 |     bookmark_fixture.content = bookmark_fixture.content.replace("\n", "")
53 |     # test script is sanitized
54 |     assert bookmark_fixture.content.find("<script>") == -1
55 |     # test relative urls in the HTML are remapped to an absolute urls
56 |     assert "example.com/images/image1.png" in bookmark_fixture.content
57 |     assert "example.com/testing-absolute-url" in bookmark_fixture.content
58 | 
59 |     # check empty link has been cleared
60 |     assert "[](empty-link)" not in bookmark_fixture.content
61 | 
62 | 
63 | def test_bookmark_included_images_are_saved(test_app, client, mocked_responses):
64 |     mocked_responses.add(
65 |         GET, "https://example.com", body="""<html><img src='/image.png'></html>"""
66 |     )
67 |     mocked_responses.add(
68 |         GET, "https://example.com/image.png", body=open("docs/img/logo.png", "rb")
69 |     )
70 |     test_app.config["SCRAPING_CONF"]["save_images"] = True
71 |     bookmark = DataObj(type="bookmark", url="https://example.com")
72 |     bookmark.process_bookmark_url()
73 |     bookmark.insert()
74 |     images_dir = Path(test_app.config["USER_DIR"]) / "images"
75 |     assert images_dir.exists()
76 |     assert (images_dir / "image.png").exists()
77 | 


--------------------------------------------------------------------------------