Repository: jarun/buku
Branch: master
Commit: f350cd6fb017
Files: 135
Total size: 1.9 MB
Directory structure:
gitextract_q_0wh3lh/
├── .circleci/
│ └── config.yml
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE.md
│ ├── PULL_REQUEST_TEMPLATE.md
│ └── workflows/
│ └── lock.yml
├── .gitignore
├── .pre-commit-config.yaml
├── .readthedocs.yaml
├── CHANGELOG
├── Dockerfile
├── LICENSE
├── MANIFEST.in
├── Makefile
├── README.md
├── auto-completion/
│ ├── bash/
│ │ └── buku-completion.bash
│ ├── fish/
│ │ └── buku.fish
│ └── zsh/
│ └── _buku
├── buku.1
├── buku.py
├── bukuserver/
│ ├── README.md
│ ├── __init__.py
│ ├── __main__.py
│ ├── api.py
│ ├── apidocs/
│ │ ├── bookmark/
│ │ │ ├── delete.yml
│ │ │ ├── get.yml
│ │ │ └── put.yml
│ │ ├── bookmark_range/
│ │ │ ├── delete.yml
│ │ │ ├── get.yml
│ │ │ └── put.yml
│ │ ├── bookmark_refresh/
│ │ │ └── post.yml
│ │ ├── bookmarks/
│ │ │ ├── delete.yml
│ │ │ ├── get.yml
│ │ │ └── post.yml
│ │ ├── bookmarks_refresh/
│ │ │ └── post.yml
│ │ ├── bookmarks_reorder/
│ │ │ └── post.yml
│ │ ├── bookmarks_search/
│ │ │ ├── delete.yml
│ │ │ └── get.yml
│ │ ├── fetch_data/
│ │ │ └── post.yml
│ │ ├── network_handle/
│ │ │ └── post.yml
│ │ ├── tag/
│ │ │ ├── delete.yml
│ │ │ ├── get.yml
│ │ │ └── put.yml
│ │ ├── tags/
│ │ │ └── get.yml
│ │ ├── template.yml
│ │ └── tiny_url/
│ │ └── get.yml
│ ├── bookmarklet.js
│ ├── filters.py
│ ├── forms.py
│ ├── middleware/
│ │ ├── __init__.py
│ │ └── flask_reverse_proxy_fix.py
│ ├── requirements.txt
│ ├── response.py
│ ├── server.py
│ ├── static/
│ │ └── bukuserver/
│ │ ├── css/
│ │ │ ├── bookmark.css
│ │ │ ├── list.css
│ │ │ └── modal.css
│ │ └── js/
│ │ ├── Chart.js
│ │ ├── bookmark.js
│ │ ├── buku_filter.js
│ │ ├── filters_fix.js
│ │ ├── last_page.js
│ │ └── order_filter.js
│ ├── templates/
│ │ └── bukuserver/
│ │ ├── bookmark_create.html
│ │ ├── bookmark_create_modal.html
│ │ ├── bookmark_details.html
│ │ ├── bookmark_details_modal.html
│ │ ├── bookmark_edit.html
│ │ ├── bookmark_edit_modal.html
│ │ ├── bookmarklet.url
│ │ ├── bookmarks_list.html
│ │ ├── home.html
│ │ ├── lib.html
│ │ ├── statistic.html
│ │ ├── tag_edit.html
│ │ └── tags_list.html
│ ├── translations/
│ │ ├── .gitignore
│ │ ├── README.md
│ │ ├── __init__.py
│ │ ├── __main__.py
│ │ ├── babel.cfg
│ │ ├── de/
│ │ │ └── LC_MESSAGES/
│ │ │ ├── messages.mo
│ │ │ └── messages.po
│ │ ├── fr/
│ │ │ └── LC_MESSAGES/
│ │ │ ├── messages.mo
│ │ │ └── messages.po
│ │ ├── messages_custom.pot
│ │ └── ru/
│ │ └── LC_MESSAGES/
│ │ ├── messages.mo
│ │ └── messages.po
│ ├── util.py
│ └── views.py
├── bukuserver-runner/
│ ├── README.md
│ ├── buku-server-headless.desktop
│ ├── buku-server.desktop
│ └── buku-server.py
├── docker-compose/
│ └── docker-compose.yml
├── docs/
│ └── source/
│ ├── buku.rst
│ ├── bukuserver.rst
│ ├── conf.py
│ ├── index.rst
│ ├── modules.rst
│ └── tutorial_for_developer.md
├── mypy.ini
├── packagecore.yaml
├── pyproject.toml
├── requirements.txt
├── tests/
│ ├── .pylintrc
│ ├── __init__.py
│ ├── cassettes/
│ │ └── test_buku/
│ │ ├── test_fetch_data_with_url[http---example.com-exp_res1].yaml
│ │ ├── test_fetch_data_with_url[http---example.com-page1.txt-exp_res2].yaml
│ │ ├── test_fetch_data_with_url[http---www.vim.org-scripts-script.php~-exp_res7].yaml
│ │ └── test_fetch_data_with_url[https---www.google.ru-search~-exp_res6].yaml
│ ├── genbm.sh
│ ├── pytest.ini
│ ├── test_BukuCrypt.py
│ ├── test_ExtendedArgumentParser.py
│ ├── test_buku.py
│ ├── test_bukuDb/
│ │ ├── 25491522_res.yaml
│ │ ├── 25491522_res_nopt.yaml
│ │ ├── Bookmarks
│ │ ├── firefox_res.yaml
│ │ ├── firefox_res_nopt.yaml
│ │ └── places.sql
│ ├── test_bukuDb.py
│ ├── test_cli.py
│ ├── test_import_firefox_json.py
│ ├── test_requirements.py
│ ├── test_server.py
│ ├── test_views.py
│ ├── util.py
│ └── vcr_cassettes/
│ ├── test_browse_by_index.yaml
│ ├── test_delete_rec_range_and_delay_commit.yaml
│ ├── test_search_by_multiple_tags_search_all.yaml
│ ├── test_search_by_multiple_tags_search_any.yaml
│ └── test_search_by_tags_enforces_space_seprations_exclusion.yaml
├── tox.bat
└── tox.ini
================================================
FILE CONTENTS
================================================
================================================
FILE: .circleci/config.yml
================================================
version: 2.1
executors:
docker-publisher:
environment:
IMAGE_NAME: bukuserver/bukuserver
docker:
- image: cimg/base:current
test-template: &test-template
working_directory: ~/Buku
environment:
CI_FORCE_TEST: 1
steps:
- run: &init
command: |
apt update && apt install -y --no-install-recommends git
pip install --upgrade pip
- checkout
- run: &deps
command: |
pip install -e . --group dev
- run:
command:
python3 -m pytest ./tests/test_*.py --cov buku -vv --durations=0 -c ./tests/pytest.ini
lint-template: &lint-template
working_directory: ~/Buku
environment:
CI_FORCE_TEST: 1
steps:
- run: *init
- checkout
- run: *deps
- run:
command: |
python3 -m flake8
echo buku | xargs pylint --rcfile tests/.pylintrc
find . -iname "*.py" ! -path "./api/*" | xargs pylint --rcfile tests/.pylintrc
jobs:
lint:
docker:
- image: python:3.13-slim
<<: *lint-template
py310:
docker:
- image: python:3.10-slim
<<: *test-template
py311:
docker:
- image: python:3.11-slim
<<: *test-template
py312:
docker:
- image: python:3.12-slim
<<: *test-template
py313:
docker:
- image: python:3.13-slim
<<: *test-template
py314:
docker:
- image: python:3.14-slim
<<: *test-template
# package-and-publish:
# machine: true
# working_directory: ~/Buku
# steps:
# - checkout
# - run:
# name: "package with packagecore"
# command: |
# # Use latest installed python3 from pyenv
# export PYENV_VERSION="$(pyenv versions | grep -Po '\b3\.\d+\.\d+' | tail -1)"
# pip install packagecore
# packagecore -o ./dist/ ${CIRCLE_TAG#v}
# - run:
# name: "publish to GitHub"
# command: |
# go get github.com/tcnksm/ghr
# ghr -t ${GITHUB_API_TOKEN} -u ${CIRCLE_PROJECT_USERNAME} -r ${CIRCLE_PROJECT_REPONAME} -c ${CIRCLE_SHA1} -replace ${CIRCLE_TAG} ./dist/
build-and-publish-docker-image:
executor: docker-publisher
steps:
- checkout
- setup_remote_docker
- run:
name: Login to Docker Hub
command: 'echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin'
- run:
name: Create a builder instance
command: docker buildx create --use
- run:
name: Build Docker image and publish it to Docker Hub
command: |
TAGS="--tag $IMAGE_NAME:latest"
[ "$CIRCLE_TAG" ] && TAGS="--tag $IMAGE_NAME:$CIRCLE_TAG $TAGS"
docker buildx build --platform=linux/amd64,linux/arm64 $TAGS --push .
workflows:
version: 2.1
CircleCI:
jobs: &all-tests
- lint
- py310
- py311
- py312
- py313
- py314
nightly:
triggers:
- schedule:
cron: "0 0 * * 6"
filters:
branches:
only:
- master
jobs: *all-tests
# publish-github-release:
# jobs:
# - package-and-publish:
# filters:
# tags:
# only: /^v.*/
# branches:
# ignore: /.*/
publish-docker-image:
jobs:
- build-and-publish-docker-image:
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
================================================
FILE: .github/FUNDING.yml
================================================
github: jarun
================================================
FILE: .github/ISSUE_TEMPLATE.md
================================================
#### Bug reports
Before opening an issue, please try to reproduce on [the latest development version](https://github.com/jarun/Buku#from-source) first. The bug you noticed might have already been fixed.
If the issue can be reproduced on master, then please make sure you provide the following:
- Debug logs using the `-z` option;
- Details of operating system, Python version used, terminal emulator and shell;
- `locale` output, if relevant. It's a good idea to set your locale to UFT-8. Please refer to [Buku #131](https://github.com/jarun/Buku/issues/30).
If we need more information and there is no communication from the bug reporter within 7 days from the date of request, we will close the issue. If you have relevant information, resume discussion any time.
#### Feature requests
Please consider contributing the feature back to `Buku` yourself. Feel free to discuss. We are more than happy to help.
--- PLEASE DELETE THIS LINE AND EVERYTHING ABOVE ---
================================================
FILE: .github/PULL_REQUEST_TEMPLATE.md
================================================
Did you visit the [PR guidelines](https://github.com/jarun/Buku/wiki/PR-guidelines)?
--- PLEASE DELETE THIS LINE AND EVERYTHING ABOVE ---
================================================
FILE: .github/workflows/lock.yml
================================================
name: 'Lock threads'
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
discussions: write
concurrency:
group: lock-threads
jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
with:
github-token: ${{ github.token }}
issue-inactive-days: '30'
issue-lock-reason: ''
pr-inactive-days: '30'
pr-lock-reason: ''
================================================
FILE: .gitignore
================================================
*.py[co]
*.sw[po]
.cache/
.coverage
.hypothesis
buku.egg-info
dist
build
.tox
.history
.vscode/launch.json
/tests/test_bukuDb/places.sqlite*
/tests/vcr_cassettes/test_search_and_open_all_in_browser.yaml
/tests/vcr_cassettes/tests.test_bukuDb/
/bookmarks.db
/venv/
/docker-compose/data/*
!/docker-compose/data/nginx/
================================================
FILE: .pre-commit-config.yaml
================================================
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
hooks:
- id: check-added-large-files
- repo: https://github.com/pycqa/isort
rev: 5.10.1
hooks:
- id: isort
name: isort (python)
args: [--profile, black, --filter-files]
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
rev: v2.4.0
hooks:
- id: pretty-format-yaml
args: [--autofix]
- repo: https://github.com/akaihola/darker
rev: 1.5.1
hooks:
- id: darker
args: [--line-length, '139', --skip-string-normalization]
- repo: https://github.com/PyCQA/pylint/
rev: v2.15.5
hooks:
- id: pylint
name: pylint
entry: pylint
language: system
types: [python]
args: [-rn, -sn, --rcfile=tests/.pylintrc]
# "-rn", # Only display messages
# "-sn", # Don't display the score
# based on
# https://pylint.pycqa.org/en/latest/user_guide/pre-commit-integration.html
- repo: https://github.com/PyCQA/autoflake
rev: v1.7.7
hooks:
- id: autoflake
================================================
FILE: .readthedocs.yaml
================================================
# .readthedocs.yaml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-24.04
tools:
python: '3.12'
jobs:
install:
- pip install --upgrade pip
- pip install .[locales,server] --group docs
# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: docs/source/conf.py
# If using Sphinx, optionally build your docs in additional formats such as PDF
# formats:
# - pdf
================================================
FILE: CHANGELOG
================================================
buku (unreleased)
- Bukuserver: mitigate Host header poisoning via BUKUSERVER_SERVER_NAME config
buku v5.1
2025-12-07
- auto-import from Brave Browser
- `amd64` docker image (will be built starting from next release)
- sorting bookmarks by absence/presence of a specific tag
- reordering all bookmarks in DB (based on specified sorting)
- support overriding default URI scheme for `browse()` (@meonkeys)
- include a `Sec-Fetch-Mode` header when fetching a webpage (in case the website admin cares about that)
- source: renaming main file to `buku.py` (which is the name it gets installed as)
- packaging: migrating from `setup.py` to `pyproject.toml` (@branchvincent)
- packaging: installing the manpage into `pipx` venv (@branchvincent)
- Python: adding v3.14, removing v3.9
- API: improved BukuCrypt
- Bukuserver: Swagger-based interactive documentation for the web-API
- Bukuserver: option to change initial value of Fetch checkbox in Create Bookmark form
- Bukuserver: migrate from Bootstrap v3 to v4
- migrate to the upcoming flask-admin v2 (see #753)
buku v5.0
2025-04-37
- removed URL shorten/expand feature (see #815)
- fix "htttp" typos in tests (@chenrui333)
- fix default value of `--url` used with `--update` (see #729)
- add Python 3.12 support (@chenrui333)
- add Python 3.13 support + use it as default for testing
- list [BukuBot](https://git.xmpp-it.net/sch/BukuBot) in the docs as a related project (@sjehuda)
- fix pylint warnings
- fix parsing of `` in fetched webpages (see #734)
- fix `get_rec_all_by_ids()`
- fix autoimport of Firefox profiles with a custom absolute path (see #791)
- fix `--db` parameter being ignored by encryption actions (see #796)
- fix inconsistent handling of netloc (see #799)
- fix import in single-parent-folder-tag mode (see #807)
- add import/export in the `.rss` (Atom) format (@vagos)
- add `.atom` as an RSS import/export extension
- add description to XBEL export format (@sjehuda)
- support raw (unnamed) URLs when parsing Markdown/OrgMode (i.e. ``/`[[url]]` respectively)
- implement backwards navigation in the interactive shell mode (@20mattia02)
- implement DB switching in the interactive shell mode (with displaying non-default DB name)
- support opening interactive shell mode on a non-default DB
- implement custom-setting the default DB directory
- implement printing DB list in default directory when invoked specifically as `buku --db`
- implement handling `--db` parameter without `.` and directory separators as a _name_ (with omitted `.db` extension and located in default DB directory)
- Bukuserver: implement support for passing a DB name in `BUKUSERVER_DB_FILE`
- implement thread safety (e.g. for handling simultaneous requests in Bukuserver)
- Bukuserver: prevent `/api/tags` query from being spammed due to a Flask-Admin bug
- Bukuserver: increase size of the bookmarklet popup window
- Bukuserver: minor UI improvements
- Bukuserver: some readme improvements (@rachmadaniHaryono)
- Bukuserver: add "contains"/"not contains" modes for some filters (URL/title in Bookmarks, name in Tags)
- Bukuserver: fix input autofoxus on page/dialog opening
- Bukuserver: ensure that links in the bookmarklet popup window are always opened externally
- allow tags supplied by `--add` (or by `--tag`) to override fetched tags (unless started with `+`)
- implement search-with-markers (see #777)
- implement bookmarks output ordering [by fields/netloc] (see #777)
- implement random selection of bookmarks
- implement bookmark index swapping
- Bukuserver: implement translations
- Bukuserver: fix unstable ordering of the filters form
- Bukuserver: add a runner script
- Bukuserver: add support for tags/fetch fields to bookmarklet API endpoint
- Bukuserver: fix popup detection for sites that prevent exposing `opener`
- Bukuserver: fix disabling no-netloc URLs in rendering modes other than `full`
- Bukuserver: remove dependency on the stale package Flask-API
- support `urllib3>=2` as a dependency (see #776)
- fix various deprecation warnings
- Bukuserver: update screenshots in documentation
-------------------------------------------------------------------------------
buku v4.9
2024-04-07
- fixed profile detection for multiple Firefox installs (#711)
- added option `--offline` to add a bookmark without web connection
- added a mini-guide for quick keyboard access to the bookmarklet
- support environment variable `NO_COLOR`
- fixed HTML encoding detection (#713)
- fixed Windows profile detection (#683)
- support python 3.11 (support for python 3.7 removed)
- fixed readline internal error on Windows (#704)
-------------------------------------------------------------------------------
buku v4.8
2023-02-18
- support Vivaldi browser
- better XBEL compatibility
- check for empty search results in piped operations
- remove python 3.6 support, add 3.10
- API changes in bukudb (#660):
- bookmark data tuples returned from methods `get_rec_all()`
& `get_rec_by_id()`, now have user-friendly properties
(`id`, `url`, `title`, `desc`, `tags`/`taglist`, `immutable`;
as well as for raw DB fields – `tags_raw`, `flags`)
- methods `get_rec_all()`, `list_using_id()`, `searchdb()`, `search_by_tag()`,
`search_keywords_and_filter_by_tags()` & `exclude_results_from_search()`
are now guaranteed to return a list (empty if no data is found)
- methods `get_rec_id()`, `get_max_id()` & `add_rec()` now return `None` as
the "no ID" value
- methods `add_rec()`, `update_rec()` & `edit_update_rec()` now treat the
value of `immutable` parameter as a boolean (the default/noop value for
update calls is `None`)
- a `FIELD_FILTER` dictionary is introduced that contains fields formatting
description; also, in `format_json()` (and `print_json_safe()`), the output
format now matches the one described in CLI help
- IMPACT: If you have a local repo clone, remove .tox/ subfolder if it's there
before you run tests for the first time
-------------------------------------------------------------------------------
buku v4.7
2022-07-01
- support XBEL export/import (#569)
- support for Microsoft Edge bookmarks (#585)
- block web fetch on import
- many bukuserver fixes (#543, #545, #547, #548, #553, #554, #559)
- import nested directory names as tags on html import (#539)
- fix slow/failed markdown import (#538)
- fix SSL certificate identification not working on macOS (#528)
- import tags from markdown (#523)
- fix broken pipe error with oil (#520)
-------------------------------------------------------------------------------
buku v4.6
2021-06-16
- use textwrap to wrap comments and tags when printing in terminal
- show listing start and end index over prompt in interactive mode
- option `--nostdin`: don't wait for input (must be first arg) (#513)
- user-friendly prompt message when watiing for input in non-tty mode
- several test framework improvements
-------------------------------------------------------------------------------
buku v4.5
2020-12-29
- Fix encryption and decryption (#480)
- Fix Wayback Machine API query
- Support wayland native copier `wl-copy`
- Add bookmarklet for bukuserver (#385)
- Delete by tag without prompting for each bookmark (#458)
- Fix issue with utf-8 characters in bookmark titles (#456)
- Fix reomve all tags from prompt (#467)
- Example to fuzzy search and preview in Quickstart section
- Replace debug option `-z` with `-g`
- Support python 3.9, retire python 3.5
-------------------------------------------------------------------------------
buku v4.4
2020-06-16
- optionally specify output file with `--json`
- confirm auto-tag generation in chatty mode
- unblock GUI browsers when running on WSL
- handle up to 10 server redirects (#452)
- fix issue with reverse proxy (#435)
- use ImportError instead ModuleNotFoundError (#437)
- import pyreadline on windows (#441)
- auto-generated package refresh
-------------------------------------------------------------------------------
Buku v4.3
2020-01-31
- Project renamed to `buku` (small `b`)
- Export tags in markdown format as comments
- Tag support for Org import/export
- Better Windows 10 support
- Reverse proxy support for `bukuserver`
- Config `OPEN_IN_NEW_TAB` for `bukuserver`
- Documentation updated
- Fix Firefox default profile detection
- Fix export to DB failing after search
- Fix broken prompt colors
- User agent updated
-------------------------------------------------------------------------------
Buku v4.2.2
2019-05-02
- Fixes broken prompt due to PR #373
-------------------------------------------------------------------------------
Buku v4.2.1
2019-04-30
- A fix on top of v4.2 to address a packaging problem
-------------------------------------------------------------------------------
Buku v4.2
2019-04-30
- Disabled appending tags from page on update
- Improved Windows color support using colorama (optional dep)
- New format option to show only title and tag
- Python 3.4 is EOL, support discontinued
- Several fixes and code refactor
-------------------------------------------------------------------------------
Buku v4.1
2019-01-15
What's in?
- Import firefox-exported json
- Fix auto-import for firefox
- Fix write to GNU Screen paste buffer
- Some CVE fixes
-------------------------------------------------------------------------------
Buku v4.0
2018-11-01
What's in?
- Show records in pages with option `-p` (works with `-n`, default 10)
- Enhanced clipboard support: `xclip`, tmux, GNU Screen, Termux
- Prompt key `O` works with search results along with GUI browser toggling
- Search by taglist id with prompt key `g`
- Multiple fixes
-------------------------------------------------------------------------------
Buku v3.9
2018-08-30
What's in?
- Set number of search results per page (default 10)
- Retrieve description and tags from page, if available
- Visit cached version on Wayback Machine
- Export works with all search options now
- Changed user agent to Firefox on Ubuntu
- Several dependencies made _required_ for installation
- bukuserver will use Flask-Admin
-------------------------------------------------------------------------------
Buku v3.8
2018-05-24
What's in?
- A self-hosted http server, bukuserver, that exposes core functionality
- browsable front-end on a local web host server
- flask default cli interface is used instead custom one
- handle not only API but also HTML request
- statistic page
- CRUD on bookmark
- replaces the earlier API module
- Import complete folder hierarchy as tags during auto-import
- Merge tags on import even if bookmark URL exists
- Orgfile import/export
- Show bookmarks to be deleted before deletion
- Merge tags during import if bookmark exists
- Escape regex metacharacters in regex input
-------------------------------------------------------------------------------
Buku v3.7
2018-03-28
What's in?
- Exclude keywords in search (keyword filtering)
- Search and filter by tags
- Order search results by number of keyword matches
- Copy URL to clipboard
- Prompt shortcut 'O' to override text browsers
- New official packagers: Fedora, Gentoo, OpenBSD, openSUSE
-------------------------------------------------------------------------------
Buku v3.6
2018-01-09
What's in?
- Skip bookmark addition if edit is aborted
- Use urllib3 for handling http connections everywhere
- Fix auto-import on FreeBSD
- Generate packages for openSUSE Leap 42.3, Fedora 27
-------------------------------------------------------------------------------
Buku v3.5
2017-11-10
What's in?
- Buku now has its own user agent
- Search works with field filters
- Edit the last record with `-w=-1` (useful when adding bookmark from GUI)a
- Support for Chromium browser
- Colors disabled by default on cmd (Windows), option `--colors` has to be used
- Get default Firefox profile name from profiles.ini
- Bash scriptlet to autogen records for testing
- Some optimization in add record and suggest tags
- A fresh utility Pinku to import Pinboard bookmarks to Buku
-------------------------------------------------------------------------------
Buku v3.4
2017-09-18
What's in?
- Export bookmarks (including specific tags) to Buku DB file using `--export`
- Option `--import` can merge Buku DB files now, option `--merge` is retired
- Option `--suggest` now works at prompt as well
- Auto-import issue when Firefox is not installed fixed
-------------------------------------------------------------------------------
Buku v3.3.1
2017-09-11
This is for all purposes the same as v3.3. We had to re-upload a new version to
PyPi and hence the new tag. Functionality remains the same.
The tagline is changed to - `Powerful command-line bookmark manager.`
-------------------------------------------------------------------------------
Buku v3.3
2017-09-11
What's in?
- Auto-import (`--ai`) bookmarks from Firefox and Google Chrome
- Support custom colors (`--colors`)
- Search multiple tags (with exclusion)
- Timestamp (YYYYMonDD) tag in auto-imported bookmarks
- Enable browser output for text browsers
- Generate documentation in RTD using Sphinx (http://buku.readthedocs.io)
- Integrated flake8 and pylint in Travis CI
- Integrated PackageCore to auto-generate packages in Travis CI
-------------------------------------------------------------------------------
Buku v3.2
2017-08-03
What's in?
- Option `--suggest` to list and choose similar tags when adding a bookmark
- Ask for a unique tag when importing bookmarks
- Ignore non-generic URLs when importing browser exported bookmarks
-------------------------------------------------------------------------------
Buku v3.1
2017-06-30
What's in?
- Handle negative indices (like tail) with option `-p`
- Support browsing bookmarks from prompt (key `o`)
- Add program search keywords to history
- Support XDG_DATA_HOME and HOME as env vars on all platforms
- Replace %USERPROFILE% with %APPDATA% as install location on Windows
-------------------------------------------------------------------------------
Buku v3.0
2017-04-26
What's in?
- Edit bookmarks in EDITOR at prompt
- Import folder names as tags from browser HTML (thanks @mohammadKhalifa)
- Append, overwrite, delete tags at prompt using >>, >, << (familiar, eh? ;))
- Negative indices with `--print` (like `tail`)
- Update in EDITOR along with `--immutable`
- Request HTTP HEAD for immutable records
- Interface revamp (title on top in bold, colour changes...)
- Per-level colourful logs in colour mode
- Changes in program OPTIONS
- `-t` stands for tag search (earlier `--title`)
- `-r` stands for regex search (earlier `--replace`)
- Lots of new automated test cases (thanks @rachmadaniHaryono)
- REST APIs for server-side apps (thanks @kishore-narendran)
- Document, notify behaviour when not invoked from tty (thanks @The-Compiler)
- Fix Firefox tab-opening issues on Windows (thanks @dertuxmalwieder)
-------------------------------------------------------------------------------
Buku v2.9
2017-02-20
Modifications
- New option `--write` to compose and edit bookmarks in text editor
- Support positional arguments as search keywords
- New option `--oa` to search and open results directly in browser
- Autodetect Markdown mode by file extension during export, import
- Shortened options:
- `--nc` replaces `--nocolor`
- `--np` replaces `--noprompt`
- `-V` replaces `--upstream`
- Option `--markdown` removed as the mode is autodetected now
-------------------------------------------------------------------------------
Buku v2.8
2017-01-11
Modifications
- Multithreaded full DB refresh with delayed writes
- Customize number of threads for full DB refresh (default 4)
- Support search and update search results in a go
- Support shortened URL expansion
- Support multiple bookmarks with `--open`
- Support `--nocolor` (for scripting, Windows users)
- Support https_proxy with `--upstream` and `--shorten`
- Remove trailing `/` from search tokens (like Google search)
- Support `--version` to show program version
- Fixed #109: Missing + when shortening URL
- Performance optimizations, few module dependency removals
-------------------------------------------------------------------------------
Buku v2.7
2016-11-30
Modifications
- Continuous search at (redesigned) prompt
- urllib3 for all HTTP operations
- Use HTTP HEAD method for pdf and txt mime types
- Add user agent (Firefox 50 on Ubuntu)
- Support URL shortening
- List bookmarks by tag index in tag list
- Show tag usage count in tag list
- Store tags in lowercase (use undocumented option `--fixtags` to fix old tags)
- Support environment variable *https_proxy*
- Support option `--immutable` to pin titles
- Keyword `immutable` to search (`-S`) pinned titles
- Show index in JSON output
- New key *q* to quit prompt
- Support deflate compression
- Add option `--tacit` to reduce verbosity of some operations
- **Removed** option `--st`, only `--stag` to search tags
- Support custom DB file location (for library, not exposed to user)
-------------------------------------------------------------------------------
Buku v2.6
2016-11-04
Modifications
- Support Markdown import/export
- Support regex search
- New option `--upstream` to check latest upstream version
- Fix search and delete behaviour
- Lot of code reformatting, performance improvements
- Use delayed commit wherever possible (e.g. bulk deletion cases)
- When a range is specified, consider 0 as ALL
- Added option to control verbosity in some APIs
- In-source documentation update
-------------------------------------------------------------------------------
Buku v2.5
2016-10-20
Modifications
- Export specific tags to HTML
- Fixed obvious issues on Windows
- Open a random bookmark with option --open
- Support lists and ranges with --print
- Show a bookmark on tag append
- Show only title with --format=3
- PEP8 compliance fixes
- Buku GUI integration documented
-------------------------------------------------------------------------------
Buku v2.4
2016-09-12
Modifications
- Exact word match support using regex (**default**)
- New option --deep to scan matching substrings
- Support DB index lists and ranges in update operation
- Open a list or range of search results in browser
- Open all search results in browser
- A more concise prompt
- PEP8 compliance (almost)
- Tons of new test cases added (thanks @wheresmyjetpack)
-------------------------------------------------------------------------------
Buku v2.3
2016-07-14
Modifications
- Delete a range or a list of indices
- Delete tag from tagset by bookmark index
- Delete results of a particular search
- Linked to rofi front-end script project for Buku
- Use the logging framework for debug info instead of print
- Fixed an issue with gzip stream decoding
- Using only relative path to fetch resource on server
- Fixed auto-completion errors with Zsh
- A lot of code cleanup and globals removed, additional test cases
-------------------------------------------------------------------------------
Buku v2.2
2016-06-12
Modifications
- Export bookmarks to Firefox bookmarks formatted HTML
- Merge Buku database
- .deb package for Debian and Ubuntu family
- Switch from PyCrypto to cryptography (thanks @asergi)
- Append tags support
- Filter tags for duplicates and sort alphabetically
- Travis CI integration, more test cases (thanks @poikjhn)
- Show DB index in bold in search results
- Several performance optimizations
-------------------------------------------------------------------------------
Buku v2.1
2016-05-28
Modifications
- Import bookmarks from Firefox, Google Chrome or IE HTML bookmark exports
- Support comments on bookmarks
- Prettier output using symbols (`>` title, `+` comments, `#` tags)
- New option (`--st`, `--stag`) to search by tag
- New option (`--noprompt`) for noninteractive mode
- New options (`--url` and `--tag`)
- `--update` now handles each option (url, tag, title, comment) independently
- Several messages removed or moved to debug
-------------------------------------------------------------------------------
Buku v2.0
2016-05-15
Modifications
To begin with, 2.0 is a significant release with respect to options. `Buku` now has fewer options with more (and merged) functionality. Please go through the program help at least once to understand the changes.
- Replace getopt with argparse for parsing arguments
- Long options for each short option
- Options changed
- insert: removed as automatic DB compaction serves the purpose (previously `-i`)
- iterations: removed as optional argument to `-l` and `-k` (previously `-t`)
- title: `-t` is now the short option to set title manually (previously `-m`)
- Special search keywords for ALL search (`-S`):
- tags: show all tags (previously `-g`)
- blank: show bookmarks with empty tags (previously `-e`)
- lock/unlock: now accepts number of hash iterations to generate key
- format: print formatting option changed to `-f` (previously `-x`)
- help: option added to show program help
- Following options apply to ALL bookmarks without arguments
- `-u`, `--update`
- `-d`, `--delete`
- `-p`, `--print`
- Shell-completion scripts for Bash, Fish and Zsh
- Warn if URL is not HTTP(S)
- More comprehensive help
- Fix a bug with deletion when only one entry in DB
- Some import dependencies removed or late loaded (if optional)
- Handle exception if DB file is encrypted or invalid
-------------------------------------------------------------------------------
Buku v1.9
2016-04-23
Modifications
- **New location for database file** (refer to README or man page). The old database file, if exists, is migrated automatically.
- **Removed options**
- `-P`: (print all) is now `-p 0`
- `-D`: (delete all) is now `-d 0`
- `-R`: (update all) is now `-u 0`
- `-w`: title web fetch is now the default behaviour, override with `-m title` option
- **Change in search behaviour**
- `-s`: search bookmarks for ANY keyword in URL, title or tags
- `-S`: search bookmarks for ALL keywords in URL, title or tags
- Update only title of a bookmark (`-u N`)
- Set empty title (`-m none`)
- Support HTTP(S) gzip compression
- Optional JSON output for `-p` and `-s` options (thanks @CaptainQuirk)
- Reformatted help and man page with general options on top
- Optimize add and insert: ensure URL is not in DB already
- Handle URLs passed with %xx escape
- Retry with truncated resource path on HTTP error 500
- Several code optimizations
- Catchier errors and warnings
- Version added to debug logs
-------------------------------------------------------------------------------
Buku v1.8
2016-03-26
Modifications
- Auto compact DB on single record removal
- Handle piped input
- Better tag management
- Tag modify or delete support
- Show unique tags alphabetically
- Full DB refresh
- Fix stuff broken earlier
- Optimize to update titles only
- Update titles only if non-empty to preserve earlier data
- Redirection
- Handle multiple redirections
- Detect redirection loop and break
- Show redirected link in bold
- List all bookmarks with no title or tags (for manual bookkeeping)
- Confirm full DB removal
- Better comma (`,`) separator handling for tags
- Help
- Place regular options before power options in program help
- Help added in man page for quick reference
- Additional examples for new features
- Errors & warnings
- Error out if both encrypted and flat DB files exist
- Catchier error and warning messages
-------------------------------------------------------------------------------
Buku v1.7
2016-03-15
Modifications
- Add title manually using option `-m`
- Unquote redirected URL
- Quit on `Ctrl-d` at prompt
- More dynamic shebang for python3
-------------------------------------------------------------------------------
Buku v1.6
2016-01-22
Modifications
- Stronger encryption: 256-bit salt, multi-hash key.
- Allow user to specify number of iterations to generate key (check option `-t`).
-------------------------------------------------------------------------------
Buku v1.5
2015-12-20
Modifications
- Project name changed to `Buku` to avoid any copyright issues. This also means old users have to move the database file. Run:
- Manual AES-256 encryption and decryption support (password protection) implemented. This adds dependency on PyCrypto module. Installation instructions updated in README.
- Some typos fixed (thanks @GuilhermeHideki)
-------------------------------------------------------------------------------
MarkIt v1.4
2015-11-13
Modifications
- Refresh full bookmark database. Fetch titles from the web, retain tags.
- Notify empty titles in red during online add or update.
-------------------------------------------------------------------------------
MarkIt v1.2
2015-11-11
Modifications
- Introduced `-S` search option to match ALL keywords in URL or title
- Introduced `-x` option to show unformatted selective output (for creating batch scripts)
- Added examples on batch add and update (refresh) scripts
- Handle multiple title tags in page
- Handle title data within another tag (e.g. head)
- Show DB index in search results, removal and update confirmation message
-------------------------------------------------------------------------------
MarkIt v1.1
2015-11-10
Modifications
- Replace Unicode chars in title data before UTF-8 decoding (for parser to succeed).
-------------------------------------------------------------------------------
================================================
FILE: Dockerfile
================================================
FROM python:3.14.0-alpine
LABEL org.opencontainers.image.authors="shenoy.ameya@gmail.com"
ENV BUKUSERVER_PORT=5001
COPY . /buku
ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1
RUN set -ex \
&& apk add --no-cache --virtual .build-deps \
gcc \
openssl-dev \
musl-dev \
libffi-dev \
&& pip install -U --no-cache-dir \
pip \
gunicorn \
/buku[server] \
&& apk del .build-deps \
&& echo "import sys; bind = '0.0.0.0:${BUKUSERVER_PORT}'" > 'gunicorn.conf.py' \
&& rm -rf /buku
HEALTHCHECK --interval=1m --timeout=10s \
CMD nc -z 127.0.0.1 ${BUKUSERVER_PORT} || exit 1
ENTRYPOINT ["gunicorn", "bukuserver.server:create_app()"]
EXPOSE ${BUKUSERVER_PORT}
================================================
FILE: LICENSE
================================================
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
{one line to give the program's name and a brief idea of what it does.}
Copyright (C) {year} {name of author}
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
{project} Copyright (C) {year} {fullname}
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
.
================================================
FILE: MANIFEST.in
================================================
include CHANGELOG LICENSE README.md buku.1 requirements.txt
include tests/test_bukuDb/Bookmarks
recursive-include tests *.py
recursive-include tests/test_bukuDb *.yaml
recursive-include auto-completion *
recursive-include bukuserver/apidocs *
recursive-include bukuserver/templates *
recursive-include bukuserver/static *
================================================
FILE: Makefile
================================================
PREFIX ?= /usr/local
BINDIR ?= $(DESTDIR)$(PREFIX)/bin
MANDIR ?= $(DESTDIR)$(PREFIX)/share/man/man1
DOCDIR ?= $(DESTDIR)$(PREFIX)/share/doc/buku
.PHONY: all install uninstall
all:
install:
install -m755 -d $(BINDIR)
install -m755 -d $(MANDIR)
install -m755 -d $(DOCDIR)
gzip -c buku.1 > buku.1.gz
install -m755 buku.py $(BINDIR)/buku
install -m644 buku.1.gz $(MANDIR)
install -m644 README.md $(DOCDIR)
rm -f buku.1.gz
uninstall:
rm -f $(BINDIR)/buku
rm -f $(MANDIR)/buku.1.gz
rm -rf $(DOCDIR)
================================================
FILE: README.md
================================================
buku
buku in action!
### Introduction
`buku` is a powerful bookmark manager and a personal textual mini-web.
For those who prefer the GUI, `bukuserver` exposes a browsable front-end on a local web host server. See [bukuserver page](https://github.com/jarun/buku/tree/master/bukuserver#readme) for config and screenshots.
When I started writing it, I couldn't find a flexible command-line solution with a private, portable, merge-able database along with seamless GUI integration. Hence, `buku`.
`buku` can import bookmarks from browser(s) or fetch the title, tags and description of a URL from the web. Use your favourite editor to add, compose and update bookmarks. Search bookmarks instantly with multiple search options, including regex and a deep scan mode (handy with URLs).
It can look up broken links on the Wayback Machine. There's an Easter Egg to revisit random bookmarks.
There's no tracking, hidden history, obsolete records, usage analytics or homing.
To get started right away, jump to the [Quickstart](#quickstart) section. `buku` has one of the best documentation around. The man page comes with examples. For internal details, please refer to the [operational notes](https://github.com/jarun/buku/wiki/Operational-notes).
`buku` is a library too! There are several related projects, including a browser plug-in.
### Table of Contents
- [Features](#features)
- [Installation](#installation)
- [Dependencies](#dependencies)
- [From a package manager](#from-a-package-manager)
- [Release packages](#release-packages)
- [From source](#from-source)
- [Running standalone](#running-standalone)
- [Shell completion](#shell-completion)
- [Usage](#usage)
- [Command-line options](#command-line-options)
- [Colors](#colors)
- [Quickstart](#quickstart)
- [Examples](#examples)
- [Automation](#automation)
- [Troubleshooting](#troubleshooting)
- [Editor integration](#editor-integration)
- [Collaborators](#collaborators)
- [Contributions](#contributions)
- [Related projects](#related-projects)
- [In the Press](#in-the-press)
### Features
- Store bookmarks with auto-fetched title, tags and description
- Auto-import from Firefox, Google Chrome, Chromium, Vivaldi, Brave, and MS Edge
- Open bookmarks and search results in browser
- Browse cached page from the Wayback Machine
- Text editor integration
- Lightweight, clean interface, custom colors
- Powerful search options (regex, substring...)
- Continuous search with on the fly mode switch
- Portable, merge-able database to sync between systems
- Import/export bookmarks from/to HTML, XBEL, Markdown, RSS/Atom or Orgfile
- Smart tag management using redirection (>>, >, <<)
- Multi-threaded full DB refresh
- Manual encryption support
- Shell completion scripts, man page with handy examples
- Privacy-aware (no unconfirmed user data collection)
- Can be used as a Python library ([_API documentation_](https://buku.readthedocs.io/en/latest/?badge=latest))
- Has a compation Web-application ([Bukuserver](https://github.com/jarun/buku/wiki/Bukuserver-%28WebUI%29)) with an HTTP-based API (for personal use only)
### Installation
#### Dependencies
| Feature | Dependency |
| --- | --- |
| Lang, SQLite | Python 3.10+ |
| HTTPS | certifi, urllib3 |
| Encryption | cryptography |
| HTML | beautifulsoup4, html5lib |
To copy URL to clipboard `buku` uses `xsel` (or `xclip`) on Linux, `pbcopy` (default installed) on OS X, `clip` (default installed) on Windows, `termux-clipboard` on Termux (terminal emulation for Android), `wl-copy` on Wayland. If X11 is missing, GNU Screen or tmux copy-paste buffers are recognized.
#### From a package manager
To install buku with all its dependencies from PyPI, run:
# pip3 install buku
You can also install `buku` from your package manager. If the version available is dated try an alternative installation method.
Packaging status (expand)
'
return {'data': out, 'count': count}
def get_firefox_profile_names(path):
"""List folder and detect default Firefox profile names for all installs.
Returns
-------
profiles : [str]
All default Firefox profile names.
"""
from configparser import ConfigParser, NoOptionError
profiles = []
profile_path = os.path.expanduser(os.path.join(path, 'profiles.ini'))
if os.path.exists(profile_path):
config = ConfigParser()
config.read(profile_path)
install_names = [section for section in config.sections() if section.startswith('Install')]
for name in install_names:
try:
profiles += [config.get(name, 'default')]
except NoOptionError:
pass
if profiles:
return profiles
profiles_names = [section for section in config.sections() if section.startswith('Profile')]
for name in profiles_names:
try:
# If profile is default
if config.getboolean(name, 'default'):
profiles += [config.get(name, 'path')]
continue
except NoOptionError:
pass
try:
# alternative way to detect default profile
if config.get(name, 'name').lower() == "default":
profiles += [config.get(name, 'path')]
except NoOptionError:
pass
return profiles
# There are no default profiles
LOGDBG('get_firefox_profile_names(): {} does not exist'.format(path))
return profiles
def get_firefox_db_paths(default_ff_folder, specified=None):
profiles = ([specified] if specified else get_firefox_profile_names(default_ff_folder))
_profile_path = lambda s: (s if os.path.isabs(s) else os.path.join(default_ff_folder, s))
return {s: os.path.join(_profile_path(s), 'places.sqlite') for s in profiles}
def walk(root):
"""Recursively iterate over JSON.
Parameters
----------
root : JSON element
Base node of the JSON data.
"""
for element in root['children']:
if element['type'] == 'url':
url = element['url']
title = element['name']
yield (url, title, None, None, 0, True)
else:
walk(element)
def import_md(filepath: str, newtag: Optional[str]):
"""Parse bookmark Markdown file.
Parameters
----------
filepath : str
Path to Markdown file.
newtag : str, optional
New tag for bookmarks in Markdown file.
Returns
-------
tuple
Parsed result.
"""
# Supported Markdown format: `[title](url) ` (or ` `)
_named_link, _raw_link = r'\[(?P.*)\]\((?P.+)\)', r'\<(?P[^!>][^>]*)\>'
pattern = re.compile(r'(%s|%s)(\s+)?' % (_named_link, _raw_link))
with open(filepath, mode='r', encoding='utf-8') as infp:
for line in infp:
if match := pattern.search(line):
title = match.group('title') or ''
url = match.group('url') or match.group('url_raw')
if is_nongeneric_url(url):
continue
tags = DELIM.join(s for s in [newtag, match.group('tags')] if s)
tags = parse_tags([tags])
yield (url, title, delim_wrap(tags), None, 0, True, False)
def import_rss(filepath: str, newtag: Optional[str]):
"""Parse bookmark RSS file.
Parameters
----------
filepath : str
Path to RSS file.
newtag : str, optional
New tag for bookmarks in RSS file.
Returns
tuple
Parsed result.
"""
with open(filepath, mode='r', encoding='utf-8') as infp:
ns = {'atom': 'http://www.w3.org/2005/Atom'}
root = ET.fromstring(infp.read())
for entry in root.findall('atom:entry', ns):
title = entry.find('atom:title', ns).text
url = entry.find('atom:link', ns).attrib['href']
tags = ','.join([tag.attrib['term'] for tag in entry.findall('atom:category', ns)])
if newtag is not None:
tags = newtag + ',' + tags
desc = entry.find('atom:content', ns)
desc = desc.text if desc is not None else None
yield (url, title, delim_wrap(tags), desc, 0, True, False)
def import_org(filepath: str, newtag: Optional[str]):
"""Parse bookmark org file.
Parameters
----------
filepath : str
Path to org file.
newtag : str, optional
New tag for bookmarks in org file.
Returns
-------
tuple
Parsed result.
"""
def get_org_tags(tag_string):
"""Extracts tags from Org
Parameters
----------
tag_string: str
string of tags in Org-format
Syntax: Org splits tags with colons. If colons are part of a buku-tag, this is indicated by using
multiple colons in org. If a buku-tag starts or ends with a colon, this is indicated by a
preceding or trailing whitespace
Returns
-------
list
List of tags
"""
tag_list_raw = [s for s in re.split(r'(?((?!\]\[).)+?)', r'(\]\[(?P.+))?'
pattern = re.compile(r'\[\[%s%s\]\](?P\s+:.*:)?' % (_url, _maybe_title))
with open(filepath, mode='r', encoding='utf-8') as infp:
for line in infp:
if match := pattern.search(line):
title = match.group('title') or ''
url = match.group('url')
if is_nongeneric_url(url):
continue
tags = list(dict.fromkeys(get_org_tags(match.group('tags') or '')))
tags_string = DELIM.join(tags)
if newtag and newtag.lower() not in tags:
tags_string = (newtag + DELIM) + tags_string
yield (url, title, delim_wrap(tags_string), None, 0, True, False)
def import_firefox_json(json, add_bookmark_folder_as_tag=False, unique_tag=None):
"""Open Firefox JSON export file and import data.
Ignore 'SmartBookmark' and 'Separator' entries.
Needed/used fields out of the JSON schema of the bookmarks:
title : the name/title of the entry
tags : ',' separated tags for the bookmark entry
typeCode : 1 - uri, 2 - subfolder, 3 - separator
annos/{name,value} : following annotation entries are used
name : Places/SmartBookmark : identifies smart folder, ignored
name : bookmarkPropereties/description : detailed bookmark entry description
children : for subfolders, recurse into the child entries
Parameters
----------
path : str
Path to Firefox JSON bookmarks file.
unique_tag : str
Timestamp tag in YYYYMonDD format.
add_bookmark_folder_as_tag : bool
True if bookmark parent folder should be added as tags else False.
"""
class TypeCode(Enum):
""" Format
typeCode
1 : uri (type=text/x-moz-place)
2 : subfolder (type=text/x-moz-container)
3 : separator (type=text/x-moz-separator)
"""
uri = 1
folder = 2
separator = 3
def is_smart(entry):
result = False
try:
d = [anno for anno in entry['annos'] if anno['name'] == "Places/SmartBookmark"]
result = bool(len(d))
except Exception:
result = False
return result
def extract_desc(entry):
try:
d = [
anno for anno in entry['annos']
if anno['name'] == "bookmarkProperties/description"
]
return d[0]['value']
except Exception:
LOGDBG("ff_json: No description found for entry: {} {}".format(entry['uri'], entry['title']))
return ""
def extract_tags(entry):
tags = []
try:
tags = entry['tags'].split(',')
except Exception:
LOGDBG("ff_json: No tags found for entry: {} {}".format(entry['uri'], entry['title']))
return tags
def iterate_children(parent_folder, entry_list):
for bm_entry in entry_list:
entry_title = bm_entry['title'] if 'title' in bm_entry else ""
try:
typeCode = bm_entry['typeCode']
except Exception:
LOGDBG("ff_json: item without typeCode found, ignoring: {}".format(entry_title))
continue
LOGDBG("ff_json: processing typeCode '{}', title '{}'".format(typeCode, entry_title))
if TypeCode.uri.value == typeCode:
try:
if is_smart(bm_entry):
LOGDBG("ff_json: SmartBookmark found, ignoring: {}".format(entry_title))
continue
if is_nongeneric_url(bm_entry['uri']):
LOGDBG("ff_json: Non-Generic URL found, ignoring: {}".format(entry_title))
continue
desc = extract_desc(bm_entry)
bookmark_tags = extract_tags(bm_entry)
# if parent_folder is not "None"
if add_bookmark_folder_as_tag and parent_folder:
bookmark_tags.append(parent_folder)
if unique_tag:
bookmark_tags.append(unique_tag)
formatted_tags = [DELIM + tag for tag in bookmark_tags]
tags = parse_tags(formatted_tags)
LOGDBG("ff_json: Entry found: {}, {}, {}, {} " .format(bm_entry['uri'], entry_title, tags, desc))
yield (bm_entry['uri'], entry_title, tags, desc, 0, True, False)
except Exception as e:
LOGERR("ff_json: Error parsing entry '{}' Exception '{}'".format(entry_title, e))
elif TypeCode.folder.value == typeCode:
# ignore special bookmark folders
if 'root' in bm_entry and bm_entry['root'] in IGNORE_FF_BOOKMARK_FOLDERS:
LOGDBG("ff_json: ignoring root folder: {}" .format(entry_title))
entry_title = None
if "children" in bm_entry:
yield from iterate_children(entry_title, bm_entry['children'])
else:
# if any of the properties does not exist, bail out silently
LOGDBG("ff_json: No 'children' found in bookmark folder - skipping: {}".format(entry_title))
elif TypeCode.separator.value == typeCode:
# ignore separator
pass
else:
LOGDBG("ff_json: Unknown typeCode found : {}".format(typeCode))
if "children" in json:
main_entry_list = json['children']
else:
LOGDBG("ff_json: No children in Root entry found")
return []
yield from iterate_children(None, main_entry_list)
def import_xbel(html_soup: BeautifulSoup, add_parent_folder_as_tag: bool, newtag: str, use_nested_folder_structure: bool = False):
"""Parse bookmark XBEL.
Parameters
----------
html_soup : BeautifulSoup object
BeautifulSoup representation of bookmark HTML.
add_parent_folder_as_tag : bool
True if bookmark parent folders should be added as tags else False.
newtag : str
A new unique tag to add to imported bookmarks.
use_nested_folder_structure: bool
True if all bookmark parent folder should be added, not just direct parent else False
add_parent_folder_as_tag must be True for this flag to have an effect
Returns
-------
tuple
Parsed result.
"""
# compatibility
soup = html_soup
for tag in soup.find_all('bookmark'):
# Extract comment from tag
try:
if is_nongeneric_url(tag['href']):
continue
except KeyError:
continue
title_tag = tag.title.string
desc = None
comment_tag = tag.find_next_sibling('desc')
if comment_tag:
desc = comment_tag.find(text=True, recursive=False)
if add_parent_folder_as_tag:
# add parent folder as tag
if use_nested_folder_structure:
# New method that would generalize for else case to
# structure of folders
# folder
# title (folder name)
# folder
# title
# bookmark (could be h3, and continue recursively)
parents = tag.find_parents('folder')
for parent in parents:
header = parent.find_previous_sibling('title')
if header:
if tag.has_attr('tags'):
tag['tags'] += (DELIM + header.text)
else:
tag['tags'] = header.text
else:
# could be its folder or not
possible_folder = tag.find_previous('title')
# get list of tags within that folder
tag_list = tag.parent.parent.find_parent('folder')
if ((possible_folder) and possible_folder.parent in list(tag_list.parents)):
# then it's the folder of this bookmark
if tag.has_attr('tags'):
tag['tags'] += (DELIM + possible_folder.text)
else:
tag['tags'] = possible_folder.text
# add unique tag if opted
if newtag:
if tag.has_attr('tags'):
tag['tags'] += (DELIM + newtag)
else:
tag['tags'] = newtag
yield (
tag['href'], title_tag,
parse_tags([tag['tags']]) if tag.has_attr('tags') else None,
desc if not desc else desc.strip(), 0, True, False
)
def import_html(html_soup: BeautifulSoup, add_parent_folder_as_tag: bool, newtag: str, use_nested_folder_structure: bool = False):
"""Parse bookmark HTML.
Parameters
----------
html_soup : BeautifulSoup object
BeautifulSoup representation of bookmark HTML.
add_parent_folder_as_tag : bool
True if bookmark parent folders should be added as tags else False.
newtag : str
A new unique tag to add to imported bookmarks.
use_nested_folder_structure: bool
True if all bookmark parent folder should be added, not just direct parent else False
add_parent_folder_as_tag must be True for this flag to have an effect
Returns
-------
tuple
Parsed result.
"""
# compatibility
soup = html_soup
for tag in soup.find_all('a'):
# Extract comment from
tag
try:
if is_nongeneric_url(tag['href']):
continue
except KeyError:
continue
desc = None
comment_tag = tag.find_next_sibling('dd')
if comment_tag:
desc = comment_tag.find(string=True, recursive=False)
if add_parent_folder_as_tag:
# New method that would generalize for else case to
# structure of folders
# dt
# h3 (folder name)
# dl
# dt
# a (could be h3, and continue recursively)
parents = tag.find_parents('dl')
for parent in (parents if use_nested_folder_structure else parents[:1]):
header = parent.find_previous_sibling('h3')
if header:
if tag.has_attr('tags'):
tag['tags'] += (DELIM + strip_delim(header.text))
else:
tag['tags'] = strip_delim(header.text)
# add unique tag if opted
if newtag:
if tag.has_attr('tags'):
tag['tags'] += (DELIM + strip_delim(newtag))
else:
tag['tags'] = strip_delim(newtag)
yield (
tag['href'], tag.string,
parse_tags([tag['tags']]) if tag.has_attr('tags') else None,
desc if not desc else desc.strip(), 0, True, False
)
def get_netloc(url):
"""Get the netloc token, or None."""
try:
netloc = urlparse(url).netloc
if not netloc and not urlparse(url).scheme:
# Try to prepend '//' and get netloc
netloc = urlparse('//' + url).netloc
return netloc or None
except Exception as e:
LOGERR('%s, URL: %s', e, url)
return None
def is_bad_url(url):
"""Check if URL is malformed.
.. Note:: This API is not bulletproof but works in most cases.
Parameters
----------
url : str
URL to scan.
Returns
-------
bool
True if URL is malformed, False otherwise.
"""
netloc = get_netloc(url)
if not netloc:
return True
LOGDBG('netloc: %s', netloc)
# netloc cannot start or end with a '.'
if netloc.startswith('.') or netloc.endswith('.'):
return True
# netloc should have at least one '.'
return '.' not in netloc
def is_nongeneric_url(url):
"""Returns True for URLs which are non-http and non-generic.
Parameters
----------
url : str
URL to scan.
Returns
-------
bool
True if URL is a non-generic URL, False otherwise.
"""
ignored_prefix = [
'about:',
'apt:',
'chrome://',
'file://',
'place:',
'vivaldi://',
]
for prefix in ignored_prefix:
if url.startswith(prefix):
return True
return False
def is_ignored_mime(url):
"""Check if URL links to ignored MIME.
.. Note:: Only a 'HEAD' request is made for these URLs.
Parameters
----------
url : str
URL to scan.
Returns
-------
bool
True if URL links to ignored MIME, False otherwise.
"""
for mime in SKIP_MIMES:
if url.lower().endswith(mime):
LOGDBG('matched MIME: %s', mime)
return True
return False
def is_unusual_tag(tagstr):
"""Identify unusual tags with word to comma ratio > 3.
Parameters
----------
tagstr : str
tag string to check.
Returns
-------
bool
True if valid tag else False.
"""
if not tagstr:
return False
nwords = len(tagstr.split())
ncommas = tagstr.count(',') + 1
if nwords / ncommas > 3:
return True
return False
def parse_decoded_page(page):
"""Fetch title, description and keywords from decoded HTML page.
Parameters
----------
page : str
Decoded HTML page.
Returns
-------
tuple
(title, description, keywords).
"""
title = ''
desc = ''
keys = ''
soup = BeautifulSoup(page, 'html5lib')
try:
title = soup.find('title').text.strip().replace('\n', ' ')
if title:
title = re.sub(r'\s{2,}', ' ', title)
except Exception as e:
LOGDBG(e)
description = (soup.find('meta', attrs={'name':'description'}) or
soup.find('meta', attrs={'name':'Description'}) or
soup.find('meta', attrs={'property':'description'}) or
soup.find('meta', attrs={'property':'Description'}) or
soup.find('meta', attrs={'name':'og:description'}) or
soup.find('meta', attrs={'name':'og:Description'}) or
soup.find('meta', attrs={'property':'og:description'}) or
soup.find('meta', attrs={'property':'og:Description'}))
try:
if description:
desc = description.get('content').strip()
if desc:
desc = re.sub(r'\s{2,}', ' ', desc)
except Exception as e:
LOGDBG(e)
keywords = (soup.find('meta', attrs={'name':'keywords'}) or
soup.find('meta', attrs={'name':'Keywords'}))
try:
if keywords:
keys = keywords.get('content').strip().replace('\n', ' ')
keys = re.sub(r'\s{2,}', ' ', re.sub(r'\s*,\s*', ',', keys))
if is_unusual_tag(keys):
if keys not in (title, desc):
LOGDBG('keywords to description: %s', keys)
if desc:
desc = desc + '\n## ' + keys
else:
desc = '* ' + keys
keys = ''
except Exception as e:
LOGDBG(e)
LOGDBG('title: %s', title)
LOGDBG('desc : %s', desc)
LOGDBG('keys : %s', keys)
return (title, desc, keys and keys.strip(DELIM))
def get_data_from_page(resp):
"""Detect HTTP response encoding and invoke parser with decoded data.
Parameters
----------
resp : HTTP response
Response from GET request.
Returns
-------
tuple
(title, description, keywords).
"""
try:
charset = EncodingDetector.find_declared_encoding(resp.data, is_html=True)
if not charset and 'content-type' in resp.headers:
m = email.message.Message()
m['content-type'] = resp.headers['content-type']
if m.get_param('charset') is not None:
charset = m.get_param('charset')
if charset:
LOGDBG('charset: %s', charset)
title, desc, keywords = parse_decoded_page(resp.data.decode(charset, errors='replace'))
else:
title, desc, keywords = parse_decoded_page(resp.data.decode(errors='replace'))
return (title, desc, keywords)
except Exception as e:
LOGERR(e)
return (None, None, None)
def extract_auth(url):
"""Convert an url into an (auth, url) tuple [the returned URL will contain no auth part]."""
_url = urlparse(url)
if _url.username is None: # no '@' in netloc
return None, url
auth = _url.username + ('' if _url.password is None else f':{_url.password}')
return auth, url.replace(auth + '@', '')
def gen_headers():
"""Generate headers for network connection."""
global MYHEADERS, MYPROXY
MYHEADERS = {
'Accept-Encoding': 'gzip,deflate',
'User-Agent': USER_AGENT,
'Sec-Fetch-Mode': 'navigate',
'Accept': '*/*',
'Cookie': '',
'DNT': '1'
}
MYPROXY = os.environ.get('https_proxy')
if MYPROXY:
try:
auth, MYPROXY = extract_auth(MYPROXY)
except Exception as e:
LOGERR(e)
return
# Strip username and password (if present) and update headers
if auth:
auth_headers = make_headers(basic_auth=auth)
MYHEADERS.update(auth_headers)
LOGDBG('proxy: [%s]', MYPROXY)
def get_PoolManager():
"""Creates a pool manager with proxy support, if applicable.
Returns
-------
ProxyManager or PoolManager
ProxyManager if https_proxy is defined, PoolManager otherwise.
"""
ca_certs = os.getenv('BUKU_CA_CERTS', default=CA_CERTS)
if MYPROXY:
return urllib3.ProxyManager(MYPROXY, num_pools=1, headers=MYHEADERS, timeout=15,
cert_reqs='CERT_REQUIRED', ca_certs=ca_certs)
return urllib3.PoolManager(
num_pools=1,
headers=MYHEADERS,
timeout=15,
cert_reqs='CERT_REQUIRED',
ca_certs=ca_certs)
def network_handler(
url: str,
http_head: bool = False
) -> Tuple[str, str, str, int, int]:
"""Handle server connection and redirections.
Deprecated; use fetch_data() instead.
Returns
-------
tuple
(title, description, tags, recognized mime, bad url)
"""
warn('\'buku.network_handler()\' is deprecated; use \'buku.fetch_data()\' instead.', DeprecationWarning)
result = fetch_data(url, http_head)
return (result.title, result.desc, result.keywords, int(result.mime), int(result.bad))
def fetch_data(
url: str,
http_head: bool = False
) -> FetchResult:
"""Handle server connection and redirections.
Parameters
----------
url : str
URL to fetch.
http_head : bool
If True, send only HTTP HEAD request. Default is False.
Returns
-------
FetchResult
(url, title, desc, keywords, mime, bad, fetch_status)
"""
page_status = None
page_url = url
page_title = ''
page_desc = ''
page_keys = ''
exception = False
if is_nongeneric_url(url) or is_bad_url(url):
return FetchResult(url, bad=True)
if is_ignored_mime(url) or http_head:
method = 'HEAD'
else:
method = 'GET'
if not MYHEADERS:
gen_headers()
try:
manager = get_PoolManager()
while True:
resp = manager.request(method, url, retries=Retry(redirect=10))
page_status = resp.status
if resp.status == 200:
if method == 'GET':
for retry in resp.retries.history:
if retry.status not in PERMANENT_REDIRECTS:
break
page_status, page_url = retry.status, retry.redirect_location
page_title, page_desc, page_keys = get_data_from_page(resp)
elif resp.status == 403 and url.endswith('/'):
# HTTP response Forbidden
# Handle URLs in the form of https://www.domain.com/
# which fail when trying to fetch resource '/'
# retry without trailing '/'
LOGDBG('Received status 403: retrying...')
# Remove trailing /
url = url[:-1]
resp.close()
continue
else:
page_title, page_desc, page_keys = get_data_from_page(resp)
LOGERR('[%s] %s', resp.status, resp.reason)
if resp:
resp.close()
break
except Exception as e:
LOGERR('fetch_data(): %s', e)
exception = True
if manager:
manager.clear()
if exception:
return FetchResult(url)
if method == 'HEAD':
return FetchResult(url, mime=True, fetch_status=page_status)
return FetchResult(page_url, title=page_title, desc=page_desc, keywords=page_keys, fetch_status=page_status)
def parse_tags(keywords=[], *, edit_input=False):
"""Format and get tag string from tokens.
Parameters
----------
keywords : list
List of tags to parse. Default is empty list.
edit_input : bool
Whether the taglist is an edit input (i.e. may start with '+'/'-').
Defaults to False.
Returns
-------
str
Comma-delimited string of tags.
DELIM : str
If no keywords, returns the delimiter.
None
If keywords is None.
"""
if keywords is None:
return None
tagstr = ' '.join(s for s in keywords if s)
if not tagstr:
return DELIM
if edit_input and keywords[0] in ('+', '-'):
return keywords[0] + parse_tags(keywords[1:])
# Cleanse and get the tags
marker = tagstr.find(DELIM)
tags = DELIM
while marker >= 0:
token = tagstr[0:marker]
tagstr = tagstr[marker + 1:]
marker = tagstr.find(DELIM)
token = token.strip()
if token == '':
continue
tags += token + DELIM
tagstr = tagstr.strip()
if tagstr != '':
tags += tagstr + DELIM
LOGDBG('keywords: %s', keywords)
LOGDBG('parsed tags: [%s]', tags)
if tags == DELIM:
return tags
# sorted unique tags in lowercase, wrapped with delimiter
return taglist_str(tags)
def prep_tag_search(tags: str) -> Tuple[List[str], Optional[str], Optional[str]]:
"""Prepare list of tags to search and determine search operator.
Parameters
----------
tags : str
String list of tags to search.
Returns
-------
tuple
(list of formatted tags to search,
a string indicating query search operator (either OR or AND),
a regex string of tags or None if ' - ' delimiter not in tags).
"""
exclude_only = False
# tags may begin with `- ` if only exclusion list is provided
if tags.startswith('- '):
tags = ' ' + tags
exclude_only = True
# tags may start with `+ ` etc., tricky test case
if tags.startswith(('+ ', ', ')):
tags = tags[2:]
# tags may end with ` -` etc., tricky test case
if tags.endswith((' -', ' +', ' ,')):
tags = tags[:-2]
# tag exclusion list can be separated by comma (,), so split it first
excluded_tags = None
if ' - ' in tags:
tags, excluded_tags = tags.split(' - ', 1)
excluded_taglist = [delim_wrap(re.escape(t.strip())) for t in excluded_tags.split(',')]
# join with pipe to construct regex string
excluded_tags = '|'.join(excluded_taglist)
if exclude_only:
search_operator = 'OR'
tags_ = ['']
else:
# do not allow combination of search logics in tag inclusion list
if ' + ' in tags and ',' in tags:
return [], None, None
search_operator = 'OR'
tag_delim = ','
if ' + ' in tags:
search_operator = 'AND'
tag_delim = ' + '
tags_ = [delim_wrap(t.strip()) for t in tags.split(tag_delim)]
return tags_, search_operator, excluded_tags
def gen_auto_tag():
"""Generate a tag in Year-Month-Date format.
Returns
-------
str
New tag as YYYYMonDD.
"""
t = time.localtime()
return '%d%s%02d' % (t.tm_year, calendar.month_abbr[t.tm_mon], t.tm_mday)
def edit_at_prompt(obj, nav, suggest=False):
"""Edit and add or update a bookmark.
Parameters
----------
obj : BukuDb instance
A valid instance of BukuDb class.
nav : str
Navigation command argument passed at prompt by user.
suggest : bool
If True, suggest similar tags on new bookmark addition.
"""
if nav == 'w':
editor = get_system_editor()
if not is_editor_valid(editor):
return
elif is_int(nav[2:]):
obj.edit_update_rec(int(nav[2:]))
return
else:
editor = nav[2:]
result = edit_rec(editor, '', None, DELIM, None)
if result is not None:
url, title, tags, desc = result
if suggest:
tags = obj.suggest_similar_tag(tags)
obj.add_rec(url, title, tags, desc)
def show_taglist(obj):
"""Additional prompt to show unique tag list.
Parameters
----------
obj : BukuDb instance
A valid instance of BukuDb class.
"""
unique_tags, dic = obj.get_tag_all()
if not unique_tags:
count = 0
print('0 tags')
else:
count = 1
for tag in unique_tags:
print('%6d. %s (%d)' % (count, tag, dic[tag]))
count += 1
print()
def prompt(obj, results, noninteractive=False, deep=False, listtags=False, suggest=False, num=10, markers=False, order=['+id']):
"""Show each matching result from a search and prompt.
Parameters
----------
obj : BukuDb instance
A valid instance of BukuDb class.
results : list
Search result set from a DB query.
noninteractive : bool
If True, does not seek user input. Shows all results. Default is False.
deep : bool
Use deep search. Default is False.
markers : bool
Use search-with-markers. Default is False.
listtags : bool
If True, list all tags.
suggest : bool
If True, suggest similar tags on edit and add bookmark.
order : list of str
Order description (fields from JSON export or DB, prepended with '+'/'-' for ASC/DESC).
num : int
Number of results to show per page. Default is 10.
"""
if not isinstance(obj, BukuDb):
LOGERR('Not a BukuDb instance')
return
bdb = obj
new_results = bool(results)
nav = ''
cur_index = next_index = prev_index = 0
if listtags:
show_taglist(obj)
try:
columns, _ = os.get_terminal_size()
except OSError:
columns = 0
if noninteractive:
try:
for i, row in enumerate(results or []):
print_single_rec(row, i + 1, columns)
except Exception as e:
LOGERR(e)
return
skip_print = False
while True:
if (new_results or nav in ['n', 'N']) and not skip_print:
_total_results = len(results or [])
cur_index = next_index # used elsewhere as "most recent page start index"
if not results:
print('0 results')
new_results = False
elif cur_index >= _total_results and nav != 'N':
print('No more results')
new_results = False
else:
if nav == 'N':
cur_index = min(cur_index, prev_index)
prev_index = max(0, cur_index - num)
next_index = min(cur_index + num, _total_results)
print()
for i in range(cur_index, next_index):
print_single_rec(results[i], i + 1, columns)
print('%d-%d/%d' % (cur_index + 1, next_index, _total_results))
skip_print = False
try:
prompt_suffix = ''
if bdb.dbfile != os.path.realpath(os.path.join(BukuDb.get_default_dbdir(), 'bookmarks.db')):
prompt_suffix = (f'[{bdb.dbname}] ' if not bdb.colorize else
f'\001\x1b[7\002m[{bdb.dbname}]\001\x1b[0m\002 ')
nav = read_in(PROMPTMSG + prompt_suffix)
if not nav:
nav = read_in(PROMPTMSG + prompt_suffix)
if not nav:
# Quit on double enter
break
nav = nav.strip()
except EOFError:
return
# show the next set of results from previous search
if nav in ('n', 'N'):
continue
if (m := re.match(r'^R(?: (-)?([0-9]+))?$', nav.rstrip())) and (n := int(m[2] or 1)) > 0:
skip_print = True
if results and not m[1]: # from search results
picked = random.sample(results, min(n, len(results)))
else: # from all bookmarks
ids = range(1, 1 + (bdb.get_max_id() or 0))
picked = bdb.get_rec_all_by_ids(random.sample(ids, min(n, len(ids))))
for row in bdb._sort(picked, order):
print_single_rec(row, columns=columns)
continue
if (m := re.match(r'^\^ ([1-9][0-9]*) ([1-9][0-9]*)$', nav.rstrip())):
index1, index2 = map(int, m.group(1, 2))
if bdb.swap_recs(index1, index2):
bdb.print_rec({index1, index2})
else:
print('Failed to swap records #%d and #%d' % (index1, index2))
continue
# search ANY match with new keywords
if nav.startswith('s '):
keywords = (nav[2:].split() if not markers else split_by_marker(nav[2:]))
results = bdb.searchdb(keywords, deep=deep, markers=markers, order=order)
new_results = True
cur_index = next_index = 0
continue
# search ALL match with new keywords
if nav.startswith('S '):
keywords = (nav[2:].split() if not markers else split_by_marker(nav[2:]))
results = bdb.searchdb(keywords, all_keywords=True, deep=deep, markers=markers, order=order)
new_results = True
cur_index = next_index = 0
continue
# regular expressions search with new keywords
if nav.startswith('r '):
keywords = (nav[2:].split() if not markers else split_by_marker(nav[2:]))
results = bdb.searchdb(keywords, all_keywords=True, regex=True, markers=markers, order=order)
new_results = True
cur_index = next_index = 0
continue
# tag search with new keywords
if nav.startswith('t '):
results = bdb.search_by_tag(nav[2:], order=order)
new_results = True
cur_index = next_index = 0
continue
# quit with 'q'
if nav == 'q':
return
# No new results fetched beyond this point
new_results = False
# toggle deep search with 'd', search-with-markers with 'm'
if nav == 'd':
deep = not deep
print('deep search', ('on' if deep else 'off'))
continue
if nav == 'm':
markers = not markers
print('search-with-markers', ('on' if markers else 'off'))
continue
if re.match(r'v!? ', nav): # letters 's' and 'o' are taken already
_fields = {'metadata': 'title', **JSON_FIELDS}
_order = bdb._ordering(filter(None, re.split(r'[,\s]+', nav[2:].strip())))
order_ = [('+' if asc else '-') + _fields.get(s, s) for s, asc in _order]
if nav.startswith('v '):
order = order_
print('order', ', '.join(order))
else:
bdb.reorder(order_)
print('Reindexed bookmarks to match order:', ', '.join(order_))
continue
# Toggle GUI browser with 'O'
if nav == 'O':
browse.override_text_browser = not browse.override_text_browser
print('text browser override toggled')
continue
# Show help with '?'
if nav == '?':
ExtendedArgumentParser.prompt_help(sys.stdout)
continue
# Edit and add or update
if nav == 'w' or nav.startswith('w '):
edit_at_prompt(bdb, nav, suggest)
continue
# Append or overwrite tags
if nav.startswith('g '):
unique_tags, dic = obj.get_tag_all()
_count = bdb.set_tag(nav[2:], unique_tags)
if _count == -1:
print('Invalid input')
elif _count == -2:
try:
tagid_list = nav[2:].split()
tagstr = bdb.get_tagstr_from_taglist(tagid_list, unique_tags)
tagstr = tagstr.strip(DELIM)
results = bdb.search_by_tag(tagstr)
new_results = True
cur_index = next_index = 0
except Exception:
print('Invalid input')
else:
print('%d updated' % _count)
continue
# Print bookmarks by DB index
if nav.startswith('p '):
try:
ids = parse_range(nav[2:].split(), maxidx=bdb.get_max_id() or 0)
ids and bdb.print_rec(ids, order=order)
except ValueError:
print('Invalid input')
continue
# Browse bookmarks by DB index
if nav.startswith('o '):
id_list = nav[2:].split()
try:
for id in id_list:
if is_int(id):
bdb.browse_by_index(int(id))
elif '-' in id:
vals = [int(x) for x in id.split('-')]
bdb.browse_by_index(0, vals[0], vals[-1], True)
else:
print('Invalid input')
except ValueError:
print('Invalid input')
continue
# Copy URL to clipboard
if nav.startswith('c ') and nav[2:].isdigit():
index = int(nav[2:]) - 1
if index < 0 or index >= next_index:
print('No matching index')
continue
copy_to_clipboard(content=results[index + cur_index][1].encode('utf-8'))
continue
# open all results and re-prompt with 'a'
if nav == 'a':
for index in range(cur_index, next_index):
browse(results[index][1], bdb.default_scheme)
continue
# list tags with 't'
if nav == 't':
show_taglist(bdb)
continue
if (nav+' ').startswith('DB '):
dbpath, dbfile = os.path.split(bdb.dbfile)
if nav == 'DB':
print(f'Available DB files (in {dbpath}):')
for s in sorted(s for s in os.listdir(dbpath) if s.endswith('.db')):
print(('*' if s == dbfile else ' '), s.removesuffix('.db'))
else:
s = os.path.expanduser(re.sub(r'^DB\s+', '', nav))
path = os.path.join(dbpath, (s if s != '~.' else BukuDb.get_default_dbdir())) # relative to current dir
newpath, newfile = os.path.split(path+'/' if os.path.isdir(path) else path)
newfile, (_, ext) = newfile or 'bookmarks', os.path.splitext(newfile)
if not ext or os.path.isfile(os.path.join(newpath, newfile+'.db')):
newfile += '.db'
newdb = os.path.join(newpath, newfile)
try:
if not os.path.exists(newdb):
print(f'DB file is being created at {newdb}')
_bdb = bdb
bdb = BukuDb(json=bdb.json, field_filter=bdb.field_filter, colorize=bdb.colorize, dbfile=newdb,
default_scheme=bdb.default_scheme)
_bdb.close()
results, new_results = [], False
cur_index = next_index = prev_index = 0
print(f'Loaded DB file at {bdb.dbfile}')
except Exception:
print(f'Failed to open DB file at {newdb}')
continue
toggled = False
# Open in GUI browser
if nav.startswith('O '):
if not browse.override_text_browser:
browse.override_text_browser = True
toggled = True
nav = nav[2:]
# iterate over white-space separated indices
for nav in nav.split():
if is_int(nav):
index = int(nav) - 1
if index < 0 or index >= next_index:
print('No matching index %s' % nav)
continue
browse(results[index][1], bdb.default_scheme)
elif '-' in nav:
try:
vals = [int(x) for x in nav.split('-')]
if vals[0] > vals[-1]:
vals[0], vals[-1] = vals[-1], vals[0]
for _id in range(vals[0]-1, vals[-1]):
if 0 <= _id < next_index:
browse(results[_id][1], bdb.default_scheme)
else:
print('No matching index %d' % (_id + 1))
except ValueError:
print('Invalid input')
break
else:
print('Invalid input')
break
if toggled:
browse.override_text_browser = False
def copy_to_clipboard(content):
"""Copy content to clipboard
Parameters
----------
content : str
Content to be copied to clipboard
"""
# try copying the url to clipboard using native utilities
copier_params = []
if sys.platform.startswith(('linux', 'freebsd', 'openbsd')):
if shutil.which('xsel') is not None:
copier_params = ['xsel', '-b', '-i']
elif shutil.which('xclip') is not None:
copier_params = ['xclip', '-selection', 'clipboard']
elif shutil.which('wl-copy') is not None:
copier_params = ['wl-copy']
# If we're using Termux (Android) use its 'termux-api'
# add-on to set device clipboard.
elif shutil.which('termux-clipboard-set') is not None:
copier_params = ['termux-clipboard-set']
elif sys.platform == 'darwin':
copier_params = ['pbcopy']
elif sys.platform == 'win32':
copier_params = ['clip']
if copier_params:
Popen(copier_params, stdin=PIPE, stdout=DEVNULL, stderr=DEVNULL).communicate(content)
return
# If native clipboard utilities are absent, try to use terminal multiplexers
# tmux
if os.getenv('TMUX_PANE'):
copier_params = ['tmux', 'set-buffer']
Popen(
copier_params + [content],
stdin=DEVNULL,
stdout=DEVNULL,
stderr=DEVNULL
).communicate()
print('URL copied to tmux buffer.')
return
# GNU Screen paste buffer
if os.getenv('STY'):
copier_params = ['screen', '-X', 'readbuf', '-e', 'utf8']
tmpfd, tmppath = tempfile.mkstemp()
try:
with os.fdopen(tmpfd, 'wb') as fp:
fp.write(content)
copier_params.append(tmppath)
Popen(copier_params, stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL).communicate()
finally:
os.unlink(tmppath)
return
print('Failed to locate suitable clipboard utility')
return
def print_rec_with_filter(records, field_filter=0):
"""Print records filtered by field.
User determines which fields in the records to display
by using the --format option.
Parameters
----------
records : list or sqlite3.Cursor object
List of bookmark records to print
field_filter : int
Integer indicating which fields to print. Default is 0 ("all fields").
"""
try:
records = bookmark_vars(records)
fields = FIELD_FILTER.get(field_filter)
if fields:
pattern = '\t'.join('%s' for k in fields)
for row in records:
print(pattern % tuple(getattr(row, k) for k in fields))
else:
try:
columns, _ = os.get_terminal_size()
except OSError:
columns = 0
for row in records:
print_single_rec(row, columns=columns)
except BrokenPipeError:
sys.stdout = os.fdopen(1)
sys.exit(1)
def print_single_rec(row: BookmarkVar, idx: int=0, columns: int=0): # NOQA
"""Print a single DB record.
Handles both search results and individual record.
Parameters
----------
row : tuple
Tuple representing bookmark record data.
idx : int
Search result index. If 0, print with DB index.
Default is 0.
columns : int
Number of columns to wrap comments to.
Default is 0.
"""
str_list = []
row = BookmarkVar(*row) # ensuring named tuple
# Start with index and title
if idx != 0:
id_title_res = ID_STR % (idx, row.title or 'Untitled', row.id)
else:
id_title_res = ID_DB_STR % (row.id, row.title or 'Untitled')
# Indicate if record is immutable
if row.immutable:
id_title_res = MUTE_STR % (id_title_res,)
else:
id_title_res += '\n'
try:
print(id_title_res, end='')
print(URL_STR % (row.url,), end='')
if columns == 0:
if row.desc:
print(DESC_STR % (row.desc,), end='')
if row.tags:
print(TAG_STR % (row.tags,), end='')
print()
return
INDENT = 5
fillwidth = columns - INDENT
desc_lines = [s for line in row.desc.splitlines()
for s in textwrap.wrap(line, width=fillwidth) or ['']]
TR = str.maketrans(',-', '-,') # we want breaks after commas rather than hyphens
tag_lines = textwrap.wrap(row.tags.translate(TR), width=fillwidth)
for idx, line in enumerate(desc_lines):
if idx == 0:
print(DESC_STR % line, end='')
else:
print(DESC_WRAP % (' ' * INDENT, line))
for idx, line in enumerate(tag_lines):
if idx == 0:
print(TAG_STR % line.translate(TR), end='')
else:
print(TAG_WRAP % (' ' * INDENT, line.translate(TR)))
print()
except UnicodeEncodeError:
str_list = []
str_list.append(id_title_res)
str_list.append(URL_STR % (row.url,))
if row.desc:
str_list.append(DESC_STR % (row.desc,))
if row.tags:
str_list.append(TAG_STR % (row.tags,))
sys.stdout.buffer.write((''.join(str_list) + '\n').encode('utf-8'))
except BrokenPipeError:
sys.stdout = os.fdopen(1)
sys.exit(1)
def write_string_to_file(content: str, filepath: str):
"""Writes given content to file
Parameters
----------
content : str
filepath : str
Returns
-------
None
"""
try:
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
except Exception as e:
LOGERR(e)
def format_json(resultset, single_record=False, field_filter=0):
"""Return results in JSON format.
Parameters
----------
resultset : list
Search results from DB query.
single_record : bool
If True, indicates only one record. Default is False.
field_filter : int
Indicates format for displaying bookmarks. Default is 0 ("all fields").
Returns
-------
json
Record(s) in JSON format.
"""
resultset = bookmark_vars(resultset)
fields = [(k, JSON_FIELDS.get(k, k)) for k in FIELD_FILTER.get(field_filter, ALL_FIELDS)]
marks = [{field: getattr(row, k) for k, field in fields} for row in resultset]
if single_record:
marks = marks[-1] if marks else {}
return json.dumps(marks, sort_keys=True, indent=4)
def print_json_safe(resultset, single_record=False, field_filter=0):
"""Prints json results and handles IOError
Parameters
----------
resultset : list
Search results from DB query.
single_record : bool
If True, indicates only one record. Default is False.
field_filter : int
Indicates format for displaying bookmarks. Default is 0 ("all fields").
Returns
-------
None
"""
try:
print(format_json(resultset, single_record, field_filter))
except IOError:
try:
sys.stdout.close()
except IOError:
pass
try:
sys.stderr.close()
except IOError:
pass
def is_int(string):
"""Check if a string is a digit.
string : str
Input string to check.
Returns
-------
bool
True on success, False on exception.
"""
try:
int(string)
return True
except Exception:
return False
def browse(url, default_scheme=SCHEME_HTTP):
"""Duplicate stdin, stdout and open URL in default browser.
.. Note:: Duplicates stdin and stdout in order to
suppress showing errors on the terminal.
Parameters
----------
url : str
URL to open in browser.
default_scheme : str
Scheme to assume if missing from URL. Default is http.
Attributes
----------
suppress_browser_output : bool
True if a text based browser is detected.
Must be initialized (as applicable) to use the API.
override_text_browser : bool
If True, tries to open links in a GUI based browser.
"""
if not urlparse(url).scheme:
# Prefix with default_scheme to improve the chance
# it will open properly in the browser
LOGERR('Scheme missing in URI, trying %s', default_scheme)
url = f'{default_scheme}://{url}'
browser = webbrowser.get()
if browse.override_text_browser:
browser_output = browse.suppress_browser_output
for name in [b for b in webbrowser._tryorder if b not in TEXT_BROWSERS]:
browser = webbrowser.get(name)
LOGDBG(browser)
# Found a GUI browser, suppress browser output
browse.suppress_browser_output = True
break
if sys.platform == 'win32': # GUI apps have no terminal IO on Windows
browse.suppress_browser_output = False
if browse.suppress_browser_output:
_stderr = os.dup(2)
os.close(2)
_stdout = os.dup(1)
if "microsoft" not in platform.uname()[3].lower():
os.close(1)
fd = os.open(os.devnull, os.O_RDWR)
os.dup2(fd, 2)
os.dup2(fd, 1)
try:
if sys.platform != 'win32':
browser.open(url, new=2)
else:
# On Windows, the webbrowser module does not fork.
# Use threads instead.
def browserthread():
webbrowser.open(url, new=2)
t = threading.Thread(target=browserthread)
t.start()
except Exception as e:
LOGERR('browse(): %s', e)
finally:
if browse.suppress_browser_output:
os.close(fd)
os.dup2(_stderr, 2)
os.dup2(_stdout, 1)
if browse.override_text_browser:
browse.suppress_browser_output = browser_output
def check_upstream_release():
"""Check and report the latest upstream release version."""
if MYPROXY is None:
gen_headers()
ca_certs = os.getenv('BUKU_CA_CERTS', default=CA_CERTS)
if MYPROXY:
manager = urllib3.ProxyManager(
MYPROXY,
num_pools=1,
headers=MYHEADERS,
cert_reqs='CERT_REQUIRED',
ca_certs=ca_certs
)
else:
manager = urllib3.PoolManager(num_pools=1,
headers={'User-Agent': USER_AGENT},
cert_reqs='CERT_REQUIRED',
ca_certs=ca_certs)
try:
r = manager.request(
'GET',
'https://api.github.com/repos/jarun/buku/releases?per_page=1',
headers={'User-Agent': USER_AGENT}
)
except Exception as e:
LOGERR(e)
return
if r.status == 200:
latest = json.loads(r.data.decode(errors='replace'))[0]['tag_name']
if latest == 'v' + __version__:
print('This is the latest release')
else:
print('Latest upstream release is %s' % latest)
else:
LOGERR('[%s] %s', r.status, r.reason)
manager.clear()
def regexp(expr, item):
"""Perform a regular expression search.
Parameters
----------
expr : regex
Regular expression to search for.
item : str
Item on which to perform regex search.
Returns
-------
bool
True if result of search is not None, else False.
"""
if expr is None or item is None:
LOGDBG('expr: [%s], item: [%s]', expr, item)
return False
return re.search(expr, item, re.IGNORECASE) is not None
def delim_wrap(token):
"""Returns token string wrapped in delimiters.
Parameters
----------
token : str
String item to wrap with DELIM.
Returns
-------
str
Token string wrapped by DELIM.
"""
if token is None or token.strip() == '':
return DELIM
if token[0] != DELIM:
token = DELIM + token
if token[-1] != DELIM:
token = token + DELIM
return token
def read_in(msg):
"""A wrapper to handle input() with interrupts disabled.
Parameters
----------
msg : str
String to pass to to input().
"""
disable_sigint_handler()
message = None
try:
message = input(msg)
except KeyboardInterrupt:
print('Interrupted.')
enable_sigint_handler()
return message
def sigint_handler(signum, frame):
"""Custom SIGINT handler.
.. Note:: Neither signum nor frame are used in
this custom handler. However, they are
required parameters for signal handlers.
Parameters
----------
signum : int
Signal number.
frame : frame object or None.
"""
global INTERRUPTED
INTERRUPTED = True
print('\nInterrupted.', file=sys.stderr)
# Do a hard exit from here
os._exit(1)
DEFAULT_HANDLER = signal.signal(signal.SIGINT, sigint_handler)
def disable_sigint_handler():
"""Disable signint handler."""
signal.signal(signal.SIGINT, DEFAULT_HANDLER)
def enable_sigint_handler():
"""Enable sigint handler."""
signal.signal(signal.SIGINT, sigint_handler)
# ---------------------
# Editor mode functions
# ---------------------
def get_system_editor():
"""Returns default system editor is $EDITOR is set."""
return os.environ.get('EDITOR', 'none')
def is_editor_valid(editor):
"""Check if the editor string is valid.
Parameters
----------
editor : str
Editor string.
Returns
-------
bool
True if string is valid, else False.
"""
if editor == 'none':
LOGERR('EDITOR is not set')
return False
if editor == '0':
LOGERR('Cannot edit index 0')
return False
return True
def to_temp_file_content(url, title_in, tags_in, desc):
"""Generate temporary file content string.
Parameters
----------
url : str
URL to open.
title_in : str
Title to add manually.
tags_in : str
Comma-separated tags to add manually.
desc : str
String description.
Returns
-------
str
Lines as newline separated string.
Raises
------
AttributeError
when tags_in is None.
"""
strings = [('# Lines beginning with "#" will be stripped.\n'
'# Add URL in next line (single line).'), ]
# URL
if url is not None:
strings += (url,)
# TITLE
strings += (('# Add TITLE in next line (single line). '
'Leave blank to web fetch, "-" for no title.'),)
if title_in is None:
title_in = ''
elif title_in == '':
title_in = '-'
strings += (title_in,)
# TAGS
strings += ('# Add comma-separated TAGS in next line (single line).',)
strings += [(tags_in or '').strip(DELIM)]
# DESC
strings += ('# Add COMMENTS in next line(s). Leave blank to web fetch, "-" for no comments.',)
if desc is None:
strings += ('\n',)
elif desc == '':
strings += ('-',)
else:
strings += (desc,)
return '\n'.join(strings)
def parse_temp_file_content(content):
"""Parse and return temporary file content.
Parameters
----------
content : str
String of content.
Returns
-------
tuple
(url, title, tags, comments)
url: URL to open
title: string title to add manually
tags: string of comma-separated tags to add manually
comments: string description
"""
content = content.split('\n')
content = [c for c in content if not c or c[0] != '#']
if not content or content[0].strip() == '':
print('Edit aborted')
return None
url = content[0]
title = None
if len(content) > 1:
title = content[1]
if title == '':
title = None
elif title == '-':
title = ''
tags = DELIM
if len(content) > 2:
tags = parse_tags([content[2]])
comments = []
if len(content) > 3:
comments = list(content[3:])
# need to remove all empty line that are at the end
# and not those in the middle of the text
for i in range(len(comments) - 1, -1, -1):
if comments[i].strip() != '':
break
if i == -1:
comments = []
else:
comments = comments[0:i+1]
comments = '\n'.join(comments)
if comments == '':
comments = None
elif comments == '-':
comments = ''
return url, title, tags, comments
def edit_rec(editor, url, title_in, tags_in, desc):
"""Edit a bookmark record.
Parameters
----------
editor : str
Editor to open.
URL : str
URL to open.
title_in : str
Title to add manually.
tags_in : str
Comma-separated tags to add manually.
desc : str
Bookmark description.
Returns
-------
tuple
Parsed results from parse_temp_file_content().
"""
temp_file_content = to_temp_file_content(url, title_in, tags_in, desc)
fd, tmpfile = tempfile.mkstemp(prefix='buku-edit-')
os.close(fd)
try:
with open(tmpfile, 'w+', encoding='utf-8') as fp:
fp.write(temp_file_content)
fp.flush()
LOGDBG('Edited content written to %s', tmpfile)
cmd = editor.split(' ')
cmd += (tmpfile,)
subprocess.call(cmd)
with open(tmpfile, 'r', encoding='utf-8') as f:
content = f.read()
os.remove(tmpfile)
except FileNotFoundError:
if os.path.exists(tmpfile):
os.remove(tmpfile)
LOGERR('Cannot open editor')
else:
LOGERR('Cannot open tempfile')
return None
parsed_content = parse_temp_file_content(content)
return parsed_content
def setup_logger(LOGGER):
"""Setup logger with color.
Parameters
----------
LOGGER : logger object
Logger to colorize.
"""
def decorate_emit(fn):
def new(*args):
levelno = args[0].levelno
if levelno == logging.DEBUG:
color = '\x1b[35m'
elif levelno == logging.ERROR:
color = '\x1b[31m'
elif levelno == logging.WARNING:
color = '\x1b[33m'
elif levelno == logging.INFO:
color = '\x1b[32m'
elif levelno == logging.CRITICAL:
color = '\x1b[31m'
else:
color = '\x1b[0m'
args[0].msg = '{}[{}]\x1b[0m {}'.format(color, args[0].levelname, args[0].msg)
return fn(*args)
return new
sh = logging.StreamHandler()
sh.emit = decorate_emit(sh.emit)
LOGGER.addHandler(sh)
def piped_input(argv, pipeargs=None):
"""Handle piped input.
Parameters
----------
pipeargs : str
"""
if not sys.stdin.isatty():
pipeargs += argv
print('buku: waiting for input (unexpected? try --nostdin)')
for s in sys.stdin:
pipeargs += s.split()
def setcolors(args):
"""Get colors from user and separate into 'result' list for use in arg.colors.
Parameters
----------
args : str
Color string.
"""
Colors = collections.namedtuple('Colors', ' ID_srch, ID_STR, URL_STR, DESC_STR, TAG_STR')
colors = Colors(*[COLORMAP[c] for c in args])
id_col = colors.ID_srch
id_str_col = colors.ID_STR
url_col = colors.URL_STR
desc_col = colors.DESC_STR
tag_col = colors.TAG_STR
result = [id_col, id_str_col, url_col, desc_col, tag_col]
return result
def unwrap(text):
"""Unwrap text."""
lines = text.split('\n')
result = ''
for i in range(len(lines) - 1):
result += lines[i]
if not lines[i]:
# Paragraph break
result += '\n\n'
elif lines[i + 1]:
# Next line is not paragraph break, add space
result += ' '
# Handle last line
result += lines[-1] if lines[-1] else '\n'
return result
def check_stdout_encoding():
"""Make sure stdout encoding is utf-8.
If not, print error message and instructions, then exit with
status 1.
This function is a no-op on win32 because encoding on win32 is
messy, and let's just hope for the best. /s
"""
if sys.platform == 'win32':
return
# Use codecs.lookup to resolve text encoding alias
encoding = codecs.lookup(sys.stdout.encoding).name
if encoding != 'utf-8':
locale_lang, locale_encoding = locale.getlocale()
if locale_lang is None:
locale_lang = ''
if locale_encoding is None:
locale_encoding = ''
ioencoding = os.getenv('PYTHONIOENCODING', 'not set')
sys.stderr.write(unwrap(textwrap.dedent("""\
stdout encoding '{encoding}' detected. ddgr requires utf-8 to
work properly. The wrong encoding may be due to a non-UTF-8
locale or an improper PYTHONIOENCODING. (For the record, your
locale language is {locale_lang} and locale encoding is
{locale_encoding}; your PYTHONIOENCODING is {ioencoding}.)
Please set a UTF-8 locale (e.g., en_US.UTF-8) or set
PYTHONIOENCODING to utf-8.
""".format(
encoding=encoding,
locale_lang=locale_lang,
locale_encoding=locale_encoding,
ioencoding=ioencoding,
))))
sys.exit(1)
def monkeypatch_textwrap_for_cjk():
"""Monkeypatch textwrap for CJK wide characters.
"""
try:
if textwrap.wrap.patched:
return
except AttributeError:
pass
psl_textwrap_wrap = textwrap.wrap
def textwrap_wrap(text, width=70, **kwargs):
width = max(width, 2)
# We first add a U+0000 after each East Asian Fullwidth or East
# Asian Wide character, then fill to width - 1 (so that if a NUL
# character ends up on a new line, we still have one last column
# to spare for the preceding wide character). Finally we strip
# all the NUL characters.
#
# East Asian Width: https://www.unicode.org/reports/tr11/
return [
line.replace('\0', '')
for line in psl_textwrap_wrap(
''.join(
ch + '\0' if unicodedata.east_asian_width(ch) in ('F', 'W') else ch
for ch in unicodedata.normalize('NFC', text)
),
width=width - 1,
**kwargs
)
]
def textwrap_fill(text, width=70, **kwargs):
return '\n'.join(textwrap_wrap(text, width=width, **kwargs))
textwrap.wrap = textwrap_wrap
textwrap.fill = textwrap_fill
textwrap.wrap.patched = True
textwrap.fill.patched = True
def parse_range(tokens: Optional[str | Values[str]],
valid: Optional[Callable[[int], bool]] = None,
maxidx: int = None) -> Optional[Set[int]]:
"""Convert a token or sequence/set of token into a set of indices.
Raises a ValueError on invalid token. Returns None if passed None as tokens.
Parameters
----------
tokens : str | str[] | str{}, optional
String(s) containing an index (#), or a range (#-#), or a comma-separated list thereof.
valid : (int) -> bool, optional
Additional check for invalid indices (default is None).
maxidx : int, optional
When specified, negative indices are valid and parsed as tail-ranges.
Returns
-------
Optional[Set[int]]
None if tokens is None, otherwise parsed indices as unordered set.
"""
if tokens is None:
return None
result = set()
for token in ([tokens] if isinstance(tokens, str) else tokens):
for idx in token.split(','):
if is_int(idx):
result |= ({int(idx)} if not idx.startswith('-') or maxidx is None else
set(range(maxidx, max(0, maxidx + int(idx)), -1)))
elif '-' in idx:
l, r = map(int, idx.split('-'))
if l > r:
l, r = r, l
if maxidx is not None:
r = min(r, maxidx)
result |= set(range(l, r + 1))
elif idx:
raise ValueError(f'Invalid token: {idx}')
if valid and any(not valid(idx) for idx in result):
raise ValueError('Not a valid range')
return result
# main starts here
def main(argv=sys.argv[1:], *, program_name=os.path.basename(sys.argv[0])):
"""Main."""
global ID_STR, ID_DB_STR, MUTE_STR, URL_STR, DESC_STR, DESC_WRAP, TAG_STR, TAG_WRAP, PROMPTMSG
# readline should not be loaded when buku is used as a library
import readline
if sys.platform == 'win32':
try:
import colorama
colorama.just_fix_windows_console() # noop on non-Windows systems
except ImportError:
pass
title_in = None
tags_in = None
desc_in = None
pipeargs = []
colorstr_env = os.getenv('BUKU_COLORS')
if argv == ['--db']:
for s in sorted(s for s in os.listdir(BukuDb.get_default_dbdir()) if s.endswith('.db')):
print(s.removesuffix('.db'))
return
if argv and argv[0] != '--nostdin':
try:
piped_input(argv, pipeargs)
except KeyboardInterrupt:
pass
# If piped input, set argument vector
if pipeargs:
argv = pipeargs
# Setup custom argument parser
argparser = ExtendedArgumentParser(
prog=program_name,
description='''Bookmark manager like a text-based mini-web.
POSITIONAL ARGUMENTS:
KEYWORD search keywords''',
formatter_class=argparse.RawTextHelpFormatter,
usage='''buku [OPTIONS] [KEYWORD [KEYWORD ...]]''',
add_help=False)
hide = argparse.SUPPRESS
argparser.add_argument('keywords', nargs='*', metavar='KEYWORD', help=hide)
# ---------------------
# GENERAL OPTIONS GROUP
# ---------------------
general_grp = argparser.add_argument_group(
title='GENERAL OPTIONS',
description=''' -a, --add URL [+|-] [tag, ...]
bookmark URL with comma-separated tags
(prepend tags with '+' or '-' to use fetched tags)
-u, --update [...] update fields of an existing bookmark
accepts indices and ranges
refresh title and desc if no edit options
if no arguments:
- update results when used with search
- otherwise refresh all titles and desc
-w, --write [editor|index]
edit and add a new bookmark in editor
else, edit bookmark at index in EDITOR
edit last bookmark, if index=-1
if no args, edit new bookmark in EDITOR
-d, --delete [...] remove bookmarks from DB
accepts indices or a single range
if no arguments:
- delete results when used with search
- otherwise delete all bookmarks
--retain-order prevents reordering after deleting a bookmark
-h, --help show this information and exit
-v, --version show the program version and exit''')
addarg = general_grp.add_argument
addarg('-a', '--add', nargs='+', help=hide)
addarg('-u', '--update', nargs='*', help=hide)
addarg('-w', '--write', nargs='?', const=get_system_editor(), help=hide)
addarg('-d', '--delete', nargs='*', help=hide)
addarg('--retain-order', action='store_true', default=False, help=hide)
addarg('-h', '--help', action='store_true', help=hide)
addarg('-v', '--version', action='version', version=__version__, help=hide)
# ------------------
# EDIT OPTIONS GROUP
# ------------------
edit_grp = argparser.add_argument_group(
title='EDIT OPTIONS',
description=''' --url keyword bookmark link
--tag [+|-] [...] comma-separated tags
clear bookmark tagset, if no arguments
'+' appends to, '-' removes from tagset
--title [...] bookmark title; if no arguments:
-a: do not set title, -u: clear title
-c, --comment [...] notes or description of the bookmark
clears description, if no arguments
--immutable N disable web-fetch during auto-refresh
N=0: mutable (default), N=1: immutable
--swap N M swap two records at specified indices''')
addarg = edit_grp.add_argument
addarg('--url', nargs=1, help=hide)
addarg('--tag', nargs='*', help=hide)
addarg('--title', nargs='*', help=hide)
addarg('-c', '--comment', nargs='*', help=hide)
addarg('--immutable', type=int, choices={0, 1}, help=hide)
addarg('--swap', nargs=2, type=int, help=hide)
_bool = lambda x: x if x is None else bool(x)
_immutable = lambda args: _bool(args.immutable)
# --------------------
# SEARCH OPTIONS GROUP
# --------------------
search_grp = argparser.add_argument_group(
title='SEARCH OPTIONS',
description=''' -s, --sany [...] find records with ANY matching keyword
this is the default search option
-S, --sall [...] find records matching ALL the keywords
special keywords -
"blank": entries with empty title/tag
"immutable": entries with locked title
--deep match substrings ('pen' matches 'opens')
--markers search for keywords in specific fields
based on (optional) prefix markers:
'.' - title, '>' - description, ':' - URL,
'#' - tags (comma-separated, PARTIAL matches)
'#,' - tags (comma-separated, EXACT matches)
'*' - any field (same as no prefix)
-r, --sreg expr run a regex search
-t, --stag [tag [,|+] ...] [- tag, ...]
search bookmarks by tags
use ',' to find entries matching ANY tag
use '+' to find entries matching ALL tags
excludes entries with tags after ' - '
list all tags, if no search keywords
-x, --exclude [...] omit records matching specified keywords
--random [N] output random bookmarks out of the selection (default 1)
--order fields [...] comma-separated list of fields to order the output by
(prepend with '+'/'-' to choose sort direction)''')
addarg = search_grp.add_argument
addarg('-s', '--sany', nargs='*', help=hide)
addarg('-S', '--sall', nargs='*', help=hide)
addarg('-r', '--sreg', nargs='*', help=hide)
addarg('--deep', action='store_true', help=hide)
addarg('--markers', action='store_true', help=hide)
addarg('-t', '--stag', nargs='*', help=hide)
addarg('-x', '--exclude', nargs='*', help=hide)
addarg('--random', nargs='?', type=int, const=1, help=hide)
addarg('--order', nargs='+', help=hide)
# ------------------------
# ENCRYPTION OPTIONS GROUP
# ------------------------
crypto_grp = argparser.add_argument_group(
title='ENCRYPTION OPTIONS',
description=''' -l, --lock [N] encrypt DB in N (default 8) # iterations
-k, --unlock [N] decrypt DB in N (default 8) # iterations''')
addarg = crypto_grp.add_argument
addarg('-k', '--unlock', nargs='?', type=int, const=8, help=hide)
addarg('-l', '--lock', nargs='?', type=int, const=8, help=hide)
# ----------------
# POWER TOYS GROUP
# ----------------
power_grp = argparser.add_argument_group(
title='POWER TOYS',
description=''' --ai auto-import bookmarks from web browsers
Firefox, Chrome, Chromium, Vivaldi, Brave, Edge
(Firefox profile can be specified using
environment variable FIREFOX_PROFILE)
-e, --export file export bookmarks to Firefox format HTML
export XBEL, if file ends with '.xbel'
export Markdown, if file ends with '.md'
format: [title](url)
export Orgfile, if file ends with '.org'
format: *[[url][title]] :tags:
export rss feed if file ends with '.rss'/'.atom'
export buku DB, if file ends with '.db'
combines with search results, if opted
-i, --import file import bookmarks from file
supports .html .xbel .json .md .org .rss .atom .db
(.json = Firefox backup; .db = another Buku DB)
-p, --print [...] show record details by indices, ranges
print all bookmarks, if no arguments
-n shows the last n results (like tail)
-f, --format N limit fields in -p or JSON search output
N=1: URL; N=2: URL, tag; N=3: title;
N=4: URL, title, tag; N=5: title, tag;
N0 (10, 20, 30, 40, 50) omits DB index
-j, --json [file] JSON formatted output for -p and search.
prints to stdout if argument missing.
otherwise writes to given file
--colors COLORS set output colors in five-letter string
--nc disable color output
-n, --count N show N results per page (default 10)
--np do not show the subprompt, run and exit
-o, --open [...] browse bookmarks by indices and ranges
open a random bookmark, if no arguments
--oa browse all search results immediately
--default-scheme S if scheme is missing from uri, assume S
when opening in browser (default http)
--replace old new replace old tag with new tag everywhere
delete old tag, if new tag not specified
--url-redirect when fetching an URL, use the resulting
URL from following *permanent* redirects
(when combined with --export, the old URL
is included as additional metadata)
--tag-redirect [tag] when fetching an URL that causes permanent
redirect, add a tag in specified pattern
(using 'http:{}' if not specified)
--tag-error [tag] when fetching an URL that causes an HTTP
error, add a tag in specified pattern
(using 'http:{}' if not specified)
--del-error [...] when fetching an URL causes any (given)
HTTP error, delete/do not add it
--export-on [...] export records affected by the above
options, including removed info
(requires --update and --export; specific
HTTP response filter can be provided)
--reorder order... update DB indices to match specified order
--cached index|URL browse a cached page from Wayback Machine
--offline add a bookmark without connecting to web
--suggest show similar tags when adding bookmarks
--tacit reduce verbosity, skip some confirmations
--nostdin do not wait for input (must be first arg)
--threads N max network connections in full refresh
default N=4, min N=1, max N=10
-V check latest upstream version available
-g, --debug show debug information and verbose logs''')
addarg = power_grp.add_argument
addarg('--ai', action='store_true', help=hide)
addarg('-e', '--export', nargs=1, help=hide)
addarg('-i', '--import', nargs=1, dest='importfile', help=hide)
addarg('-p', '--print', nargs='*', help=hide)
addarg('-f', '--format', type=int, default=0, choices={1, 2, 3, 4, 5, 10, 20, 30, 40, 50}, help=hide)
addarg('-j', '--json', nargs='?', default=None, const='', help=hide)
addarg('--colors', dest='colorstr', type=argparser.is_colorstr, metavar='COLORS', help=hide)
addarg('--nc', action='store_true', help=hide)
addarg('-n', '--count', nargs='?', const=10, type=int, default=0, help=hide)
addarg('--np', action='store_true', help=hide)
addarg('-o', '--open', nargs='*', help=hide)
addarg('--oa', action='store_true', help=hide)
addarg('--default-scheme', nargs=1, default=[SCHEME_HTTP], help=hide)
addarg('--replace', nargs='+', help=hide)
addarg('--url-redirect', action='store_true', help=hide)
addarg('--tag-redirect', nargs='?', const=True, default=False, help=hide)
addarg('--tag-error', nargs='?', const=True, default=False, help=hide)
addarg('--del-error', nargs='*', help=hide)
addarg('--export-on', nargs='*', help=hide)
addarg('--reorder', nargs='+', help=hide)
addarg('--cached', nargs=1, help=hide)
addarg('--offline', action='store_true', help=hide)
addarg('--suggest', action='store_true', help=hide)
addarg('--tacit', action='store_true', help=hide)
addarg('--nostdin', action='store_true', help=hide)
addarg('--threads', type=int, default=4, choices=range(1, 11), help=hide)
addarg('-V', dest='upstream', action='store_true', help=hide)
addarg('-g', '--debug', action='store_true', help=hide)
# Undocumented APIs
# Fix uppercase tags allowed in releases before v2.7
addarg('--fixtags', action='store_true', help=hide)
# App-use only, not for manual usage
addarg('--db', nargs=1, default=[None], help=hide)
# Parse the arguments
args = argparser.parse_args(argv)
# Show help and exit if help requested
if args.help:
argparser.print_help()
sys.exit(0)
# By default, buku uses ANSI colors. As Windows does not really use them,
# we'd better check for known working console emulators first. Currently,
# only ConEmu is supported. If the user does not use ConEmu, colors are
# disabled unless --colors or %BUKU_COLORS% is specified.
if sys.platform == 'win32' and os.environ.get('ConemuDir') is None:
if args.colorstr is None and colorstr_env is not None:
args.nc = True
# Handle NO_COLOR as well:
if os.environ.get('NO_COLOR') is not None:
args.nc = True
# Handle color output preference
if args.nc:
logging.basicConfig(format='[%(levelname)s] %(message)s')
else:
# Set colors
if colorstr_env is not None:
# Someone set BUKU_COLORS.
colorstr = colorstr_env
elif args.colorstr is not None:
colorstr = args.colorstr
else:
colorstr = 'oKlxm'
ID = setcolors(colorstr)[0] + '%d. ' + COLORMAP['x']
ID_DB_dim = COLORMAP['z'] + '[%s]\n' + COLORMAP['x']
ID_STR = ID + setcolors(colorstr)[1] + '%s ' + COLORMAP['x'] + ID_DB_dim
ID_DB_STR = ID + setcolors(colorstr)[1] + '%s' + COLORMAP['x']
MUTE_STR = '%s \x1b[2m(L)\x1b[0m\n'
URL_STR = COLORMAP['j'] + ' > ' + setcolors(colorstr)[2] + '%s\n' + COLORMAP['x']
DESC_STR = COLORMAP['j'] + ' + ' + setcolors(colorstr)[3] + '%s\n' + COLORMAP['x']
DESC_WRAP = COLORMAP['j'] + setcolors(colorstr)[3] + '%s%s' + COLORMAP['x']
TAG_STR = COLORMAP['j'] + ' # ' + setcolors(colorstr)[4] + '%s\n' + COLORMAP['x']
TAG_WRAP = COLORMAP['j'] + setcolors(colorstr)[4] + '%s%s' + COLORMAP['x']
# Enable color in logs
setup_logger(LOGGER)
# Enable prompt with reverse video
PROMPTMSG = '\001\x1b[7\002mbuku (? for help)\001\x1b[0m\002 '
# Enable browser output in case of a text based browser
if os.getenv('BROWSER') in TEXT_BROWSERS:
browse.suppress_browser_output = False
else:
browse.suppress_browser_output = True
# Overriding text browsers is disabled by default
browse.override_text_browser = False
# Handle DB name (--db value without extension and path separators)
_db = args.db[0]
if _db and not os.path.dirname(_db) and not os.path.splitext(_db)[1]:
_db = os.path.join(BukuDb.get_default_dbdir(), _db + '.db')
# Fallback to prompt if no arguments
if args._passed <= {'nostdin', 'db'}:
try:
_db = _db or os.path.join(BukuDb.get_default_dbdir(), 'bookmarks.db')
if not os.path.exists(_db):
print(f'DB file is being created at {_db}') # not printed without chatty param
bdb = BukuDb(dbfile=_db, default_scheme=args.default_scheme[0])
except Exception:
sys.exit(1)
prompt(bdb, None)
bdb.close_quit(0)
# Set up debugging
if args.debug:
LOGGER.setLevel(logging.DEBUG)
LOGDBG('buku v%s', __version__)
LOGDBG('Python v%s', ('%d.%d.%d' % sys.version_info[:3]))
else:
logging.disable(logging.WARNING)
urllib3.disable_warnings()
# Handle encrypt/decrypt options at top priority
try:
if args.lock is not None:
BukuCrypt.encrypt_file(args.lock, dbfile=_db)
print('File encrypted')
sys.exit(0)
if args.unlock is not None:
BukuCrypt.decrypt_file(args.unlock, dbfile=_db)
print('File decrypted')
except RuntimeError as e:
LOGERR(e)
sys.exit(1)
order = parse_order(args.order or [])
# Set up title
if args.title is not None:
title_in = ' '.join(args.title)
# Set up tags
if args.tag is not None:
tags_in = args.tag or [DELIM]
# Set up comment
if args.comment is not None:
desc_in = ' '.join(args.comment)
# validating HTTP-code handling args
tag_redirect = args.tag_redirect
if isinstance(args.tag_redirect, str):
try:
args.tag_redirect.format(301)
tag_redirect = args.tag_redirect.strip() or True
except (IndexError, KeyError):
LOGERR('Invalid format of --tag-redirect (should use "{}" as placeholder)')
sys.exit(1)
tag_error = args.tag_error
if isinstance(args.tag_error, str):
try:
args.tag_error.format(301)
tag_error = args.tag_error.strip() or True
except (IndexError, KeyError):
LOGERR('Invalid format of --tag-error (should use "{}" as placeholder)')
sys.exit(1)
try:
del_error = (None if args.del_error is None else
parse_range(args.del_error, lambda x: 400 <= x < 600) or range(400, 600))
except ValueError:
LOGERR('Invalid HTTP code(s) given for --del-error (should be within 4xx/5xx ranges)')
sys.exit(1)
try:
_default = (set() if not args.url_redirect and not tag_redirect else PERMANENT_REDIRECTS)
_default |= set([] if not tag_error else range(400, 600)) | set(del_error or [])
export_on = (None if args.export_on is None else
parse_range(args.export_on, lambda x: 100 <= x < 600) or _default)
except ValueError:
LOGERR('Invalid HTTP code(s) given for --export-on')
sys.exit(1)
# Initialize the database and get handles, set verbose by default
try:
bdb = BukuDb(args.json, args.format, not args.tacit, dbfile=_db, colorize=not args.nc, default_scheme=args.default_scheme[0])
except Exception:
sys.exit(1)
if args.swap:
index1, index2 = args.swap
if bdb.swap_recs(index1, index2):
bdb.print_rec({index1, index2})
else:
LOGERR('Failed to swap records #%d and #%d', index1, index2)
bdb.close_quit(0)
# Editor mode
if args.write is not None:
if not is_editor_valid(args.write):
bdb.close_quit(1)
if is_int(args.write):
if not bdb.edit_update_rec(int(args.write), _immutable(args)):
bdb.close_quit(1)
elif args.add is None:
# Edit and add a new bookmark
# Parse tags into a comma-separated string
tags = parse_tags(tags_in, edit_input=True)
result = edit_rec(args.write, '', title_in, tags, desc_in)
if result is not None:
url, title_in, tags, desc_in = result
if args.suggest:
tags = bdb.suggest_similar_tag(tags)
bdb.add_rec(url, title_in, tags, desc_in, _immutable(args), False, not args.offline)
# Add record
if args.add is not None:
if args.url is not None and args.update is None:
LOGERR('Bookmark a single URL at a time')
bdb.close_quit(1)
# Parse tags into a comma-separated string
# --add may have URL followed by tags
keywords_except, keywords = [], args.add[1:]
# taglists are taken from --add (starting from 2nd value) and from --tags
# if taglist starts with '-', its contents are excluded from resulting tags
# if BOTH taglists is are either empty or start with '+'/'-', fetched tags are included
if keywords and keywords[0] == '-':
keywords, keywords_except = [], keywords[1:]
tags_add = (not keywords or keywords[0] == '+')
if tags_add:
keywords = keywords[1:]
if tags_in:
# note: need to add a delimiter as url+tags may not end with one
if tags_in[0] == '-':
keywords_except += [DELIM] + tags_in[1:]
elif tags_in[0] == '+':
keywords += [DELIM] + tags_in[1:]
else:
keywords += [DELIM] + tags_in
tags_add = False
tags, tags_except = parse_tags(keywords), parse_tags(keywords_except)
tags, tags_except = ((s if s and s != DELIM else None) for s in [tags, tags_except])
url = args.add[0]
edit_aborted = False
if args.write and not is_int(args.write):
result = edit_rec(args.write, url, title_in, tags, desc_in)
if result is not None:
url, title_in, tags, desc_in = result
else:
edit_aborted = True
if edit_aborted is False:
if args.suggest:
tags = bdb.suggest_similar_tag(tags)
network_test = args.url_redirect or tag_redirect or tag_error or del_error
fetch = not args.offline and (network_test or tags_add or title_in is None)
bdb.add_rec(url, title_in, tags, desc_in, _immutable(args), delay_commit=False, fetch=fetch,
tags_fetch=tags_add, tags_except=tags_except, url_redirect=args.url_redirect,
tag_redirect=tag_redirect, tag_error=tag_error, del_error=del_error)
# Search record
search_results, search_opted = None, True
if args.sany is not None:
if not args.sany:
LOGERR('no keyword')
else:
LOGDBG('args.sany')
# Apply tag filtering, if opted
search_results = bdb.search_keywords_and_filter_by_tags(
args.sany, deep=args.deep, stag=args.stag, markers=args.markers, without=args.exclude, order=order)
elif args.sall is not None:
if not args.sall:
LOGERR('no keyword')
else:
LOGDBG('args.sall')
search_results = bdb.search_keywords_and_filter_by_tags(
args.sall, all_keywords=True, deep=args.deep, stag=args.stag,
markers=args.markers, without=args.exclude, order=order)
elif args.sreg is not None:
if not args.sreg:
LOGERR('no expression')
else:
LOGDBG('args.sreg')
search_results = bdb.search_keywords_and_filter_by_tags(
args.sreg, regex=True, stag=args.stag, markers=args.markers, without=args.exclude, order=order)
elif args.keywords:
LOGDBG('args.keywords')
search_results = bdb.search_keywords_and_filter_by_tags(
args.keywords, deep=args.deep, stag=args.stag, markers=args.markers, without=args.exclude, order=order)
elif args.stag is not None:
if not args.stag: # use sub-prompt to list all tags
prompt(bdb, None, noninteractive=args.np, listtags=True, suggest=args.suggest, order=order)
else:
LOGDBG('args.stag')
search_results = bdb.exclude_results_from_search(
bdb.search_by_tag(' '.join(args.stag), order=order), args.exclude, deep=args.deep, markers=args.markers)
elif args.exclude is not None:
LOGERR('No search criteria to exclude results from')
elif args.markers:
LOGERR('No search criteria to apply markers to')
else:
search_opted = False
# Add cmdline search options to readline history
if search_opted and len(args.keywords):
try:
readline.add_history(' '.join(args.keywords))
except Exception:
pass
check_stdout_encoding()
monkeypatch_textwrap_for_cjk()
update_search_results = False
if search_results:
if args.random and args.random < len(search_results):
search_results = bdb._sort(random.sample(search_results, args.random), order)
single_record = args.random == 1 # matching print_rec() behaviour
oneshot = args.np
# Open all results in browser right away if args.oa
# is specified. The has priority over delete/update.
# URLs are opened first and updated/deleted later.
if args.oa:
for row in search_results:
browse(row[1], args.default_scheme[0])
if (
(args.export is not None) or
(args.delete is not None and not args.delete) or
(args.update is not None and not args.update)):
oneshot = True
if args.json is None and not args.format and not args.random:
num = 10 if not args.count else args.count
prompt(bdb, search_results, noninteractive=oneshot, deep=args.deep, markers=args.markers, order=order, num=num)
elif args.json is None:
print_rec_with_filter(search_results, field_filter=args.format)
elif args.json:
write_string_to_file(format_json(search_results, single_record, field_filter=args.format), args.json)
else:
# Printing in JSON format is non-interactive
print_json_safe(search_results, single_record, field_filter=args.format)
# Export the results, if opted
if args.export and not (args.update is not None and export_on):
bdb.exportdb(args.export[0], search_results)
# In case of search and delete/update,
# prompt should be non-interactive
# delete gets priority over update
if args.delete is not None and not args.delete:
bdb.delete_resultset(search_results, retain_order=args.retain_order)
elif args.update is not None and not args.update:
update_search_results = True
# Update record
if args.update is not None:
url_in = (args.url[0] if args.url else None)
# Parse tags into a comma-separated string
tags = parse_tags(tags_in, edit_input=True)
tags = (None if tags == DELIM else tags)
# No arguments to --update, update all
if not args.update:
# Update all records only if search was not opted
if not search_opted:
_indices = []
elif search_results and update_search_results:
if not args.tacit:
print('Updated results:\n')
_indices = [x.id for x in search_results]
else:
_indices = None
else:
try:
_indices = parse_range(args.update, lambda x: x >= 0)
except ValueError:
LOGERR('Invalid index or range to update')
bdb.close_quit(1)
_indices = ([] if 0 in _indices else _indices)
if _indices is not None:
bdb.update_rec(_indices, url_in, title_in, tags, desc_in, _immutable(args), threads=args.threads,
url_redirect=args.url_redirect, tag_redirect=tag_redirect, tag_error=tag_error,
del_error=del_error, export_on=export_on, retain_order=args.retain_order)
if args.export and bdb._to_export is not None:
bdb.exportdb(args.export[0], order=order)
# Delete record
if args.delete is not None:
if not args.delete:
# Attempt delete-all only if search was not opted
if not search_opted:
bdb.cleardb()
elif len(args.delete) == 1 and '-' in args.delete[0]:
try:
vals = [int(x) for x in args.delete[0].split('-')]
if len(vals) == 2:
bdb.delete_rec(0, vals[0], vals[1], is_range=True, retain_order=args.retain_order)
except ValueError:
LOGERR('Invalid index or range to delete')
bdb.close_quit(1)
else:
ids = set(args.delete)
try:
# Index delete order - highest to lowest
ids = sorted(map(int, ids), reverse=True)
for idx in ids:
bdb.delete_rec(idx, retain_order=args.retain_order)
except ValueError:
LOGERR('Invalid index or range or combination')
bdb.close_quit(1)
# Print record
if args.print is not None:
try:
max_id = bdb.get_max_id() or 0
id_range = list(parse_range(args.print, maxidx=max_id) or []) or range(1, 1 + max_id)
except ValueError:
LOGERR('Invalid index or range to print')
bdb.close_quit(1)
if args.random and args.random < len(id_range):
bdb.print_rec(random.sample(id_range, args.random), order=order)
elif not args.print:
if args.count:
search_results = bdb.list_using_id(order=order)
prompt(bdb, search_results, noninteractive=args.np, num=args.count, order=order)
else:
bdb.print_rec(None, order=order)
else:
if args.count:
search_results = bdb.list_using_id(args.print, order=order)
prompt(bdb, search_results, noninteractive=args.np, num=args.count, order=order)
else:
bdb.print_rec(id_range, order=order)
# Replace a tag in DB
if args.replace is not None:
if len(args.replace) == 1:
bdb.delete_tag_at_index(0, args.replace[0])
else:
try:
bdb.replace_tag(args.replace[0], [' '.join(args.replace[1:])])
except Exception as e:
LOGERR(str(e))
bdb.close_quit(1)
# Export bookmarks
if args.export and not search_opted and not export_on:
bdb.exportdb(args.export[0], order=order, pick=args.random)
# Import bookmarks
if args.importfile is not None:
bdb.importdb(args.importfile[0], args.tacit)
# Import bookmarks from browser
if args.ai:
bdb.auto_import_from_browser(firefox_profile=os.environ.get('FIREFOX_PROFILE'))
# Open URL in browser
if args.open is not None:
if not args.open:
bdb.browse_by_index(0)
else:
try:
for idx in args.open:
if is_int(idx):
bdb.browse_by_index(int(idx))
elif '-' in idx:
vals = [int(x) for x in idx.split('-')]
bdb.browse_by_index(0, vals[0], vals[-1], True)
except ValueError:
LOGERR('Invalid index or range to open')
bdb.close_quit(1)
# Try to fetch URL from Wayback Machine
if args.cached:
wbu = bdb.browse_cached_url(args.cached[0])
if wbu is not None:
browse(wbu)
# Report upstream version
if args.upstream:
check_upstream_release()
# Fix tags
if args.fixtags:
bdb.fixtags()
if args.reorder:
size = bdb.get_max_id()
if size and (args.np or read_in(f'Are you sure you want to reorder all {size} bookmarks? (y/n): ') == 'y'):
bdb.reorder(parse_order(args.reorder))
print(f'...Reordered {size} bookmarks.')
# Close DB connection and quit
bdb.close_quit(0)
if __name__ == '__main__':
main()
================================================
FILE: bukuserver/README.md
================================================
## Bukuserver
_**Note: see [the runner script](https://github.com/jarun/buku/wiki/Bukuserver-%28WebUI%29#runner-script) for advanced installation/running/DB swapping functionality**_
### Table of Contents
- [Installation](#installation)
- [Dependencies](#dependencies)
- [From PyPi](#from-pypi)
- [From source](#from-source)
- [Using Docker](#using-docker)
- [Using Docker Compose](#using-docker-compose)
- [Webserver options](#webserver-options)
- [Configuration](#configuration)
- [API](#api)
- [Screenshots](#screenshots)
### Installation
You need to have some packages before you install `bukuserver` on your server.
So be sure to have `python3`, `python3-pip` , `python3-dev`, `libffi-dev` packages from your distribution.
#### Dependencies
```
$ # venv activation (for development)
$ python3 -m venv venv
$ source venv/bin/activate
$ pip install --upgrade pip
```
#### From PyPi
```sh
$ # regular/venv install
$ pip3 install "buku[server]"
$ # pipx install
$ pipx install "buku[server]"
$ # with locales
$ pipx install "buku[server,locales]"
```
#### From source
```sh
$ git clone https://github.com/jarun/buku
$ cd buku
$ # regular/venv install
$ pip3 install ".[server]"
$ # with locales
$ pip3 install ".[server,locales]"
```
#### Using Docker
To build the image execute the command from the root directory of the project:
```sh
docker build -t bukuserver .
```
To run the generated image.
```sh
docker run -it --rm -v ~/.local/share/buku:/root/.local/share/buku -p 5001:5001 bukuserver
```
All the data generated will be stored in the `~/.local/share/buku` directory.
Feel free to change it to the full path of the location you want to store the
database.
Visit `127.0.0.1:5001` in your browser to access your bookmarks.
#### Using Docker Compose
There is a `docker-compose.yml` file present in the `docker-compose` directory
in the root of this project. You may modify the configurations in this file to
your liking, and then simply execute the below command.
```sh
docker-compose up -d
```
You will have you bukuserver running on port port 80 of the host.
To stop simply run
```sh
docker-compose down
```
In case you want to add basic auth to your hosted instance you may do so by
creating a `.htpasswd` file in the `data/basic_auth` directory. Add a user to
the file using
```sh
htpasswd -c data/basic_auth/.htpasswd your_username
```
And then comment out the basic auth lines from the `data/nginx/nginx.conf` file.
For more information please refer the [nginx docs](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/).
### Webserver options
To run the server on host 127.0.0.1, port 5001, run following command:
$ bukuserver run --host 127.0.0.1 --port 5001
Visit `127.0.0.1:5001` in your browser to access your bookmarks.
See more option on `bukuserver run --help` and `bukuserver --help`
### Configuration
The following are [os env config variables](#how-to-specify-environment-variables) available for bukuserver.
_**Important:** all of them have a shared prefix_ **`BUKUSERVER_`**.
| Name (_without prefix_) | Description | Value _²_ |
| --- | --- | --- |
| PER_PAGE | bookmarks per page | positive integer [default: 10] |
| SECRET_KEY | [flask secret key](https://flask.palletsprojects.com/config/#SECRET_KEY) | string [default: random value] |
| URL_RENDER_MODE | url render mode | `full`, `netloc` or `netloc-tag` [default: `full`] |
| DB_FILE | full path to db file³ | path string [default: standard path for buku] |
| READONLY | read-only mode | boolean¹ [default: `false`] |
| DISABLE_FAVICON | disable bookmark [favicons](https://wikipedia.org/wiki/Favicon) | boolean¹ [default: `true`] ([here's why](#why-favicons-are-disabled-by-default))|
| AUTOFETCH | initial Fetch value in Create form | boolean¹ [default: `true`] |
| OPEN_IN_NEW_TAB | url link open in new tab | boolean¹ [default: `false`] |
| REVERSE_PROXY_PATH | reverse proxy path⁵ | string |
| SERVER_NAME | canonical host:port for URL generation⁶ | string (e.g. `example.com:443`) |
| THEME | [GUI theme](https://bootswatch.com/4) | string [default: `default`] (`slate` is a good pick for dark mode) |
| LOCALE | GUI language⁴ (partial support) | string [default: `en`] |
| DEBUG | debug mode (verbose logging etc.) | boolean¹ [default: `false`] |
_**¹**_ valid boolean values are `true`, `false`, `1`, `0` (case-insensitive).
_**²**_ if input is invalid, the default value will be used if defined
_**³**_ `BUKUSERVER_DB_FILE` can be a DB name (plain filename without extension; cannot contain `.`). The specified DB with `.db` extension is located in default DB directory (which you can override with `BUKU_DEFAULT_DBDIR`).
_**⁴**_ `BUKUSERVER_LOCALE` requires buku to be installed with `[locales]`
_**⁵**_ the value for `BUKUSERVER_REVERSE_PROXY_PATH` is recommended to include preceding slash and not have trailing slash (i.e. use `/foo` not `/foo/`)
_**⁶**_ `BUKUSERVER_SERVER_NAME` mitigates [Host header injection](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/17-Testing_for_Host_Header_Injection). When set, absolute URLs (e.g. bookmarklet) use this value instead of the client-supplied Host. **Recommended for production and reverse proxy deployments.**
#### How to specify environment variables
E.g. to set bukuserver to show 100 items per page run the following command:
```
# on linux
$ export BUKUSERVER_PER_PAGE=100
# on windows
$ SET BUKUSERVER_PER_PAGE=100
# in dockerfile
ENV BUKUSERVER_PER_PAGE=100
# in env file
BUKUSERVER_PER_PAGE=100
```
Note: an env file can be supplied either [by providing `--env-file` CLI argument](https://flask.palletsprojects.com/en/stable/cli/#environment-variables-from-dotenv) (requires `python-dotenv` installed),
or as config for [the runner script](https://github.com/jarun/buku/wiki/Bukuserver-%28WebUI%29#runner-script).
#### Why favicons are disabled by default
At Bukuserver, we have [disabled favicon as a default setting](#configuration) in order to prevent any non-user triggered network activity.
Our favicon is generated with the assistance of Google.
It is important to be aware that favicon has the potential to be used for browser fingerprinting,
a technique used to identify and track a person's web browsing habits.
- [Github repo example supercookie](https://github.com/jonasstrehle/supercookie)
- [Paper by Scientists at University of Illinois, Chicago](https://www.cs.uic.edu/~polakis/papers/solomos-ndss21.pdf)
- [Article published in 2021 at Heise Online](https://heise.de/-5027814)
([English translation](https://www-heise-de.translate.goog/news/Browser-Fingerprinting-Favicons-als-Super-Cookies-5027814.html?_x_tr_sl=de&_x_tr_tl=en&_x_tr_hl=en))
It is important to note that favicon can potentially be exploited in this way.
### API
Bukuserver implements a RESTful API that provides an HTTP-based interface for the main functionality.
The API root is at `/api`; you can also access a [Swagger](https://swagger.io/tools/swagger-ui)-based interactive doc at the endpoint `/apidocs` (e.g. `http://localhost:5000/apidocs`).
_**Note: unlike regular IDs, indices aren't static; they're likely to change if used with ID**_
* `/api/tags` can be used to `GET` the list of all tags
* `/api/tags/{tag}` can be used to `GET` information on specified tag, as well as `DELETE` or replace it with new tags (`PUT`) in all bookmarks
* `/api/bookmarks` can be used to `GET` or `DELETE` all bookmarks, as well as create (`POST`) a new one
* `/api/bookmarks/{index}` can be used to `GET`, `DELETE` or update (`PUT`) an existing bookmark
* `/api/bookmarks/{start_index}/{end_index}` can be used to `GET`, `DELETE` or update (`PUT`) bookmarks in existing index range
* `/api/bookmarks/search` can be used to `GET` or `DELETE` bookmarks matching the query (you can use it to obtain current index by URL)
* ~~`/api/bookmarks/{index}/tiny` can be used to `GET` a shortened URL~~ ([the service providing this functionality is no longer available](https://web.archive.org/web/20250109212915/https://tny.im/))
* `/api/bookmarks/{index}/refresh` can be used to update (`POST`) data for a bookmark by remotely fetching & parsing the URL
* `/api/bookmarks/refresh` can be used to update (`POST`) data for _**all**_ bookmarks by remotely fetching & parsing the URLs
* `/api/fetch_data` can be used to invoke (`POST`) the fetch+parse functionality for an arbitrary URL
* `/api/network_handle` can be used to invoke (`POST`) the fetch+parse functionality for an arbitrary URL (_outdated interface_)
Also note that certain `POST`/`DELETE` endpoints (bookmarks search & data fetch) expect their parameters in urlencoded format, while others expect JSON.
### Screenshots
_**Note: more screenshots (to show off `default` & `slate` themes) can be found [on the respective project wiki page](https://github.com/jarun/buku/wiki/Bukuserver-%28WebUI%29)**_
================================================
FILE: bukuserver/__init__.py
================================================
try:
from flask_babel import gettext, ngettext, pgettext, lazy_gettext, lazy_pgettext, LazyString
except ImportError:
from flask_admin.babel import gettext as _gettext, ngettext, lazy_gettext
gettext = lambda s, *a, **kw: (s if not kw else _gettext(s, *a, **kw))
pgettext = lambda ctx, s, *a, **kw: gettext(s, *a, **kw)
lazy_pgettext = lambda ctx, s, *a, **kw: lazy_gettext(s, *a, **kw)
LazyString = lambda func, *args, **kwargs: func(*args, **kwargs)
_, _p, _l, _lp = gettext, pgettext, lazy_gettext, lazy_pgettext
def _key(s): # replicates ad-hoc implementation of "get key from lazy string" used in flask-admin
try:
return s._args[0] # works with _/_l, but not with _lp due to the extra context argument
except Exception:
return str(s)
__all__ = ['_', '_p', '_l', '_lp', '_key', 'gettext', 'pgettext', 'ngettext', 'lazy_gettext', 'lazy_pgettext', 'LazyString']
================================================
FILE: bukuserver/__main__.py
================================================
try:
from . import server
except ImportError:
from bukuserver import server
if __name__ == '__main__':
server.cli()
================================================
FILE: bukuserver/api.py
================================================
#!/usr/bin/env python
# pylint: disable=wrong-import-order, ungrouped-imports
"""Server module."""
import collections
import typing as T
from contextlib import contextmanager
import flask
from flask import current_app, redirect, request, url_for
from flask.views import MethodView
from werkzeug.exceptions import BadRequest
from flasgger import swag_from
import buku
from buku import BukuDb
try:
from . import _
from response import Response
from forms import (TAG_RE, ApiBookmarkCreateForm, ApiBookmarkEditForm, ApiBookmarkRangeEditForm,
ApiBookmarkSearchForm, ApiTagForm, ApiFetchDataForm, ApiBookmarksReorderForm)
except ImportError:
from bukuserver import _
from bukuserver.response import Response
from bukuserver.forms import (TAG_RE, ApiBookmarkCreateForm, ApiBookmarkEditForm, ApiBookmarkRangeEditForm,
ApiBookmarkSearchForm, ApiTagForm, ApiFetchDataForm, ApiBookmarksReorderForm)
_parse_bool = lambda x: str(x).lower() == 'true'
def entity(bookmark, index=False):
data = {
'index': bookmark.id,
'url': bookmark.url,
'title': bookmark.title,
'tags': bookmark.taglist,
'description': bookmark.desc,
}
if not index:
data.pop('index')
return data
@contextmanager
def get_bukudb():
"""get bukudb instance"""
bukudb = getattr(flask.g, 'bukudb', None)
if bukudb:
yield bukudb
else:
db_file = current_app.config.get('BUKUSERVER_DB_FILE', None)
bukudb = BukuDb(dbfile=db_file)
yield bukudb
bukudb.close()
def search_tag(
db: BukuDb, stag: T.Optional[str] = None, limit: T.Optional[int] = None
) -> T.Tuple[T.List[str], T.Dict[str, int]]:
"""search tag.
db:
buku db instance
stag:
search tag
limit:
positive integer limit
Returns
-------
tuple
list of unique tags sorted alphabetically and dictionary of tag and its usage count
Raises
------
ValueError
if limit is not positive
"""
if limit is not None and limit < 1:
raise ValueError("limit must be positive")
tags: T.Set[str] = set()
counter = collections.Counter()
query_list = ["SELECT DISTINCT tags , COUNT(tags) FROM bookmarks"]
if stag:
query_list.append("where tags LIKE :search_tag")
query_list.append("GROUP BY tags")
row: T.Tuple[str, int]
for row in db.cur.execute(" ".join(query_list), {"search_tag": f"%{stag}%"}):
for tag in row[0].strip(buku.DELIM).split(buku.DELIM):
if not tag:
continue
tags.add(tag)
counter[tag] += row[1]
return list(sorted(tags)), dict(counter.most_common(limit))
def _fetch_data(convert):
form = ApiFetchDataForm(request.form)
if not form.validate():
return Response.INPUT_NOT_VALID(data={'errors': form.errors})
try:
return Response.SUCCESS(data=convert(buku.fetch_data(form.url.data)))
except Exception as e:
current_app.logger.debug(str(e))
return Response.FAILURE()
@swag_from('./apidocs/network_handle/post.yml')
def handle_network():
return _fetch_data(lambda x: {'title': x.title, 'description': x.desc, 'tags': x.keywords,
'recognized mime': int(x.mime), 'bad url': int(x.bad)})
@swag_from('./apidocs/fetch_data/post.yml')
def fetch_data():
return _fetch_data(lambda x: x._asdict())
@swag_from('./apidocs/bookmarks_refresh/post.yml', endpoint='bookmarks_refresh')
@swag_from('./apidocs/bookmark_refresh/post.yml', endpoint='bookmark_refresh')
def refresh_bookmark(index: T.Optional[int]):
with get_bukudb() as bdb:
if index and not bdb.get_rec_by_id(index):
return Response.BOOKMARK_NOT_FOUND()
result_flag = bdb.refreshdb(index or None, request.form.get('threads', 4))
return Response.from_flag(result_flag)
@swag_from('./apidocs/bookmarks_reorder/post.yml')
def reorder_bookmarks():
try:
form = ApiBookmarksReorderForm(data=request.get_json())
except BadRequest:
return Response.INVALID_REQUEST()
if not form.validate():
return Response.invalid(form.errors)
try:
with get_bukudb() as bdb:
bdb.reorder(form.order.data)
return Response.SUCCESS()
except Exception as e:
current_app.logger.exception(str(e))
return Response.FAILURE()
get_tiny_url = swag_from('./apidocs/tiny_url/get.yml')(lambda index: Response.REMOVED())
@swag_from('./apidocs/tags/get.yml')
def get_all_tags():
with get_bukudb() as bdb:
return Response.SUCCESS(data={"tags": search_tag(db=bdb, limit=5)[0]})
class ApiTagView(MethodView):
def get(self, tag: str):
with get_bukudb() as bukudb:
if not TAG_RE.match(tag):
return Response.TAG_NOT_VALID()
tag = tag.lower().strip()
tags = search_tag(db=bukudb, stag=tag)
return (Response.TAG_NOT_FOUND() if tag not in tags[1] else
Response.SUCCESS(data={"name": tag, "usage_count": tags[1][tag]}))
def put(self, tag: str):
if not TAG_RE.match(tag):
return Response.TAG_NOT_VALID()
try:
form = ApiTagForm(data=request.get_json())
except BadRequest:
return Response.INVALID_REQUEST()
if not form.validate():
return Response.invalid(form.errors)
with get_bukudb() as bukudb:
tag = tag.lower().strip()
tags = search_tag(db=bukudb, stag=tag)
if tag not in tags[1]:
return Response.TAG_NOT_FOUND()
try:
bukudb.replace_tag(tag, form.tags.data)
return Response.SUCCESS()
except (ValueError, RuntimeError):
return Response.FAILURE()
def delete(self, tag: str):
if not TAG_RE.match(tag):
return Response.TAG_NOT_VALID()
with get_bukudb() as bukudb:
tag = tag.lower().strip()
tags = search_tag(db=bukudb, stag=tag)
return (Response.TAG_NOT_FOUND() if tag not in tags[1] else
Response.from_flag(bukudb.delete_tag_at_index(0, tag, chatty=False)))
class ApiBookmarksView(MethodView):
def get(self):
with get_bukudb() as bukudb:
order = request.args.getlist('order')
all_bookmarks = bukudb.get_rec_all(order=order)
return Response.SUCCESS(data={'bookmarks': [entity(bookmark, index=order)
for bookmark in all_bookmarks]})
def post(self):
try:
form = ApiBookmarkCreateForm(data=request.get_json())
except BadRequest:
return Response.INVALID_REQUEST()
if not form.validate():
return Response.invalid(form.errors)
with get_bukudb() as bukudb:
index = bukudb.add_rec(
form.url.data,
form.title.data,
form.tags_str,
form.description.data,
fetch=form.fetch.data)
return Response.from_flag(index is not None, data=index and {'index': index})
def delete(self):
with get_bukudb() as bukudb:
return Response.from_flag(bukudb.cleardb(confirm=False))
class ApiBookmarkView(MethodView):
def get(self, index: int):
with get_bukudb() as bukudb:
bookmark = bukudb.get_rec_by_id(index)
return (Response.BOOKMARK_NOT_FOUND() if bookmark is None else
Response.SUCCESS(data=entity(bookmark)))
def put(self, index: int):
try:
form = ApiBookmarkEditForm(data=request.get_json())
except BadRequest:
return Response.INVALID_REQUEST()
if not form.validate():
return Response.invalid(form.errors)
with get_bukudb() as bukudb:
if not bukudb.get_rec_by_id(index):
return Response.BOOKMARK_NOT_FOUND()
if not form.has_data:
return Response.SUCCESS() # noop
success = bukudb.update_rec(index, url=form.url.data, title_in=form.title.data,
tags_in=form.tags_str, desc=form.description.data)
return Response.from_flag(success)
def delete(self, index: int):
with get_bukudb() as bukudb:
return (Response.BOOKMARK_NOT_FOUND() if not bukudb.get_rec_by_id(index) else
Response.from_flag(bukudb.delete_rec(index, retain_order=True)))
class ApiBookmarkRangeView(MethodView):
def get(self, start_index: int, end_index: int):
with get_bukudb() as bukudb:
max_index = bukudb.get_max_id() or 0
if start_index > end_index or end_index > max_index:
return Response.RANGE_NOT_VALID()
result = {'bookmarks': {index: entity(bukudb.get_rec_by_id(index))
for index in range(start_index, end_index + 1)}}
return Response.SUCCESS(data=result)
def put(self, start_index: int, end_index: int):
with get_bukudb() as bukudb:
max_index = bukudb.get_max_id() or 0
if start_index > end_index or end_index > max_index:
return Response.RANGE_NOT_VALID()
updates = []
errors = {}
for index in range(start_index, end_index + 1):
try:
json = request.get_json().get(str(index))
except BadRequest:
return Response.INVALID_REQUEST()
if json is None:
errors[index] = _('Input required.')
continue
form = ApiBookmarkRangeEditForm(data=json)
if not form.validate():
errors[index] = form.errors
elif form.has_data:
updates += [{'index': index,
'url': form.url.data,
'title_in': form.title.data,
'tags_in': form.tags_in,
'desc': form.description.data}]
if errors:
return Response.invalid(errors)
for update in updates:
if not bukudb.update_rec(**update):
return Response.FAILURE(data={'index': update['index']})
return Response.SUCCESS()
def delete(self, start_index: int, end_index: int):
with get_bukudb() as bukudb:
max_index = bukudb.get_max_id() or 0
if start_index > end_index or end_index > max_index:
return Response.RANGE_NOT_VALID()
result_flag = bukudb.delete_rec(None, start_index, end_index, is_range=True, retain_order=True)
return Response.from_flag(result_flag)
class ApiBookmarkSearchView(MethodView):
def get(self):
form = ApiBookmarkSearchForm(request.args)
if not form.validate():
return Response.INPUT_NOT_VALID(data={'errors': form.errors})
with get_bukudb() as bukudb:
result = [entity(bookmark, index=True) for bookmark in bukudb.searchdb(**form.data)]
current_app.logger.debug('total bookmarks:{}'.format(len(result)))
return Response.SUCCESS(data={'bookmarks': result})
def delete(self):
form = ApiBookmarkSearchForm(request.form)
if not form.validate():
return Response.INPUT_NOT_VALID(data={'errors': form.errors})
with get_bukudb() as bukudb:
deleted, failed, indices = 0, 0, {x.id for x in bukudb.searchdb(**form.data)}
current_app.logger.debug('total bookmarks:{}'.format(len(indices)))
for index in sorted(indices, reverse=True):
if bukudb.delete_rec(index, retain_order=True):
deleted += 1
else:
failed += 1
return Response.from_flag(failed == 0, data={'deleted': deleted}, errors={'failed': failed})
def bookmarklet_redirect():
url = request.args.get('url')
title = request.args.get('title')
description = request.args.get('description')
tags = request.args.get('tags')
fetch = request.args.get('fetch')
with get_bukudb() as bukudb:
rec_id = bukudb.get_rec_id(url)
goto = (url_for('bookmark.edit_view', id=rec_id, popup=True) if rec_id else
url_for('bookmark.create_view', link=url, title=title, description=description, tags=tags, fetch=fetch, popup=True))
return redirect(goto)
================================================
FILE: bukuserver/apidocs/bookmark/delete.yml
================================================
Delete an existing bookmark at current index
---
#DELETE /api/bookmarks/{index}
tags: [Bookmarks]
parameters:
- name: index
in: path
required: true
type: integer
minimum: 1
responses:
200:
description: Success
schema:
$ref: '#/definitions/Response:Success'
404:
description: Bookmark not found
schema:
$ref: '#/definitions/Response:NotFound:Bookmark'
409:
description: Failure
schema:
$ref: '#/definitions/Response:Failure'
================================================
FILE: bukuserver/apidocs/bookmark/get.yml
================================================
Fetch the data of a bookmark at current index
---
#GET /api/bookmarks/{index}
tags: [Bookmarks]
parameters:
- name: index
in: path
required: true
type: integer
minimum: 1
responses:
200:
description: Bookmark data
schema:
allOf:
- $ref: '#/definitions/Response:Success'
- $ref: '#/definitions/Data:Bookmark'
404:
description: Bookmark not found
schema:
$ref: '#/definitions/Response:NotFound:Bookmark'
================================================
FILE: bukuserver/apidocs/bookmark/put.yml
================================================
Update an existing bookmark at current index
---
#PUT /api/bookmarks/{index} {Bookmark}
tags: [Bookmarks]
parameters:
- name: index
in: path
required: true
type: integer
minimum: 1
- name: form
in: body
required: true
description: "The URL must not be present in any other bookmark"
schema:
$ref: '#/definitions/Form:Bookmark'
responses:
200:
description: Updated successfully
schema:
$ref: '#/definitions/Response:Success'
400:
description: Ill-formed request
schema:
$ref: '#/definitions/Response:IllFormedRequest'
404:
description: Bookmark not found
schema:
$ref: '#/definitions/Response:NotFound:Bookmark'
409:
description: Failed to update a bookmark
schema:
$ref: '#/definitions/Response:Failure'
422:
description: Invalid request data
schema:
$ref: '#/definitions/Response:InputNotValid:Bookmark'
================================================
FILE: bukuserver/apidocs/bookmark_range/delete.yml
================================================
Delete all bookmarks in specified index range
---
#DELETE /api/bookmarks/{start_index}/{end_index}
tags: [Bookmarks]
parameters:
- name: start_index
in: path
required: true
type: integer
minimum: 1
- name: end_index
in: path
required: true
type: integer
minimum: 1
responses:
200:
description: Success
schema:
$ref: '#/definitions/Response:Success'
404:
description: Range not valid (this includes nonexistent indices)
schema:
$ref: '#/definitions/Response:NotFound:BookmarkRange'
409:
description: Failure
schema:
$ref: '#/definitions/Response:Failure'
================================================
FILE: bukuserver/apidocs/bookmark_range/get.yml
================================================
Fetch the data of all bookmarks in specified index range
---
#GET /api/bookmarks/{start_index}/{end_index}
tags: [Bookmarks]
parameters:
- name: start_index
in: path
required: true
type: integer
minimum: 1
- name: end_index
in: path
required: true
type: integer
minimum: 1
responses:
200:
description: Bookmark data
schema:
allOf:
- $ref: '#/definitions/Response:Success'
- properties:
bookmarks:
type: object
additionalProperties:
$ref: '#/definitions/Data:Bookmark'
description: "Each key is the respective bookmark index"
example:
{42: {url: "https://slashdot.org/",
title: "SLASHDOT",
tags: ['news', 'old'],
description: "News for old nerds, stuff that doesn't matter"}}
404:
description: Range not valid (this includes nonexistent indices)
schema:
$ref: '#/definitions/Response:NotFound:BookmarkRange'
================================================
FILE: bukuserver/apidocs/bookmark_range/put.yml
================================================
Update all bookmarks in specified index range
_Note that this request **does not overwrite tags** (instead, tags are added or deleted based on **del_tags** value)._
---
#PUT /api/bookmarks/{start_index}/{end_index} {Bookmark}
tags: [Bookmarks]
parameters:
- name: start_index
in: path
required: true
type: integer
minimum: 1
- name: end_index
in: path
required: true
type: integer
minimum: 1
- name: form
in: body
required: true
schema:
type: object
description: "Each key is the respective bookmark index. _**All**_ bookmarks in the range must be present. (Indices outside of the range are ignored.)"
additionalProperties:
$ref: '#/definitions/Form:Bookmark'
example: {1: {title: "SLASHDOT"}, 2: {}, 3: {tags: ['old', 'news'], del_tags: false}, 4: {fetch: true}}
responses:
200:
description: Updated successfully
schema:
$ref: '#/definitions/Response:Success'
400:
description: Ill-formed request
schema:
$ref: '#/definitions/Response:IllFormedRequest'
404:
description: Range not valid (this includes nonexistent indices)
schema:
$ref: '#/definitions/Response:NotFound:BookmarkRange'
409:
description: Failed to update a bookmark
schema:
allOf:
- $ref: '#/definitions/Response:Failure'
- properties:
index:
type: integer
description: "Indicates which bookmark could not be updated."
example: 42
422:
description: Invalid request data
schema:
$ref: '#/definitions/Response:InputNotValid:BookmarkRange'
================================================
FILE: bukuserver/apidocs/bookmark_refresh/post.yml
================================================
Refresh bookmark data from fetched resource.
---
#POST /api/bookmarks/{index}/refresh
tags: [Util]
parameters:
- name: index
in: path
required: true
type: integer
minimum: 1
responses:
200:
description: Success
schema:
$ref: '#/definitions/Response:Success'
404:
description: Bookmark not found
schema:
$ref: '#/definitions/Response:NotFound:Bookmark'
409:
description: Failure
schema:
$ref: '#/definitions/Response:Failure'
================================================
FILE: bukuserver/apidocs/bookmarks/delete.yml
================================================
Delete all bookmarks from the database
---
#DELETE /api/bookmarks
tags: [Bookmarks]
responses:
200:
description: Success
schema:
$ref: '#/definitions/Response:Success'
409:
description: Failure
schema:
$ref: '#/definitions/Response:Failure'
================================================
FILE: bukuserver/apidocs/bookmarks/get.yml
================================================
Fetch the list of all bookmarks
---
#GET /api/bookmarks?order=
tags: [Bookmarks]
parameters:
- name: order
in: query
collectionFormat: multi
type: array
items:
type: string
example: [-netloc, title, +url]
description: |-
Determines ordering of the bookmarks list, by sequentially comparing values of each specified field.
Valid field names: `index`, `url` (or `uri`), `title`, `description` (or `desc`), `tags`, `netloc` (i.e. hostname).
A field name can be prefixed with `+` or `-` to specify sorting direction for the field (`+` is the default).
# omitted some valid names that may be confusing for the user
responses:
200:
description: A list of bookmarks (with indices if **order** was supplied)
schema:
allOf:
- $ref: '#/definitions/Response:Success'
- properties:
bookmarks:
type: array
items:
$ref: '#/definitions/Data:BookmarkWithIndex'
================================================
FILE: bukuserver/apidocs/bookmarks/post.yml
================================================
Create a new bookmark
---
#POST /api/bookmarks {Bookmark}
tags: [Bookmarks]
parameters:
- name: form
in: body
required: true
description: "The URL must not be present in the database"
schema:
allOf:
- $ref: '#/definitions/Form:Bookmark'
- properties:
url:
required: true
responses:
200:
description: Success
schema:
allOf:
- $ref: '#/definitions/Response:Success'
- properties:
index:
type: integer
example: 42
400:
description: Ill-formed request
schema:
$ref: '#/definitions/Response:IllFormedRequest'
409:
description: Failed to create a bookmark (e.g. duplicate URL)
schema:
$ref: '#/definitions/Response:Failure'
422:
description: Invalid request data
schema:
$ref: '#/definitions/Response:InputNotValid:Bookmark'
================================================
FILE: bukuserver/apidocs/bookmarks_refresh/post.yml
================================================
Refresh all bookmark data from fetched resources.
---
#POST /api/bookmarks/refresh
tags: [Util]
responses:
200:
description: Success
schema:
$ref: '#/definitions/Response:Success'
409:
description: Failure
schema:
$ref: '#/definitions/Response:Failure'
================================================
FILE: bukuserver/apidocs/bookmarks_reorder/post.yml
================================================
Update DB indices to match specified order.
---
#POST /api/bookmarks/reorder order=
tags: [Util]
parameters:
- name: form
in: body
required: true
schema:
type: object
properties:
order:
required: true
type: array
items:
type: string
example: [-netloc, title, +url]
description: |-
Determines ordering of the bookmarks list, by sequentially comparing values of each specified field.
Valid field names: `index`, `url` (or `uri`), `title`, `description` (or `desc`), `tags`, `netloc` (i.e. hostname).
A field name can be prefixed with `+` or `-` to specify sorting direction for the field (`+` is the default).
# omitted some valid names that may be confusing for the user
responses:
200:
description: Reordered successfully
schema:
$ref: '#/definitions/Response:Success'
400:
description: Ill-formed request
schema:
$ref: '#/definitions/Response:IllFormedRequest'
409:
description: Failed to reorder bookmarks
schema:
$ref: '#/definitions/Response:Failure'
422:
description: Invalid request data
schema:
$ref: '#/definitions/Response:InputNotValid'
================================================
FILE: bukuserver/apidocs/bookmarks_search/delete.yml
================================================
Delete all bookmarks matching the query
You can send a non-boolean value in a boolean field, but it is only considered truthy if converting it to a lowercase string produces **'true'**.
Special behaviour exists for the following combinations:
- **all_keywords** = _true_, **regex** = _false_, **keywords** = _blank_ (bookmarks with no title or no tags)
- **all_keywords** = _true_, **regex** = _false_, **keywords** = _immutable_ (bookmarks marked with **--immutable** flag)
---
#GET /api/bookmarks/search {BookmarkSearch=}
tags: [Util]
consumes: ['application/x-www-form-urlencoded']
parameters:
- name: all_keywords
in: formData
type: boolean
description: "Exclude partial matches (with multiple keywords)."
- name: deep
in: formData
type: boolean
description: "Unless set to true, only _**full** words_ will be matched."
- name: regex
in: formData
type: boolean
description: "The keyword(s) are regular expressions (overrides other options)."
- name: markers
in: formData
type: boolean
description: |-
When this is enabled, each keyword will be applied to a specific field based on prefix:
* keywords starting with `.`, `>` or `:` will be searched for in _title_, _description_ and _URL_ respectively
* `#` will be searched for in _tags_ (comma-separated, partial matches; not affected by **deep** value)
* `#,` is the same but will match _**full** tags only_
* `*` will be searched for in _all fields_ (implied if no prefix was provided)
- name: keywords
in: formData
required: true
collectionFormat: multi
type: array
items:
type: string
example: ["global substring", ".title substring", ":url substring", ">description substring", "#partial,tags:", "#,exact,tags", "*another global substring"]
description: "A set of terms to search for."
responses:
200:
description: Deletion successful
schema:
allOf:
- $ref: '#/definitions/Response:Success'
- properties:
deleted:
type: integer
description: "Amount of bookmarks that were successfully deleted."
example: 3
409:
description: Deletion failed
schema:
allOf:
- $ref: '#/definitions/Response:Failure'
- properties:
deleted:
type: integer
description: "Amount of bookmarks that were successfully deleted."
example: 1
errors:
type: object
properties:
failed:
type: integer
example: 2
description: "Amount of bookmarks that could not be deleted."
422:
description: Invalid request data
schema:
$ref: '#/definitions/Response:InputNotValid:BookmarkSearch'
================================================
FILE: bukuserver/apidocs/bookmarks_search/get.yml
================================================
Fetch all bookmarks matching the query
You can send a non-boolean value in a boolean field, but it is only considered truthy if converting it to a lowercase string produces **'true'**.
Special behaviour exists for the following combinations:
- **all_keywords** = _true_, **regex** = _false_, **keywords** = _blank_ (bookmarks with no title or no tags)
- **all_keywords** = _true_, **regex** = _false_, **keywords** = _immutable_ (bookmarks marked with **--immutable** flag)
---
#GET /api/bookmarks/search?{BookmarkSearch=}
tags: [Util]
parameters:
- name: all_keywords
in: query
type: boolean
description: "Exclude partial matches (with multiple keywords)."
- name: deep
in: query
type: boolean
description: "Unless set to true, only _**full** words_ will be matched."
- name: regex
in: query
type: boolean
description: "The keyword(s) are regular expressions (overrides other options)."
- name: order
in: query
collectionFormat: multi
type: array
items:
type: string
example: [-netloc, title, +url]
description: |-
Determines ordering of the bookmarks list, by sequentially comparing values of each specified field.
Valid field names: `index`, `url` (or `uri`), `title`, `description` (or `desc`), `tags`, `netloc` (i.e. hostname).
A field name can be prefixed with `+` or `-` to specify sorting direction for the field (`+` is the default).
# omitted some valid names that may be confusing for the user
- name: markers
in: query
type: boolean
description: |-
When this is enabled, each keyword will be applied to a specific field based on prefix:
* keywords starting with `.`, `>` or `:` will be searched for in _title_, _description_ and _URL_ respectively
* `#` will be searched for in _tags_ (comma-separated, partial matches; not affected by **deep** value)
* `#,` is the same but will match _**full** tags only_
* `*` will be searched for in _all fields_ (implied if no prefix was provided)
- name: keywords
in: query
required: true
collectionFormat: multi
type: array
items:
type: string
example: ["global substring", ".title substring", ":url substring", ">description substring", "#partial,tags:", "#,exact,tags", "*another global substring"]
description: "A set of terms to search for."
responses:
200:
description: A list of bookmarks
schema:
allOf:
- $ref: '#/definitions/Response:Success'
- properties:
bookmarks:
type: array
items:
$ref: '#/definitions/Data:BookmarkWithIndex'
422:
description: Invalid request data
schema:
$ref: '#/definitions/Response:InputNotValid:BookmarkSearch'
================================================
FILE: bukuserver/apidocs/fetch_data/post.yml
================================================
Fetch data from URL (i.e. to test parsing functionality)
---
#POST /api/fetch_data url=
tags: [Util]
consumes: ['application/x-www-form-urlencoded']
parameters:
- name: url
in: formData
required: true
type: string
format: uri
responses:
200:
description: Operation executed normally
schema:
allOf:
- $ref: '#/definitions/Response:Success'
- properties:
url:
type: string
format: uri
example: 'https://slashdot.org/'
title:
type: string
example: "Slashdot: News for nerds, stuff that matters"
desc:
type: string
example: "Slashdot: News for nerds, stuff that matters.\n
Timely news source for technology related news with a heavy slant towards Linux and Open Source issues."
keywords:
type: string
example: "empty,usually"
mime:
type: boolean
example: false
description: true indicates that the URL implied non-webpage content (and therefore HTTP HEAD request was sent)
bad:
type: boolean
example: false
description: true indicates that the input did not contain a fetchable URL (and therefore no request was sent)
fetch_status:
type: integer
example: 200
description: HTTP response code or null (e.g. if network error occurred)
409:
description: Operation could not be executed
schema:
$ref: '#/definitions/Response:Failure'
422:
description: Invalid request data
schema:
$ref: '#/definitions/Response:InputNotValid:Url'
================================================
FILE: bukuserver/apidocs/network_handle/post.yml
================================================
Fetch data from URL (i.e. to test parsing functionality)
`[DEPRECATED]` prefer **/api/fetch_data**
---
#POST /api/network_handle url=
tags: [Util]
consumes: ['application/x-www-form-urlencoded']
parameters:
- name: url
in: formData
required: true
type: string
format: uri
responses:
200:
description: Operation executed normally
schema:
allOf:
- $ref: '#/definitions/Response:Success'
- properties:
title:
type: string
example: "Slashdot: News for nerds, stuff that matters"
description:
type: string
example: "Slashdot: News for nerds, stuff that matters.\n
Timely news source for technology related news with a heavy slant towards Linux and Open Source issues."
tags:
type: string
example: "empty,usually"
recognized mime:
type: integer
example: 0
description: 1 indicates that the URL implied non-webpage content (and therefore HTTP HEAD request was sent)
bad url:
type: integer
example: 0
description: 1 indicates that the input did not contain a fetchable URL (and therefore no request was sent)
409:
description: Operation could not be executed
schema:
$ref: '#/definitions/Response:Failure'
422:
description: Invalid request data
schema:
$ref: '#/definitions/Response:InputNotValid:Url'
================================================
FILE: bukuserver/apidocs/tag/delete.yml
================================================
Remove the specified tag from all bookmarks
---
#DELETE /api/tags/{tag}
tags: [Tags]
parameters:
- name: tag
in: path
required: true
type: string
pattern: '^[^,]*[^,\s]+[^,]*$'
description: "Cannot include comma; cannot be blank"
responses:
200:
description: Deleted successfully
schema:
$ref: '#/definitions/Response:Success'
404:
description: Tag not found
schema:
$ref: '#/definitions/Response:NotFound:Tag'
409:
description: Failed to delete
schema:
$ref: '#/definitions/Response:Failure'
422:
description: Invalid tag
schema:
$ref: '#/definitions/Response:NotValid:Tag'
================================================
FILE: bukuserver/apidocs/tag/get.yml
================================================
Get information on the specified tag
---
#GET /api/tags/{tag}
tags: [Tags]
parameters:
- name: tag
in: path
required: true
type: string
pattern: '^[^,]*[^,\s]+[^,]*$'
description: "Cannot include comma; cannot be blank"
responses:
200:
description: Tag information
schema:
allOf:
- $ref: '#/definitions/Response:Success'
- type: object
properties:
name:
type: string
example: 'foo'
usage_count:
type: integer
example: 42
404:
description: Tag not found
schema:
$ref: '#/definitions/Response:NotFound:Tag'
422:
description: Invalid tag
schema:
$ref: '#/definitions/Response:NotValid:Tag'
================================================
FILE: bukuserver/apidocs/tag/put.yml
================================================
Replace the specified tag with one or more new tags
---
#PUT /api/tags/{tag} {"tags": []}
tags: [Tags]
parameters:
- name: tag
in: path
required: true
type: string
pattern: '^[^,]*[^,\s]+[^,]*$'
description: "Cannot include comma; cannot be blank"
- name: form
in: body
required: true
schema:
$ref: '#/definitions/Form:Tags'
responses:
200:
description: Renamed successfully
schema:
$ref: '#/definitions/Response:Success'
400:
description: Ill-formed request
schema:
$ref: '#/definitions/Response:IllFormedRequest'
404:
description: Tag not found
schema:
$ref: '#/definitions/Response:NotFound:Tag'
409:
description: Failed to rename
schema:
$ref: '#/definitions/Response:Failure'
422:
description: Invalid tag/request data
schema:
$ref: '#/definitions/Response:InputNotValid:Tags'
================================================
FILE: bukuserver/apidocs/tags/get.yml
================================================
Fetch the list of all tags
---
#GET /api/tags
tags: [Tags]
responses:
200:
description: A list of tags (sorted lexicographically)
schema:
allOf:
- $ref: '#/definitions/Response:Success'
- type: object
properties:
tags:
type: array
items:
type: string
example: ['bar', 'baz', 'foo']
================================================
FILE: bukuserver/apidocs/template.yml
================================================
info:
title: "Bukuserver API"
description: "RESTful API for managing your personal bookmarks."
version: 'v5.0'
consumes: ['application/json']
produces: ['application/json']
tags:
- name: Tags
description: "Global operations on tags"
- name: Bookmarks
description: "Index-based (CRUD) operations on bookmarks"
- name: Util
description: "Other functionality"
definitions:
'Value:Tag':
type: string
pattern: '^[^,]*[^,\s]+[^,]*$'
description: "Cannot include comma; cannot be blank"
'Data:Bookmark':
type: object
properties:
url:
type: string
format: uri
example: 'https://slashdot.org/'
title:
type: string
example: "SLASHDOT"
tags:
type: array
items:
$ref: '#/definitions/Value:Tag'
example: ['news', 'old']
description:
type: string
example: "News for old nerds, stuff that doesn't matter"
'Data:BookmarkWithIndex':
allOf:
- $ref: '#/definitions/Data:Bookmark'
- properties:
index:
type: integer
example: 42
description: "If omitted (in a list), implies natural order (i.e. enumerated starting from 1)"
'Form:Bookmark':
allOf:
- $ref: '#/definitions/Data:Bookmark'
- properties:
fetch:
type: boolean
example: false
description: "'true' enables fetching unspecified data (e.g. title) from the URL"
'Form:Tags':
type: object
properties:
tags:
type: array
items:
$ref: '#/definitions/Value:Tag'
example: ['bar', 'baz']
'Response:Success':
type: object
properties:
message:
type: string
example: "Success."
status:
type: integer
example: 0
description: 0 indicates successful completion of the request
'Response:Failure':
type: object
properties:
message:
type: string
example: "Failure."
status:
type: integer
example: 1
description: 1 indicates failed completion of the request
'Response:IllFormedRequest':
allOf:
- $ref: '#/definitions/Response:Failure'
- properties:
message:
example: "Ill-formed request."
'Response:InputNotValid':
allOf:
- $ref: '#/definitions/Response:Failure'
- properties:
message:
example: "Input data not valid."
errors:
type: object
additionalProperties: {}
'Response:InputNotValid:Tags':
allOf:
- $ref: '#/definitions/Response:InputNotValid'
- properties:
errors:
properties:
tags:
description: "A single error for the field itself, or a list containing error lists for each tag."
example: ["This field is required."]
'Response:InputNotValid:Url':
allOf:
- $ref: '#/definitions/Response:InputNotValid'
- properties:
errors:
properties:
url:
type: array
items: {type: string}
example: ["This field is required."]
'Response:InputNotValid:Bookmark':
allOf:
- $ref: '#/definitions/Response:InputNotValid'
- properties:
errors:
properties:
url:
type: array
items: {type: string}
title:
type: array
items: {type: string}
tags:
type: array
items:
type: array
items: {type: string}
description:
type: array
items: {type: string}
fetch:
type: array
items: {type: string}
example: {url: ["This field is required."], tags: [[], ["The value must be a string."], ["Invalid input."]]}
'Response:InputNotValid:BookmarkRange':
allOf:
- $ref: '#/definitions/Response:InputNotValid'
- properties:
errors:
description: "Each key is a bookmark index"
additionalProperties:
description: "A single error indicating that the bookmark is missing, or a form validation result"
example:
{1: {url: ["Field must be at least 1 character long."]},
3: "Input required.",
4: {tags: [[], ["The value must be a string."], ["Invalid input."]]}}
'Response:InputNotValid:BookmarkSearch':
allOf:
- $ref: '#/definitions/Response:InputNotValid'
- properties:
errors:
properties:
keywords:
type: array
items:
type: array
items: {type: string}
example: {keywords: [[], ["The value must be a string."]]}
'Response:NotFound:Bookmark':
allOf:
- $ref: '#/definitions/Response:Failure'
- properties:
message:
example: "Bookmark not found."
'Response:NotFound:BookmarkRange':
allOf:
- $ref: '#/definitions/Response:Failure'
- properties:
message:
example: "Range not valid."
'Response:NotFound:Tag':
allOf:
- $ref: '#/definitions/Response:Failure'
- properties:
message:
example: "Tag not found."
'Response:NotValid:Tag':
allOf:
- $ref: '#/definitions/Response:Failure'
- properties:
message:
example: "Invalid tag."
'Response:Removed':
allOf:
- $ref: '#/definitions/Response:Failure'
- properties:
message:
example: "Functionality no longer available."
================================================
FILE: bukuserver/apidocs/tiny_url/get.yml
================================================
Fetch the shortened URL
`[NO LONGER AVAILABLE]`
---
#GET /api/bookmarks/{index}/tiny
tags: [Util]
parameters:
- name: index
in: path
required: true
type: integer
minimum: 1
responses:
410:
description: Functionality no longer available.
schema:
$ref: '#/definitions/Response:Removed'
================================================
FILE: bukuserver/bookmarklet.js
================================================
// source for the bookmarklet in templates/bukuserver/bookmarklet.url:
//
// 1. paste this code in https://bookmarklets.org/maker/
// 2. replace contents of bookmarklet.url file with the result
//
// ("{{url}}" will be substituted with actual URL at runtime)
var url = location.href;
var title = document.title.trim() || "";
var desc = document.getSelection().toString().trim() || (document.querySelector('meta[name$=description i], meta[property$=description i]')||{}).content || "";
if(desc.length > 4000){
desc = desc.substr(0,4000) + '...';
alert('The selected text is too long, it will be truncated.');
}
url = "{{url}}" +
"?url=" + encodeURIComponent(url) +
"&title=" + encodeURIComponent(title) +
"&description=" + encodeURIComponent(desc);
window.open(url, '_blank', 'menubar=no, height=700, width=800, toolbar=no, scrollbars=yes, status=no, dialog=1');
================================================
FILE: bukuserver/filters.py
================================================
from enum import Enum
from flask_admin.model import filters
from bukuserver import _l, _key
class BookmarkField(Enum):
ID = 0
URL = 1
TITLE = 2
TAGS = 3
DESCRIPTION = 4
def equal_func(query, value, index):
return filter(lambda x: x[index] == value, query)
def not_equal_func(query, value, index):
return filter(lambda x: x[index] != value, query)
def contains_func(query, value, index):
return filter(lambda x: value in x[index], query)
def not_contains_func(query, value, index):
return filter(lambda x: value not in x[index], query)
def greater_func(query, value, index):
return filter(lambda x: x[index] > value, query)
def smaller_func(query, value, index):
return filter(lambda x: x[index] < value, query)
def in_list_func(query, value, index):
return filter(lambda x: x[index] in value, query)
def not_in_list_func(query, value, index):
return filter(lambda x: x[index] not in value, query)
def top_x_func(query, value, index):
items = sorted(set(x[index] for x in query), reverse=True)
top_x = set(items[:value])
return filter((lambda x: x[index] in top_x), query)
def bottom_x_func(query, value, index):
items = sorted(set(x[index] for x in query), reverse=False)
top_x = set(items[:value])
return filter((lambda x: x[index] in top_x), query)
class FilterType(Enum):
EQUAL = {'func': equal_func, 'text': _l('equals')}
NOT_EQUAL = {'func': not_equal_func, 'text': _l('not equals')}
CONTAINS = {'func': contains_func, 'text': _l('contains')}
NOT_CONTAINS = {'func': not_contains_func, 'text': _l('not contains')}
GREATER = {'func': greater_func, 'text': _l('greater than')}
SMALLER = {'func': smaller_func, 'text': _l('smaller than')}
IN_LIST = {'func': in_list_func, 'text': _l('in list')}
NOT_IN_LIST = {'func': not_in_list_func, 'text': _l('not in list')}
TOP_X = {'func': top_x_func, 'text': _l('top X')}
BOTTOM_X = {'func': bottom_x_func, 'text': _l('bottom X')}
class BaseFilter(filters.BaseFilter):
def operation(self):
return getattr(self, 'operation_text')
def apply(self, query, value):
return getattr(self, 'apply_func')(query, value, getattr(self, 'index'))
class TagBaseFilter(BaseFilter):
def __init__(
self,
name,
operation_text=None,
apply_func=None,
filter_type=None,
options=None,
data_type=None):
try:
self.index = ['name', 'usage_count'].index(name)
except ValueError as e:
raise ValueError(f'name: {name}') from e
self.filter_type = filter_type
if filter_type:
self.apply_func = filter_type.value['func']
self.operation_text = filter_type.value['text']
else:
self.apply_func = apply_func
self.operation_text = operation_text
if _key(self.operation_text) in ('in list', 'not in list'):
super().__init__(name, options, data_type='select2-tags')
else:
super().__init__(name, options, data_type)
def clean(self, value):
on_list = self.filter_type in (FilterType.IN_LIST, FilterType.NOT_IN_LIST)
if on_list and self.name == 'usage_count':
value = [int(v.strip()) for v in value.split(',') if v.strip()]
elif on_list:
value = [v.strip() for v in value.split(',') if v.strip()]
elif self.name == 'usage_count':
value = int(value)
if self.filter_type in (FilterType.TOP_X, FilterType.BOTTOM_X) and value < 1:
raise ValueError
if isinstance(value, str):
return value.strip()
return value
class BookmarkOrderFilter(BaseFilter):
DIR_LIST = [('asc', _l('natural')), ('desc', _l('reversed'))]
FIELDS = ['index', 'url', 'netloc', 'title', 'description', 'tags', '-#', '#']
_NAMES = {'-#': 'with tag first', '#': 'with tag last'}
def __init__(self, field, *args, **kwargs):
self.field = field
super().__init__('order', *args, options=(None if field in self._NAMES else self.DIR_LIST), **kwargs)
def operation(self):
key = self._NAMES.get(self.field, f'by {self.field}')
return _l(key)
def apply(self, query, value):
return query
@staticmethod
def value(filters, values):
return [(filters[idx].field + value if filters[idx].field in BookmarkOrderFilter._NAMES else
('-' if value == 'desc' else '+') + filters[idx].field)
for idx, key, value in values if key == 'order']
class BookmarkBukuFilter(BaseFilter):
KEYS = {
'markers': 'markers',
'all_keywords': 'match all',
'deep': 'deep',
'regex': 'regex',
}
def __init__(self, *args, **kwargs):
self.params = {key: kwargs.pop(key, False) for key in self.KEYS}
super().__init__('buku', *args, **kwargs)
def operation(self):
parts = ', '.join(v for k, v in self.KEYS.items() if self.params[k])
key = 'search' + (parts and ' ' + parts)
return _l(key)
def apply(self, query, value):
return query
class BookmarkBaseFilter(BaseFilter):
def __init__(
self,
name,
operation_text=None,
apply_func=None,
filter_type=None,
options=None,
data_type=None):
bm_fields_dict = {x.name.lower(): x.value for x in BookmarkField}
if name in bm_fields_dict:
self.index = bm_fields_dict[name]
else:
raise ValueError(f'name: {name}')
self.filter_type = None
if filter_type:
self.apply_func = filter_type.value['func']
self.operation_text = filter_type.value['text']
else:
self.apply_func = apply_func
self.operation_text = operation_text
if _key(self.operation_text) in ('in list', 'not in list'):
super().__init__(name, options, data_type='select2-tags')
else:
super().__init__(name, options, data_type)
def clean(self, value):
on_list = _key(self.operation_text) in ('in list', 'not in list')
if on_list and self.name == BookmarkField.ID.name.lower():
value = [int(v.strip()) for v in value.split(',') if v.strip()]
elif on_list:
value = [v.strip() for v in value.split(',') if v.strip()]
elif self.name == BookmarkField.ID.name.lower():
value = int(value)
if self.filter_type in (FilterType.TOP_X, FilterType.BOTTOM_X) and value < 1:
raise ValueError
if isinstance(value, str):
return value.strip()
return value
class BookmarkTagNumberEqualFilter(BookmarkBaseFilter):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def apply_func(query, value, index):
for item in query:
tags = [tag for tag in item[index].split(',') if tag]
if len(tags) == value:
yield item
self.apply_func = apply_func
def clean(self, value):
value = int(value)
if value < 0:
raise ValueError
return value
class BookmarkTagNumberGreaterFilter(BookmarkTagNumberEqualFilter):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def apply_func(query, value, index):
for item in query:
tags = [tag for tag in item[index].split(',') if tag]
if len(tags) > value:
yield item
self.apply_func = apply_func
class BookmarkTagNumberNotEqualFilter(BookmarkTagNumberEqualFilter):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def apply_func(query, value, index):
for item in query:
tags = [tag for tag in item[index].split(',') if tag]
if len(tags) != value:
yield item
self.apply_func = apply_func
class BookmarkTagNumberSmallerFilter(BookmarkBaseFilter):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def apply_func(query, value, index):
for item in query:
tags = [tag for tag in item[index].split(',') if tag]
if len(tags) < value:
yield item
self.apply_func = apply_func
def clean(self, value):
value = int(value)
if value < 1:
raise ValueError
return value
================================================
FILE: bukuserver/forms.py
================================================
"""Forms module."""
# pylint: disable=too-few-public-methods, missing-docstring
import re
from flask_wtf import FlaskForm
from wtforms import Form
from wtforms.fields import BooleanField, FieldList, URLField, StringField, TextAreaField, HiddenField, SelectMultipleField
from wtforms.validators import DataRequired, InputRequired, Length, Regexp, StopValidation
from buku import DELIM, taglist_str
from bukuserver import _, _l, LazyString
_parse_bool = lambda x: str(x).lower() == 'true'
TAG_RE = re.compile(r'^[^,]*[^,\s]+[^,]*$')
def optional_none(form, field):
if field.data is None:
raise StopValidation()
def is_string(form, field):
if not isinstance(field.data, str):
raise StopValidation(_('The value must be a string.'))
validate_tag = [is_string, Regexp(TAG_RE)]
class ValueList(SelectMultipleField):
"""A form field model for simple value lists, capable of processing regular array data."""
def __init__(self, *args, item_validators=[], **kwargs):
self.data, self._valid, self._field = None, True, StringField(validators=item_validators)
super().__init__(*args, choices=[], validate_choice=False, coerce=(lambda x: x), **kwargs)
def process_data(self, value):
self._valid = isinstance(value, (list, tuple, set, type(None))) # i.e. for JSON input
self.data = None
if self._valid:
super().process_data(value)
def pre_validate(self, form):
_errors = []
_field = self._field.bind(form=form, name=self.name, _meta=self.meta, translations=self._translations) # pylint: disable=no-member
for item in (self.data or []):
_field.data = item
_field.validate(form)
_errors += [_field.errors]
if any(x for x in _errors):
self.errors += _errors
if not self._valid:
raise StopValidation(self.gettext('Invalid input.'))
class SearchBookmarksForm(FlaskForm):
keywords = FieldList(StringField(_l('Keywords')), min_entries=1)
all_keywords = BooleanField(_l('Match all keywords'), default=True, description=_l('Exclude partial matches (with multiple keywords)'))
markers = BooleanField(_l('With markers'), default=True, description=LazyString(lambda: '\n'.join([
_('The search string will be split into multiple keywords, each will be applied to a field based on prefix:'),
_(" - keywords starting with '.', '>' or ':' will be searched for in title, description and URL respectively"),
_(" - '#' will be searched for in tags (comma-separated, partial matches; not affected by Deep Search)"),
_(" - '#,' is the same but will match FULL tags only"),
_(" - '*' will be searched for in all fields (this prefix can be omitted in the 1st keyword)"),
_('Keywords need to be separated by placing spaces before the prefix.'),
])))
deep = BooleanField(_l('Deep search'), description=_l('When unset, only FULL words will be matched.'))
regex = BooleanField(_l('Regex'), description=_l('The keyword(s) are regular expressions (overrides other options).'))
class HomeForm(SearchBookmarksForm):
keyword = StringField(_l('Keyword'))
class BookmarkForm(FlaskForm):
url = URLField(_l('URL'), name='link', validators=[InputRequired()])
title = StringField(_l('Title'))
tags = StringField(_l('Tags'))
description = TextAreaField(_l('Description'))
fetch = HiddenField(filters=[bool])
class SwapForm(FlaskForm):
id1 = HiddenField(filters=[int])
id2 = HiddenField(filters=[int])
class ApiFetchDataForm(Form):
url = StringField(validators=[DataRequired()])
class ApiTagForm(Form):
tags = ValueList(validators=[DataRequired()], item_validators=validate_tag)
@property
def tags_str(self):
return (None if self.tags.data is None else taglist_str(DELIM.join(self.tags.data)))
class ApiBookmarkCreateForm(ApiTagForm):
url = StringField(validators=[DataRequired()])
title = StringField()
description = StringField()
tags = ValueList(item_validators=validate_tag)
fetch = BooleanField(filters=[_parse_bool])
@property
def data_values(self):
return [self.url.data, self.title.data, self.description.data, self.tags.data]
@property
def has_data(self):
return self.fetch.data or any(self.data_values)
class ApiBookmarkEditForm(ApiBookmarkCreateForm):
url = StringField(validators=[optional_none, Length(min=1)])
@property
def has_data(self): # allowing to delete existing values
return self.fetch.data or any(x is not None for x in self.data_values)
class ApiBookmarkRangeEditForm(ApiBookmarkEditForm):
del_tags = BooleanField(_('Delete tags list from existing tags'), default=False)
@property
def tags_in(self):
return (None if not self.tags.data else ('-' if self.del_tags.data else '+') + self.tags_str)
@property
def data_values(self): # ignoring empty tags list
return [self.url.data, self.title.data, self.description.data, self.tags_in]
class ApiBookmarkSearchForm(Form):
keywords = ValueList(validators=[DataRequired()], item_validators=[is_string])
all_keywords = BooleanField(filters=[_parse_bool])
deep = BooleanField(filters=[_parse_bool])
regex = BooleanField(filters=[_parse_bool])
markers = BooleanField(filters=[_parse_bool])
order = ValueList(item_validators=[is_string])
class ApiBookmarksReorderForm(Form):
order = ValueList(validators=[DataRequired()], item_validators=[is_string])
================================================
FILE: bukuserver/middleware/__init__.py
================================================
from .flask_reverse_proxy_fix import ReverseProxyPrefixFix
__all__ = ['ReverseProxyPrefixFix']
================================================
FILE: bukuserver/middleware/flask_reverse_proxy_fix.py
================================================
# clone of flask-reverse-proxy-fix with github fixes from @rachmadaniHaryono and @Glocktober
from flask import Flask as App
# noinspection PyPackageRequirements
try:
from werkzeug.contrib.fixers import ProxyFix
except ModuleNotFoundError:
from werkzeug.middleware.proxy_fix import ProxyFix
class ReverseProxyPrefixFix: # pylint: disable=too-few-public-methods
"""
Flask middleware to ensure correct URLs are generated by Flask.url_for() where an application is under a reverse
proxy. Specifically this middleware corrects URLs where a common prefix needs to be added to all URLs.
For example: If client requests for an application are reverse proxied such that:
`example.com/some-service/v1/foo` becomes `some-service-v1.internal/foo`, where `/foo` is a route within a Flask
application `foo()`.
Without this middleware, a call to `Flask.url_for('.foo')` would give: `/foo`. If returned to the client, as a
'self' link for example, this would cause a request to `example.com/foo`, which would be invalid as the
`/some-service/v1` prefix is missing.
With this middleware, a call to `Flask.url_for('.foo')` would give: '/some-service/v1/foo', which will work if used
by a client.
This middleware is compatible with both relative and absolute URLs (i.e. `Flask.url_for('.foo')` and
`Flask.url_for('.foo', _external=True)`.
This middleware incorporates the `werkzeug.contrib.fixers.ProxyFix` middleware [1] and is based on the
'Fixing SCRIPT_NAME/url_scheme when behind reverse proxy' Flask snippet [2].
Note: Ensure the prefix value includes a preceding slash, but not a trailing slash (i.e. use `/foo` not `/foo/`).
[1] http://werkzeug.pocoo.org/docs/0.14/contrib/fixers/#werkzeug.contrib.fixers.ProxyFix
[2] http://flask.pocoo.org/snippets/35/
"""
def __init__(self, app: App, **kwargs):
"""
:type app: App
:param app: Flask application
"""
self.app = app.wsgi_app
self.prefix = None
if 'REVERSE_PROXY_PATH' in app.config:
self.prefix = app.config['REVERSE_PROXY_PATH']
self.app = ProxyFix(self.app, **kwargs)
app.wsgi_app = self
def __call__(self, environ, start_response):
if self.prefix is not None:
environ['SCRIPT_NAME'] = self.prefix
path_info = environ['PATH_INFO']
if path_info.startswith(self.prefix):
environ['PATH_INFO'] = path_info[len(self.prefix):]
return self.app(environ, start_response)
================================================
FILE: bukuserver/requirements.txt
================================================
arrow>=1.2.2
Flask-Admin>=2.0.0
flask-paginate>=2022.1.8
Flask-WTF>=1.0.1
Flask>=2.2.2
Jinja2>=3
flasgger
================================================
FILE: bukuserver/response.py
================================================
from typing import Any, Dict
from enum import Enum
from http import HTTPStatus
from flask import jsonify
OK, FAIL = 0, 1
class Response(Enum):
SUCCESS = (HTTPStatus.OK, "Success.") # 200
FAILURE = (HTTPStatus.CONFLICT, "Failure.") # 409
REMOVED = (HTTPStatus.GONE, "Functionality no longer available.") # 410
INVALID_REQUEST = (HTTPStatus.BAD_REQUEST, "Ill-formed request.") # 400
INPUT_NOT_VALID = (HTTPStatus.UNPROCESSABLE_ENTITY, "Input data not valid.") # 422
TAG_NOT_VALID = (HTTPStatus.UNPROCESSABLE_ENTITY, "Invalid tag.") # 422
BOOKMARK_NOT_FOUND = (HTTPStatus.NOT_FOUND, "Bookmark not found.") # 404
RANGE_NOT_VALID = (HTTPStatus.NOT_FOUND, "Range not valid.") # 404
TAG_NOT_FOUND = (HTTPStatus.NOT_FOUND, "Tag not found.") # 404
@staticmethod
def invalid(errors):
return Response.INPUT_NOT_VALID(data={'errors': errors})
@staticmethod
def from_flag(flag: bool, *, data: Dict[str, Any] = None, errors: Dict[str, Any] = None):
errors = dict(({'errors': errors} if errors else {}), **(data or {}))
return Response.SUCCESS(data=data) if flag else Response.FAILURE(data=errors)
@property
def status_code(self) -> int:
return self.value[0].value
@property
def message(self) -> str:
return self.value[1]
@property
def status(self) -> int:
return OK if self.status_code == HTTPStatus.OK.value else FAIL
def json(self, data: Dict[str, Any] = None) -> Dict[str, Any]:
return dict(status=self.status, message=self.message, **data or {}) # pylint: disable=R1735
def __call__(self, *, data: Dict[str, Any] = None):
"""Generates a tuple in the form (response, status, headers)
If passed, data is added to the response's JSON.
"""
return (jsonify(self.json(data)), self.status_code, {'ContentType': 'application/json'})
================================================
FILE: bukuserver/server.py
================================================
#!/usr/bin/env python
# pylint: disable=wrong-import-order, ungrouped-imports
"""Server module."""
import os
import sys
import importlib.metadata
from urllib.parse import urlsplit, urlunsplit, parse_qs
import click
import flask
from flask import Flask, redirect, request, url_for
from flask.cli import FlaskGroup
from flask_admin import Admin
from flask_admin.theme import Bootstrap4Theme
from flasgger import Swagger
from buku import BukuDb, __version__
try:
from .middleware import ReverseProxyPrefixFix
except ImportError:
from bukuserver.middleware import ReverseProxyPrefixFix
try:
from . import api, views, util, _p, _l, gettext, ngettext
except ImportError:
from bukuserver import api, views, util, _p, _l, gettext, ngettext
FLASK_VERSION = importlib.metadata.version('flask')
_BOOL_VALUES = {'true': True, '1': True, 'false': False, '0': False}
def get_bool_from_env_var(key: str, default_value: bool = False) -> bool:
"""Get bool value from env var."""
return _BOOL_VALUES.get(os.getenv(key, '').lower(), default_value)
def init_locale(app, context_processor=lambda: {}):
try:
from flask_babel import Babel
Babel().init_app(app, locale_selector=lambda: app.config['BUKUSERVER_LOCALE'])
app.context_processor(lambda: {'lang': app.config['BUKUSERVER_LOCALE'] or 'en', **context_processor()})
except Exception as e:
app.jinja_env.add_extension('jinja2.ext.i18n')
app.jinja_env.install_gettext_callables(gettext, ngettext, newstyle=True)
app.logger.warning(f'failed to init locale ({e})')
app.context_processor(lambda: {'lang': '', **context_processor()})
# handling popup= URL argument
def before_request():
_post_popup = request.headers.get('Content-Type') != 'application/json' and request.form.get('popup')
flask.g.popup = request.args.get('popup') or _post_popup
# applying popup= to the redirect URL
def after_request(response):
if flask.g.popup and 'Location' in response.headers:
_scheme, _netloc, _path, _query, _fragment = urlsplit(response.headers['Location'])
_params = parse_qs(_query)
if not _params.get('popup'):
_query = '&'.join(s for s in [_query, 'popup=True'] if s)
response.headers['Location'] = urlunsplit((_scheme, _netloc, _path, _query, _fragment))
return response
def create_app(db_file=None):
"""create app."""
app = Flask(__name__)
db_file = os.getenv('BUKUSERVER_DB_FILE') or db_file
if db_file and not os.path.dirname(db_file) and not os.path.splitext(db_file)[1]:
db_file = os.path.join(BukuDb.get_default_dbdir(), db_file + '.db')
os.environ.setdefault('FLASK_DEBUG', ('1' if get_bool_from_env_var('BUKUSERVER_DEBUG') else '0'))
per_page = int(os.getenv('BUKUSERVER_PER_PAGE', str(views.DEFAULT_PER_PAGE)))
per_page = per_page if per_page > 0 else views.DEFAULT_PER_PAGE
app.config['BUKUSERVER_PER_PAGE'] = per_page
url_render_mode = os.getenv('BUKUSERVER_URL_RENDER_MODE', views.DEFAULT_URL_RENDER_MODE)
if url_render_mode not in ('full', 'netloc', 'netloc-tag'):
url_render_mode = views.DEFAULT_URL_RENDER_MODE
app.config['BUKUSERVER_URL_RENDER_MODE'] = url_render_mode
app.config['SECRET_KEY'] = os.getenv('BUKUSERVER_SECRET_KEY') or os.urandom(24)
app.config['BUKUSERVER_READONLY'] = \
get_bool_from_env_var('BUKUSERVER_READONLY')
app.config['BUKUSERVER_DISABLE_FAVICON'] = \
get_bool_from_env_var('BUKUSERVER_DISABLE_FAVICON', True)
app.config['BUKUSERVER_OPEN_IN_NEW_TAB'] = \
get_bool_from_env_var('BUKUSERVER_OPEN_IN_NEW_TAB')
app.config['BUKUSERVER_AUTOFETCH'] = \
get_bool_from_env_var('BUKUSERVER_AUTOFETCH', True)
app.config['BUKUSERVER_DB_FILE'] = db_file
# Mitigate Host header poisoning: when set, url_for(..., _external=True) uses
# this instead of the client-supplied Host header. Recommended for production
# and when behind a reverse proxy (e.g. BUKUSERVER_SERVER_NAME=example.com:443).
server_name = os.getenv('BUKUSERVER_SERVER_NAME')
if server_name:
app.config['SERVER_NAME'] = server_name
reverse_proxy_path = os.getenv('BUKUSERVER_REVERSE_PROXY_PATH')
if reverse_proxy_path:
if not reverse_proxy_path.startswith('/'):
print('Warning: reverse proxy path should include preceding slash')
if reverse_proxy_path.endswith('/'):
print('Warning: reverse proxy path should not include trailing slash')
app.config['REVERSE_PROXY_PATH'] = reverse_proxy_path
ReverseProxyPrefixFix(app)
bukudb = BukuDb(dbfile=db_file)
theme = (os.getenv('BUKUSERVER_THEME') or 'default').lower()
app.config['BUKUSERVER_LOCALE'] = os.getenv('BUKUSERVER_LOCALE') or 'en'
_dir = os.path.dirname(os.path.realpath(__file__))
app.config['SWAGGER'] = {'title': 'Bukuserver API', 'doc_dir': os.path.join(_dir, 'apidocs')}
app.app_context().push()
setattr(flask.g, 'bukudb', bukudb)
init_locale(app)
app.before_request(before_request)
app.after_request(after_request)
@app.shell_context_processor
def shell_context():
"""Shell context definition."""
return {'app': app, 'bukudb': bukudb}
app.jinja_env.filters.update(util.JINJA_FILTERS)
app.jinja_env.globals.update(_p=_p, dbfile=bukudb.dbfile, dbname=bukudb.dbname)
admin = Admin(
app, name='buku server', theme=Bootstrap4Theme(swatch=theme),
index_view=views.CustomAdminIndexView(
template='bukuserver/home.html', url='/'
)
)
Swagger(app, template_file=os.path.join(_dir, 'apidocs', 'template.yml'))
# routing
# api
app.add_url_rule('/api/tags', 'get_all_tags', api.get_all_tags, methods=['GET'], strict_slashes=False)
app.add_url_rule('/api/tags/', view_func=api.ApiTagView.as_view('tag'), methods=['GET', 'PUT', 'DELETE'])
app.add_url_rule('/api/bookmarks', view_func=api.ApiBookmarksView.as_view('bookmarks'), methods=['GET', 'POST', 'DELETE'])
app.add_url_rule('/api/bookmarks/', view_func=api.ApiBookmarkView.as_view('bookmark'), methods=['GET', 'PUT', 'DELETE'])
app.add_url_rule('/api/bookmarks/refresh', 'bookmarks_refresh', api.refresh_bookmark, defaults={'index': None}, methods=['POST'])
app.add_url_rule('/api/bookmarks/reorder', 'bookmarks_reorder', api.reorder_bookmarks, methods=['POST'])
app.add_url_rule('/api/bookmarks//refresh', 'bookmark_refresh', api.refresh_bookmark, methods=['POST'])
app.add_url_rule('/api/bookmarks//tiny', 'tiny_url', api.get_tiny_url, methods=['GET'])
app.add_url_rule('/api/bookmarks//',
view_func=api.ApiBookmarkRangeView.as_view('bookmark_range'), methods=['GET', 'PUT', 'DELETE'])
app.add_url_rule('/api/bookmarks/search', view_func=api.ApiBookmarkSearchView.as_view('bookmarks_search'), methods=['GET', 'DELETE'])
app.add_url_rule('/api/network_handle', 'network_handle', api.handle_network, methods=['POST'])
app.add_url_rule('/api/fetch_data', 'fetch_data', api.fetch_data, methods=['POST'])
# non api
@app.route('/favicon.ico')
def favicon():
return redirect(url_for('static', filename='bukuserver/favicon.svg'), code=301) # permanent redirect
app.add_url_rule('/bookmarklet', 'bookmarklet', api.bookmarklet_redirect, methods=['GET'])
admin.add_view(views.BookmarkModelView(bukudb, _l('Bookmarks')))
admin.add_view(views.TagModelView(bukudb, _l('Tags')))
admin.add_view(views.StatisticView(bukudb, _l('Statistic'), endpoint='statistic'))
return app
class CustomFlaskGroup(FlaskGroup): # pylint: disable=too-few-public-methods
def __init__(self, **kwargs):
super().__init__(**kwargs)
for idx, param in enumerate(self.params):
if param.name == "version":
self.params[idx].help = "Show the program version"
self.params[idx].callback = get_custom_version
def get_custom_version(ctx, param, value):
if not value or ctx.resilient_parsing:
return
message = "\n".join(["%(app_name)s %(app_version)s", "Flask %(version)s", "Python %(python_version)s"])
click.echo(
message
% {
"app_name": "buku",
"app_version": __version__,
"version": FLASK_VERSION,
"python_version": sys.version,
},
color=ctx.color,
)
ctx.exit()
@click.group(cls=CustomFlaskGroup, create_app=create_app)
def cli():
"""This is a script for the bukuserver application."""
if __name__ == '__main__':
cli()
================================================
FILE: bukuserver/static/bukuserver/css/bookmark.css
================================================
/* preventing layout overflow in tables */
table tr td:not(:first-child) {
overflow-wrap: anywhere;
}
/* wider modals for wide data */
@media (min-width: 768px) {
.modal-dialog {
width: min(1000px, 80%);
}
}
/* fixing details filter width */
#fa_filter {flex-grow: 1}
/* fixing tags input height */
.select2-container.form-control {display: flex !important}
.select2-container.form-control .select2-choices {flex-grow: 1}
.link {
display: block;
}
.tag-list {
display: flex;
gap: 1px;
flex-wrap: wrap;
}
.tag-list, .link .netloc {
font-size: larger;
}
.tag-list a, .link .netloc, .select2-search-choice > *, .select2-result-label {
white-space: pre;
}
.description {
white-space: pre-wrap;
}
================================================
FILE: bukuserver/static/bukuserver/css/list.css
================================================
/* overriding icon-button text color with theme color */
form.icon button {
color: inherit;
}
/* fixing table layout */
.list-buttons-column {width: 0}
.list-buttons-column .icon:last-child {margin-right: 10px}
.list-buttons-column .swap-toolbar .icon:last-child {margin-right: 9px}
.filters .filter-op {width: var(--filter-op) !important}
.filters .filter-val {width: calc(var(--filters) - var(--filter-op) - var(--filter-buttons) - var(--filter-type)) !important}
#filter_form[action^='/tag/'] {--filter-type: var(--filter-type-tags)}
:root {--filters: 510px; --filter-op: 9rem; --filter-buttons: 12.5rem; --filter-type: 6rem; --filter-type-tags: 9.5rem}
/* due to how flask-admin filters are set up, each language requires manual adjustments for full-width sizes */
html[lang=de] {--filter-buttons: 18rem}
html[lang=fr] {--filter-buttons: 16rem}
html[lang=ru] {--filter-buttons: 16.5rem; --filter-type: 8.5rem; --filter-type-tags: 11.5rem}
@media (max-width: 767px) {
.filters .filter-val {width: calc(var(--filters) - var(--filter-op) - var(--filter-type)) !important}
#filter_form .pull-right:first-child {margin: .5ex 0}
}
@media (min-width: 768px) {
:root {--filters: 690px; --filter-op: 11.5rem}
}
@media (min-width: 992px) {
:root {--filters: 930px; --filter-op: 20rem}
}
@media (min-width: 1200px) {
:root {--filters: 1110px; --filter-op: 20rem}
html[lang=ru] #filter_form[action^='/bookmark/'] {--filter-op: 25rem} /* the last 'buku' filter has a rather long name */
}
================================================
FILE: bukuserver/static/bukuserver/css/modal.css
================================================
/* prevent unnecessary scrollbox from appearing at the main window */
body.modal-open {
overflow-y: inherit;
}
/* limit dialog height with a scrollbox */
.modal-content {
max-height: calc(100vh - 3.5rem);
display: flex;
flex-direction: column;
}
.modal-body {
max-height: 100%;
overflow: auto;
}
/* header size should not be affected */
.modal-header {
flex-shrink: 0;
}
/* make the table header sticky */
.modal-body thead {
position: sticky;
/* setting `top:` with JS, to account for theme differences */
}
/* overriding icon-button text color with theme color */
.modal-header .close {
color: inherit;
}
================================================
FILE: bukuserver/static/bukuserver/js/Chart.js
================================================
/*!
* Chart.js
* http://chartjs.org/
* Version: 2.7.2
*
* Copyright 2018 Chart.js Contributors
* Released under the MIT license
* https://github.com/chartjs/Chart.js/blob/master/LICENSE.md
*/
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.Chart = f()}})(function(){var define,module,exports;return (function(){function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o lum2) {
return (lum1 + 0.05) / (lum2 + 0.05);
}
return (lum2 + 0.05) / (lum1 + 0.05);
},
level: function (color2) {
var contrastRatio = this.contrast(color2);
if (contrastRatio >= 7.1) {
return 'AAA';
}
return (contrastRatio >= 4.5) ? 'AA' : '';
},
dark: function () {
// YIQ equation from http://24ways.org/2010/calculating-color-contrast
var rgb = this.values.rgb;
var yiq = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000;
return yiq < 128;
},
light: function () {
return !this.dark();
},
negate: function () {
var rgb = [];
for (var i = 0; i < 3; i++) {
rgb[i] = 255 - this.values.rgb[i];
}
this.setValues('rgb', rgb);
return this;
},
lighten: function (ratio) {
var hsl = this.values.hsl;
hsl[2] += hsl[2] * ratio;
this.setValues('hsl', hsl);
return this;
},
darken: function (ratio) {
var hsl = this.values.hsl;
hsl[2] -= hsl[2] * ratio;
this.setValues('hsl', hsl);
return this;
},
saturate: function (ratio) {
var hsl = this.values.hsl;
hsl[1] += hsl[1] * ratio;
this.setValues('hsl', hsl);
return this;
},
desaturate: function (ratio) {
var hsl = this.values.hsl;
hsl[1] -= hsl[1] * ratio;
this.setValues('hsl', hsl);
return this;
},
whiten: function (ratio) {
var hwb = this.values.hwb;
hwb[1] += hwb[1] * ratio;
this.setValues('hwb', hwb);
return this;
},
blacken: function (ratio) {
var hwb = this.values.hwb;
hwb[2] += hwb[2] * ratio;
this.setValues('hwb', hwb);
return this;
},
greyscale: function () {
var rgb = this.values.rgb;
// http://en.wikipedia.org/wiki/Grayscale#Converting_color_to_grayscale
var val = rgb[0] * 0.3 + rgb[1] * 0.59 + rgb[2] * 0.11;
this.setValues('rgb', [val, val, val]);
return this;
},
clearer: function (ratio) {
var alpha = this.values.alpha;
this.setValues('alpha', alpha - (alpha * ratio));
return this;
},
opaquer: function (ratio) {
var alpha = this.values.alpha;
this.setValues('alpha', alpha + (alpha * ratio));
return this;
},
rotate: function (degrees) {
var hsl = this.values.hsl;
var hue = (hsl[0] + degrees) % 360;
hsl[0] = hue < 0 ? 360 + hue : hue;
this.setValues('hsl', hsl);
return this;
},
/**
* Ported from sass implementation in C
* https://github.com/sass/libsass/blob/0e6b4a2850092356aa3ece07c6b249f0221caced/functions.cpp#L209
*/
mix: function (mixinColor, weight) {
var color1 = this;
var color2 = mixinColor;
var p = weight === undefined ? 0.5 : weight;
var w = 2 * p - 1;
var a = color1.alpha() - color2.alpha();
var w1 = (((w * a === -1) ? w : (w + a) / (1 + w * a)) + 1) / 2.0;
var w2 = 1 - w1;
return this
.rgb(
w1 * color1.red() + w2 * color2.red(),
w1 * color1.green() + w2 * color2.green(),
w1 * color1.blue() + w2 * color2.blue()
)
.alpha(color1.alpha() * p + color2.alpha() * (1 - p));
},
toJSON: function () {
return this.rgb();
},
clone: function () {
// NOTE(SB): using node-clone creates a dependency to Buffer when using browserify,
// making the final build way to big to embed in Chart.js. So let's do it manually,
// assuming that values to clone are 1 dimension arrays containing only numbers,
// except 'alpha' which is a number.
var result = new Color();
var source = this.values;
var target = result.values;
var value, type;
for (var prop in source) {
if (source.hasOwnProperty(prop)) {
value = source[prop];
type = ({}).toString.call(value);
if (type === '[object Array]') {
target[prop] = value.slice(0);
} else if (type === '[object Number]') {
target[prop] = value;
} else {
console.error('unexpected color value:', value);
}
}
}
return result;
}
};
Color.prototype.spaces = {
rgb: ['red', 'green', 'blue'],
hsl: ['hue', 'saturation', 'lightness'],
hsv: ['hue', 'saturation', 'value'],
hwb: ['hue', 'whiteness', 'blackness'],
cmyk: ['cyan', 'magenta', 'yellow', 'black']
};
Color.prototype.maxes = {
rgb: [255, 255, 255],
hsl: [360, 100, 100],
hsv: [360, 100, 100],
hwb: [360, 100, 100],
cmyk: [100, 100, 100, 100]
};
Color.prototype.getValues = function (space) {
var values = this.values;
var vals = {};
for (var i = 0; i < space.length; i++) {
vals[space.charAt(i)] = values[space][i];
}
if (values.alpha !== 1) {
vals.a = values.alpha;
}
// {r: 255, g: 255, b: 255, a: 0.4}
return vals;
};
Color.prototype.setValues = function (space, vals) {
var values = this.values;
var spaces = this.spaces;
var maxes = this.maxes;
var alpha = 1;
var i;
this.valid = true;
if (space === 'alpha') {
alpha = vals;
} else if (vals.length) {
// [10, 10, 10]
values[space] = vals.slice(0, space.length);
alpha = vals[space.length];
} else if (vals[space.charAt(0)] !== undefined) {
// {r: 10, g: 10, b: 10}
for (i = 0; i < space.length; i++) {
values[space][i] = vals[space.charAt(i)];
}
alpha = vals.a;
} else if (vals[spaces[space][0]] !== undefined) {
// {red: 10, green: 10, blue: 10}
var chans = spaces[space];
for (i = 0; i < space.length; i++) {
values[space][i] = vals[chans[i]];
}
alpha = vals.alpha;
}
values.alpha = Math.max(0, Math.min(1, (alpha === undefined ? values.alpha : alpha)));
if (space === 'alpha') {
return false;
}
var capped;
// cap values of the space prior converting all values
for (i = 0; i < space.length; i++) {
capped = Math.max(0, Math.min(maxes[space][i], values[space][i]));
values[space][i] = Math.round(capped);
}
// convert to all the other color spaces
for (var sname in spaces) {
if (sname !== space) {
values[sname] = convert[space][sname](values[space]);
}
}
return true;
};
Color.prototype.setSpace = function (space, args) {
var vals = args[0];
if (vals === undefined) {
// color.rgb()
return this.getValues(space);
}
// color.rgb(10, 10, 10)
if (typeof vals === 'number') {
vals = Array.prototype.slice.call(args);
}
this.setValues(space, vals);
return this;
};
Color.prototype.setChannel = function (space, index, val) {
var svalues = this.values[space];
if (val === undefined) {
// color.red()
return svalues[index];
} else if (val === svalues[index]) {
// color.red(color.red())
return this;
}
// color.red(100)
svalues[index] = val;
this.setValues(space, svalues);
return this;
};
if (typeof window !== 'undefined') {
window.Color = Color;
}
module.exports = Color;
},{"2":2,"5":5}],4:[function(require,module,exports){
/* MIT license */
module.exports = {
rgb2hsl: rgb2hsl,
rgb2hsv: rgb2hsv,
rgb2hwb: rgb2hwb,
rgb2cmyk: rgb2cmyk,
rgb2keyword: rgb2keyword,
rgb2xyz: rgb2xyz,
rgb2lab: rgb2lab,
rgb2lch: rgb2lch,
hsl2rgb: hsl2rgb,
hsl2hsv: hsl2hsv,
hsl2hwb: hsl2hwb,
hsl2cmyk: hsl2cmyk,
hsl2keyword: hsl2keyword,
hsv2rgb: hsv2rgb,
hsv2hsl: hsv2hsl,
hsv2hwb: hsv2hwb,
hsv2cmyk: hsv2cmyk,
hsv2keyword: hsv2keyword,
hwb2rgb: hwb2rgb,
hwb2hsl: hwb2hsl,
hwb2hsv: hwb2hsv,
hwb2cmyk: hwb2cmyk,
hwb2keyword: hwb2keyword,
cmyk2rgb: cmyk2rgb,
cmyk2hsl: cmyk2hsl,
cmyk2hsv: cmyk2hsv,
cmyk2hwb: cmyk2hwb,
cmyk2keyword: cmyk2keyword,
keyword2rgb: keyword2rgb,
keyword2hsl: keyword2hsl,
keyword2hsv: keyword2hsv,
keyword2hwb: keyword2hwb,
keyword2cmyk: keyword2cmyk,
keyword2lab: keyword2lab,
keyword2xyz: keyword2xyz,
xyz2rgb: xyz2rgb,
xyz2lab: xyz2lab,
xyz2lch: xyz2lch,
lab2xyz: lab2xyz,
lab2rgb: lab2rgb,
lab2lch: lab2lch,
lch2lab: lch2lab,
lch2xyz: lch2xyz,
lch2rgb: lch2rgb
}
function rgb2hsl(rgb) {
var r = rgb[0]/255,
g = rgb[1]/255,
b = rgb[2]/255,
min = Math.min(r, g, b),
max = Math.max(r, g, b),
delta = max - min,
h, s, l;
if (max == min)
h = 0;
else if (r == max)
h = (g - b) / delta;
else if (g == max)
h = 2 + (b - r) / delta;
else if (b == max)
h = 4 + (r - g)/ delta;
h = Math.min(h * 60, 360);
if (h < 0)
h += 360;
l = (min + max) / 2;
if (max == min)
s = 0;
else if (l <= 0.5)
s = delta / (max + min);
else
s = delta / (2 - max - min);
return [h, s * 100, l * 100];
}
function rgb2hsv(rgb) {
var r = rgb[0],
g = rgb[1],
b = rgb[2],
min = Math.min(r, g, b),
max = Math.max(r, g, b),
delta = max - min,
h, s, v;
if (max == 0)
s = 0;
else
s = (delta/max * 1000)/10;
if (max == min)
h = 0;
else if (r == max)
h = (g - b) / delta;
else if (g == max)
h = 2 + (b - r) / delta;
else if (b == max)
h = 4 + (r - g) / delta;
h = Math.min(h * 60, 360);
if (h < 0)
h += 360;
v = ((max / 255) * 1000) / 10;
return [h, s, v];
}
function rgb2hwb(rgb) {
var r = rgb[0],
g = rgb[1],
b = rgb[2],
h = rgb2hsl(rgb)[0],
w = 1/255 * Math.min(r, Math.min(g, b)),
b = 1 - 1/255 * Math.max(r, Math.max(g, b));
return [h, w * 100, b * 100];
}
function rgb2cmyk(rgb) {
var r = rgb[0] / 255,
g = rgb[1] / 255,
b = rgb[2] / 255,
c, m, y, k;
k = Math.min(1 - r, 1 - g, 1 - b);
c = (1 - r - k) / (1 - k) || 0;
m = (1 - g - k) / (1 - k) || 0;
y = (1 - b - k) / (1 - k) || 0;
return [c * 100, m * 100, y * 100, k * 100];
}
function rgb2keyword(rgb) {
return reverseKeywords[JSON.stringify(rgb)];
}
function rgb2xyz(rgb) {
var r = rgb[0] / 255,
g = rgb[1] / 255,
b = rgb[2] / 255;
// assume sRGB
r = r > 0.04045 ? Math.pow(((r + 0.055) / 1.055), 2.4) : (r / 12.92);
g = g > 0.04045 ? Math.pow(((g + 0.055) / 1.055), 2.4) : (g / 12.92);
b = b > 0.04045 ? Math.pow(((b + 0.055) / 1.055), 2.4) : (b / 12.92);
var x = (r * 0.4124) + (g * 0.3576) + (b * 0.1805);
var y = (r * 0.2126) + (g * 0.7152) + (b * 0.0722);
var z = (r * 0.0193) + (g * 0.1192) + (b * 0.9505);
return [x * 100, y *100, z * 100];
}
function rgb2lab(rgb) {
var xyz = rgb2xyz(rgb),
x = xyz[0],
y = xyz[1],
z = xyz[2],
l, a, b;
x /= 95.047;
y /= 100;
z /= 108.883;
x = x > 0.008856 ? Math.pow(x, 1/3) : (7.787 * x) + (16 / 116);
y = y > 0.008856 ? Math.pow(y, 1/3) : (7.787 * y) + (16 / 116);
z = z > 0.008856 ? Math.pow(z, 1/3) : (7.787 * z) + (16 / 116);
l = (116 * y) - 16;
a = 500 * (x - y);
b = 200 * (y - z);
return [l, a, b];
}
function rgb2lch(args) {
return lab2lch(rgb2lab(args));
}
function hsl2rgb(hsl) {
var h = hsl[0] / 360,
s = hsl[1] / 100,
l = hsl[2] / 100,
t1, t2, t3, rgb, val;
if (s == 0) {
val = l * 255;
return [val, val, val];
}
if (l < 0.5)
t2 = l * (1 + s);
else
t2 = l + s - l * s;
t1 = 2 * l - t2;
rgb = [0, 0, 0];
for (var i = 0; i < 3; i++) {
t3 = h + 1 / 3 * - (i - 1);
t3 < 0 && t3++;
t3 > 1 && t3--;
if (6 * t3 < 1)
val = t1 + (t2 - t1) * 6 * t3;
else if (2 * t3 < 1)
val = t2;
else if (3 * t3 < 2)
val = t1 + (t2 - t1) * (2 / 3 - t3) * 6;
else
val = t1;
rgb[i] = val * 255;
}
return rgb;
}
function hsl2hsv(hsl) {
var h = hsl[0],
s = hsl[1] / 100,
l = hsl[2] / 100,
sv, v;
if(l === 0) {
// no need to do calc on black
// also avoids divide by 0 error
return [0, 0, 0];
}
l *= 2;
s *= (l <= 1) ? l : 2 - l;
v = (l + s) / 2;
sv = (2 * s) / (l + s);
return [h, sv * 100, v * 100];
}
function hsl2hwb(args) {
return rgb2hwb(hsl2rgb(args));
}
function hsl2cmyk(args) {
return rgb2cmyk(hsl2rgb(args));
}
function hsl2keyword(args) {
return rgb2keyword(hsl2rgb(args));
}
function hsv2rgb(hsv) {
var h = hsv[0] / 60,
s = hsv[1] / 100,
v = hsv[2] / 100,
hi = Math.floor(h) % 6;
var f = h - Math.floor(h),
p = 255 * v * (1 - s),
q = 255 * v * (1 - (s * f)),
t = 255 * v * (1 - (s * (1 - f))),
v = 255 * v;
switch(hi) {
case 0:
return [v, t, p];
case 1:
return [q, v, p];
case 2:
return [p, v, t];
case 3:
return [p, q, v];
case 4:
return [t, p, v];
case 5:
return [v, p, q];
}
}
function hsv2hsl(hsv) {
var h = hsv[0],
s = hsv[1] / 100,
v = hsv[2] / 100,
sl, l;
l = (2 - s) * v;
sl = s * v;
sl /= (l <= 1) ? l : 2 - l;
sl = sl || 0;
l /= 2;
return [h, sl * 100, l * 100];
}
function hsv2hwb(args) {
return rgb2hwb(hsv2rgb(args))
}
function hsv2cmyk(args) {
return rgb2cmyk(hsv2rgb(args));
}
function hsv2keyword(args) {
return rgb2keyword(hsv2rgb(args));
}
// http://dev.w3.org/csswg/css-color/#hwb-to-rgb
function hwb2rgb(hwb) {
var h = hwb[0] / 360,
wh = hwb[1] / 100,
bl = hwb[2] / 100,
ratio = wh + bl,
i, v, f, n;
// wh + bl cant be > 1
if (ratio > 1) {
wh /= ratio;
bl /= ratio;
}
i = Math.floor(6 * h);
v = 1 - bl;
f = 6 * h - i;
if ((i & 0x01) != 0) {
f = 1 - f;
}
n = wh + f * (v - wh); // linear interpolation
switch (i) {
default:
case 6:
case 0: r = v; g = n; b = wh; break;
case 1: r = n; g = v; b = wh; break;
case 2: r = wh; g = v; b = n; break;
case 3: r = wh; g = n; b = v; break;
case 4: r = n; g = wh; b = v; break;
case 5: r = v; g = wh; b = n; break;
}
return [r * 255, g * 255, b * 255];
}
function hwb2hsl(args) {
return rgb2hsl(hwb2rgb(args));
}
function hwb2hsv(args) {
return rgb2hsv(hwb2rgb(args));
}
function hwb2cmyk(args) {
return rgb2cmyk(hwb2rgb(args));
}
function hwb2keyword(args) {
return rgb2keyword(hwb2rgb(args));
}
function cmyk2rgb(cmyk) {
var c = cmyk[0] / 100,
m = cmyk[1] / 100,
y = cmyk[2] / 100,
k = cmyk[3] / 100,
r, g, b;
r = 1 - Math.min(1, c * (1 - k) + k);
g = 1 - Math.min(1, m * (1 - k) + k);
b = 1 - Math.min(1, y * (1 - k) + k);
return [r * 255, g * 255, b * 255];
}
function cmyk2hsl(args) {
return rgb2hsl(cmyk2rgb(args));
}
function cmyk2hsv(args) {
return rgb2hsv(cmyk2rgb(args));
}
function cmyk2hwb(args) {
return rgb2hwb(cmyk2rgb(args));
}
function cmyk2keyword(args) {
return rgb2keyword(cmyk2rgb(args));
}
function xyz2rgb(xyz) {
var x = xyz[0] / 100,
y = xyz[1] / 100,
z = xyz[2] / 100,
r, g, b;
r = (x * 3.2406) + (y * -1.5372) + (z * -0.4986);
g = (x * -0.9689) + (y * 1.8758) + (z * 0.0415);
b = (x * 0.0557) + (y * -0.2040) + (z * 1.0570);
// assume sRGB
r = r > 0.0031308 ? ((1.055 * Math.pow(r, 1.0 / 2.4)) - 0.055)
: r = (r * 12.92);
g = g > 0.0031308 ? ((1.055 * Math.pow(g, 1.0 / 2.4)) - 0.055)
: g = (g * 12.92);
b = b > 0.0031308 ? ((1.055 * Math.pow(b, 1.0 / 2.4)) - 0.055)
: b = (b * 12.92);
r = Math.min(Math.max(0, r), 1);
g = Math.min(Math.max(0, g), 1);
b = Math.min(Math.max(0, b), 1);
return [r * 255, g * 255, b * 255];
}
function xyz2lab(xyz) {
var x = xyz[0],
y = xyz[1],
z = xyz[2],
l, a, b;
x /= 95.047;
y /= 100;
z /= 108.883;
x = x > 0.008856 ? Math.pow(x, 1/3) : (7.787 * x) + (16 / 116);
y = y > 0.008856 ? Math.pow(y, 1/3) : (7.787 * y) + (16 / 116);
z = z > 0.008856 ? Math.pow(z, 1/3) : (7.787 * z) + (16 / 116);
l = (116 * y) - 16;
a = 500 * (x - y);
b = 200 * (y - z);
return [l, a, b];
}
function xyz2lch(args) {
return lab2lch(xyz2lab(args));
}
function lab2xyz(lab) {
var l = lab[0],
a = lab[1],
b = lab[2],
x, y, z, y2;
if (l <= 8) {
y = (l * 100) / 903.3;
y2 = (7.787 * (y / 100)) + (16 / 116);
} else {
y = 100 * Math.pow((l + 16) / 116, 3);
y2 = Math.pow(y / 100, 1/3);
}
x = x / 95.047 <= 0.008856 ? x = (95.047 * ((a / 500) + y2 - (16 / 116))) / 7.787 : 95.047 * Math.pow((a / 500) + y2, 3);
z = z / 108.883 <= 0.008859 ? z = (108.883 * (y2 - (b / 200) - (16 / 116))) / 7.787 : 108.883 * Math.pow(y2 - (b / 200), 3);
return [x, y, z];
}
function lab2lch(lab) {
var l = lab[0],
a = lab[1],
b = lab[2],
hr, h, c;
hr = Math.atan2(b, a);
h = hr * 360 / 2 / Math.PI;
if (h < 0) {
h += 360;
}
c = Math.sqrt(a * a + b * b);
return [l, c, h];
}
function lab2rgb(args) {
return xyz2rgb(lab2xyz(args));
}
function lch2lab(lch) {
var l = lch[0],
c = lch[1],
h = lch[2],
a, b, hr;
hr = h / 360 * 2 * Math.PI;
a = c * Math.cos(hr);
b = c * Math.sin(hr);
return [l, a, b];
}
function lch2xyz(args) {
return lab2xyz(lch2lab(args));
}
function lch2rgb(args) {
return lab2rgb(lch2lab(args));
}
function keyword2rgb(keyword) {
return cssKeywords[keyword];
}
function keyword2hsl(args) {
return rgb2hsl(keyword2rgb(args));
}
function keyword2hsv(args) {
return rgb2hsv(keyword2rgb(args));
}
function keyword2hwb(args) {
return rgb2hwb(keyword2rgb(args));
}
function keyword2cmyk(args) {
return rgb2cmyk(keyword2rgb(args));
}
function keyword2lab(args) {
return rgb2lab(keyword2rgb(args));
}
function keyword2xyz(args) {
return rgb2xyz(keyword2rgb(args));
}
var cssKeywords = {
aliceblue: [240,248,255],
antiquewhite: [250,235,215],
aqua: [0,255,255],
aquamarine: [127,255,212],
azure: [240,255,255],
beige: [245,245,220],
bisque: [255,228,196],
black: [0,0,0],
blanchedalmond: [255,235,205],
blue: [0,0,255],
blueviolet: [138,43,226],
brown: [165,42,42],
burlywood: [222,184,135],
cadetblue: [95,158,160],
chartreuse: [127,255,0],
chocolate: [210,105,30],
coral: [255,127,80],
cornflowerblue: [100,149,237],
cornsilk: [255,248,220],
crimson: [220,20,60],
cyan: [0,255,255],
darkblue: [0,0,139],
darkcyan: [0,139,139],
darkgoldenrod: [184,134,11],
darkgray: [169,169,169],
darkgreen: [0,100,0],
darkgrey: [169,169,169],
darkkhaki: [189,183,107],
darkmagenta: [139,0,139],
darkolivegreen: [85,107,47],
darkorange: [255,140,0],
darkorchid: [153,50,204],
darkred: [139,0,0],
darksalmon: [233,150,122],
darkseagreen: [143,188,143],
darkslateblue: [72,61,139],
darkslategray: [47,79,79],
darkslategrey: [47,79,79],
darkturquoise: [0,206,209],
darkviolet: [148,0,211],
deeppink: [255,20,147],
deepskyblue: [0,191,255],
dimgray: [105,105,105],
dimgrey: [105,105,105],
dodgerblue: [30,144,255],
firebrick: [178,34,34],
floralwhite: [255,250,240],
forestgreen: [34,139,34],
fuchsia: [255,0,255],
gainsboro: [220,220,220],
ghostwhite: [248,248,255],
gold: [255,215,0],
goldenrod: [218,165,32],
gray: [128,128,128],
green: [0,128,0],
greenyellow: [173,255,47],
grey: [128,128,128],
honeydew: [240,255,240],
hotpink: [255,105,180],
indianred: [205,92,92],
indigo: [75,0,130],
ivory: [255,255,240],
khaki: [240,230,140],
lavender: [230,230,250],
lavenderblush: [255,240,245],
lawngreen: [124,252,0],
lemonchiffon: [255,250,205],
lightblue: [173,216,230],
lightcoral: [240,128,128],
lightcyan: [224,255,255],
lightgoldenrodyellow: [250,250,210],
lightgray: [211,211,211],
lightgreen: [144,238,144],
lightgrey: [211,211,211],
lightpink: [255,182,193],
lightsalmon: [255,160,122],
lightseagreen: [32,178,170],
lightskyblue: [135,206,250],
lightslategray: [119,136,153],
lightslategrey: [119,136,153],
lightsteelblue: [176,196,222],
lightyellow: [255,255,224],
lime: [0,255,0],
limegreen: [50,205,50],
linen: [250,240,230],
magenta: [255,0,255],
maroon: [128,0,0],
mediumaquamarine: [102,205,170],
mediumblue: [0,0,205],
mediumorchid: [186,85,211],
mediumpurple: [147,112,219],
mediumseagreen: [60,179,113],
mediumslateblue: [123,104,238],
mediumspringgreen: [0,250,154],
mediumturquoise: [72,209,204],
mediumvioletred: [199,21,133],
midnightblue: [25,25,112],
mintcream: [245,255,250],
mistyrose: [255,228,225],
moccasin: [255,228,181],
navajowhite: [255,222,173],
navy: [0,0,128],
oldlace: [253,245,230],
olive: [128,128,0],
olivedrab: [107,142,35],
orange: [255,165,0],
orangered: [255,69,0],
orchid: [218,112,214],
palegoldenrod: [238,232,170],
palegreen: [152,251,152],
paleturquoise: [175,238,238],
palevioletred: [219,112,147],
papayawhip: [255,239,213],
peachpuff: [255,218,185],
peru: [205,133,63],
pink: [255,192,203],
plum: [221,160,221],
powderblue: [176,224,230],
purple: [128,0,128],
rebeccapurple: [102, 51, 153],
red: [255,0,0],
rosybrown: [188,143,143],
royalblue: [65,105,225],
saddlebrown: [139,69,19],
salmon: [250,128,114],
sandybrown: [244,164,96],
seagreen: [46,139,87],
seashell: [255,245,238],
sienna: [160,82,45],
silver: [192,192,192],
skyblue: [135,206,235],
slateblue: [106,90,205],
slategray: [112,128,144],
slategrey: [112,128,144],
snow: [255,250,250],
springgreen: [0,255,127],
steelblue: [70,130,180],
tan: [210,180,140],
teal: [0,128,128],
thistle: [216,191,216],
tomato: [255,99,71],
turquoise: [64,224,208],
violet: [238,130,238],
wheat: [245,222,179],
white: [255,255,255],
whitesmoke: [245,245,245],
yellow: [255,255,0],
yellowgreen: [154,205,50]
};
var reverseKeywords = {};
for (var key in cssKeywords) {
reverseKeywords[JSON.stringify(cssKeywords[key])] = key;
}
},{}],5:[function(require,module,exports){
var conversions = require(4);
var convert = function() {
return new Converter();
}
for (var func in conversions) {
// export Raw versions
convert[func + "Raw"] = (function(func) {
// accept array or plain args
return function(arg) {
if (typeof arg == "number")
arg = Array.prototype.slice.call(arguments);
return conversions[func](arg);
}
})(func);
var pair = /(\w+)2(\w+)/.exec(func),
from = pair[1],
to = pair[2];
// export rgb2hsl and ["rgb"]["hsl"]
convert[from] = convert[from] || {};
convert[from][to] = convert[func] = (function(func) {
return function(arg) {
if (typeof arg == "number")
arg = Array.prototype.slice.call(arguments);
var val = conversions[func](arg);
if (typeof val == "string" || val === undefined)
return val; // keyword
for (var i = 0; i < val.length; i++)
val[i] = Math.round(val[i]);
return val;
}
})(func);
}
/* Converter does lazy conversion and caching */
var Converter = function() {
this.convs = {};
};
/* Either get the values for a space or
set the values for a space, depending on args */
Converter.prototype.routeSpace = function(space, args) {
var values = args[0];
if (values === undefined) {
// color.rgb()
return this.getValues(space);
}
// color.rgb(10, 10, 10)
if (typeof values == "number") {
values = Array.prototype.slice.call(args);
}
return this.setValues(space, values);
};
/* Set the values for a space, invalidating cache */
Converter.prototype.setValues = function(space, values) {
this.space = space;
this.convs = {};
this.convs[space] = values;
return this;
};
/* Get the values for a space. If there's already
a conversion for the space, fetch it, otherwise
compute it */
Converter.prototype.getValues = function(space) {
var vals = this.convs[space];
if (!vals) {
var fspace = this.space,
from = this.convs[fspace];
vals = convert[fspace][space](from);
this.convs[space] = vals;
}
return vals;
};
["rgb", "hsl", "hsv", "cmyk", "keyword"].forEach(function(space) {
Converter.prototype[space] = function(vals) {
return this.routeSpace(space, arguments);
}
});
module.exports = convert;
},{"4":4}],6:[function(require,module,exports){
'use strict'
module.exports = {
"aliceblue": [240, 248, 255],
"antiquewhite": [250, 235, 215],
"aqua": [0, 255, 255],
"aquamarine": [127, 255, 212],
"azure": [240, 255, 255],
"beige": [245, 245, 220],
"bisque": [255, 228, 196],
"black": [0, 0, 0],
"blanchedalmond": [255, 235, 205],
"blue": [0, 0, 255],
"blueviolet": [138, 43, 226],
"brown": [165, 42, 42],
"burlywood": [222, 184, 135],
"cadetblue": [95, 158, 160],
"chartreuse": [127, 255, 0],
"chocolate": [210, 105, 30],
"coral": [255, 127, 80],
"cornflowerblue": [100, 149, 237],
"cornsilk": [255, 248, 220],
"crimson": [220, 20, 60],
"cyan": [0, 255, 255],
"darkblue": [0, 0, 139],
"darkcyan": [0, 139, 139],
"darkgoldenrod": [184, 134, 11],
"darkgray": [169, 169, 169],
"darkgreen": [0, 100, 0],
"darkgrey": [169, 169, 169],
"darkkhaki": [189, 183, 107],
"darkmagenta": [139, 0, 139],
"darkolivegreen": [85, 107, 47],
"darkorange": [255, 140, 0],
"darkorchid": [153, 50, 204],
"darkred": [139, 0, 0],
"darksalmon": [233, 150, 122],
"darkseagreen": [143, 188, 143],
"darkslateblue": [72, 61, 139],
"darkslategray": [47, 79, 79],
"darkslategrey": [47, 79, 79],
"darkturquoise": [0, 206, 209],
"darkviolet": [148, 0, 211],
"deeppink": [255, 20, 147],
"deepskyblue": [0, 191, 255],
"dimgray": [105, 105, 105],
"dimgrey": [105, 105, 105],
"dodgerblue": [30, 144, 255],
"firebrick": [178, 34, 34],
"floralwhite": [255, 250, 240],
"forestgreen": [34, 139, 34],
"fuchsia": [255, 0, 255],
"gainsboro": [220, 220, 220],
"ghostwhite": [248, 248, 255],
"gold": [255, 215, 0],
"goldenrod": [218, 165, 32],
"gray": [128, 128, 128],
"green": [0, 128, 0],
"greenyellow": [173, 255, 47],
"grey": [128, 128, 128],
"honeydew": [240, 255, 240],
"hotpink": [255, 105, 180],
"indianred": [205, 92, 92],
"indigo": [75, 0, 130],
"ivory": [255, 255, 240],
"khaki": [240, 230, 140],
"lavender": [230, 230, 250],
"lavenderblush": [255, 240, 245],
"lawngreen": [124, 252, 0],
"lemonchiffon": [255, 250, 205],
"lightblue": [173, 216, 230],
"lightcoral": [240, 128, 128],
"lightcyan": [224, 255, 255],
"lightgoldenrodyellow": [250, 250, 210],
"lightgray": [211, 211, 211],
"lightgreen": [144, 238, 144],
"lightgrey": [211, 211, 211],
"lightpink": [255, 182, 193],
"lightsalmon": [255, 160, 122],
"lightseagreen": [32, 178, 170],
"lightskyblue": [135, 206, 250],
"lightslategray": [119, 136, 153],
"lightslategrey": [119, 136, 153],
"lightsteelblue": [176, 196, 222],
"lightyellow": [255, 255, 224],
"lime": [0, 255, 0],
"limegreen": [50, 205, 50],
"linen": [250, 240, 230],
"magenta": [255, 0, 255],
"maroon": [128, 0, 0],
"mediumaquamarine": [102, 205, 170],
"mediumblue": [0, 0, 205],
"mediumorchid": [186, 85, 211],
"mediumpurple": [147, 112, 219],
"mediumseagreen": [60, 179, 113],
"mediumslateblue": [123, 104, 238],
"mediumspringgreen": [0, 250, 154],
"mediumturquoise": [72, 209, 204],
"mediumvioletred": [199, 21, 133],
"midnightblue": [25, 25, 112],
"mintcream": [245, 255, 250],
"mistyrose": [255, 228, 225],
"moccasin": [255, 228, 181],
"navajowhite": [255, 222, 173],
"navy": [0, 0, 128],
"oldlace": [253, 245, 230],
"olive": [128, 128, 0],
"olivedrab": [107, 142, 35],
"orange": [255, 165, 0],
"orangered": [255, 69, 0],
"orchid": [218, 112, 214],
"palegoldenrod": [238, 232, 170],
"palegreen": [152, 251, 152],
"paleturquoise": [175, 238, 238],
"palevioletred": [219, 112, 147],
"papayawhip": [255, 239, 213],
"peachpuff": [255, 218, 185],
"peru": [205, 133, 63],
"pink": [255, 192, 203],
"plum": [221, 160, 221],
"powderblue": [176, 224, 230],
"purple": [128, 0, 128],
"rebeccapurple": [102, 51, 153],
"red": [255, 0, 0],
"rosybrown": [188, 143, 143],
"royalblue": [65, 105, 225],
"saddlebrown": [139, 69, 19],
"salmon": [250, 128, 114],
"sandybrown": [244, 164, 96],
"seagreen": [46, 139, 87],
"seashell": [255, 245, 238],
"sienna": [160, 82, 45],
"silver": [192, 192, 192],
"skyblue": [135, 206, 235],
"slateblue": [106, 90, 205],
"slategray": [112, 128, 144],
"slategrey": [112, 128, 144],
"snow": [255, 250, 250],
"springgreen": [0, 255, 127],
"steelblue": [70, 130, 180],
"tan": [210, 180, 140],
"teal": [0, 128, 128],
"thistle": [216, 191, 216],
"tomato": [255, 99, 71],
"turquoise": [64, 224, 208],
"violet": [238, 130, 238],
"wheat": [245, 222, 179],
"white": [255, 255, 255],
"whitesmoke": [245, 245, 245],
"yellow": [255, 255, 0],
"yellowgreen": [154, 205, 50]
};
},{}],7:[function(require,module,exports){
/**
* @namespace Chart
*/
var Chart = require(29)();
Chart.helpers = require(45);
// @todo dispatch these helpers into appropriated helpers/helpers.* file and write unit tests!
require(27)(Chart);
Chart.defaults = require(25);
Chart.Element = require(26);
Chart.elements = require(40);
Chart.Interaction = require(28);
Chart.layouts = require(30);
Chart.platform = require(48);
Chart.plugins = require(31);
Chart.Ticks = require(34);
require(22)(Chart);
require(23)(Chart);
require(24)(Chart);
require(33)(Chart);
require(32)(Chart);
require(35)(Chart);
require(55)(Chart);
require(53)(Chart);
require(54)(Chart);
require(56)(Chart);
require(57)(Chart);
require(58)(Chart);
// Controllers must be loaded after elements
// See Chart.core.datasetController.dataElementType
require(15)(Chart);
require(16)(Chart);
require(17)(Chart);
require(18)(Chart);
require(19)(Chart);
require(20)(Chart);
require(21)(Chart);
require(8)(Chart);
require(9)(Chart);
require(10)(Chart);
require(11)(Chart);
require(12)(Chart);
require(13)(Chart);
require(14)(Chart);
// Loading built-it plugins
var plugins = require(49);
for (var k in plugins) {
if (plugins.hasOwnProperty(k)) {
Chart.plugins.register(plugins[k]);
}
}
Chart.platform.initialize();
module.exports = Chart;
if (typeof window !== 'undefined') {
window.Chart = Chart;
}
// DEPRECATIONS
/**
* Provided for backward compatibility, not available anymore
* @namespace Chart.Legend
* @deprecated since version 2.1.5
* @todo remove at version 3
* @private
*/
Chart.Legend = plugins.legend._element;
/**
* Provided for backward compatibility, not available anymore
* @namespace Chart.Title
* @deprecated since version 2.1.5
* @todo remove at version 3
* @private
*/
Chart.Title = plugins.title._element;
/**
* Provided for backward compatibility, use Chart.plugins instead
* @namespace Chart.pluginService
* @deprecated since version 2.1.5
* @todo remove at version 3
* @private
*/
Chart.pluginService = Chart.plugins;
/**
* Provided for backward compatibility, inheriting from Chart.PlugingBase has no
* effect, instead simply create/register plugins via plain JavaScript objects.
* @interface Chart.PluginBase
* @deprecated since version 2.5.0
* @todo remove at version 3
* @private
*/
Chart.PluginBase = Chart.Element.extend({});
/**
* Provided for backward compatibility, use Chart.helpers.canvas instead.
* @namespace Chart.canvasHelpers
* @deprecated since version 2.6.0
* @todo remove at version 3
* @private
*/
Chart.canvasHelpers = Chart.helpers.canvas;
/**
* Provided for backward compatibility, use Chart.layouts instead.
* @namespace Chart.layoutService
* @deprecated since version 2.8.0
* @todo remove at version 3
* @private
*/
Chart.layoutService = Chart.layouts;
},{"10":10,"11":11,"12":12,"13":13,"14":14,"15":15,"16":16,"17":17,"18":18,"19":19,"20":20,"21":21,"22":22,"23":23,"24":24,"25":25,"26":26,"27":27,"28":28,"29":29,"30":30,"31":31,"32":32,"33":33,"34":34,"35":35,"40":40,"45":45,"48":48,"49":49,"53":53,"54":54,"55":55,"56":56,"57":57,"58":58,"8":8,"9":9}],8:[function(require,module,exports){
'use strict';
module.exports = function(Chart) {
Chart.Bar = function(context, config) {
config.type = 'bar';
return new Chart(context, config);
};
};
},{}],9:[function(require,module,exports){
'use strict';
module.exports = function(Chart) {
Chart.Bubble = function(context, config) {
config.type = 'bubble';
return new Chart(context, config);
};
};
},{}],10:[function(require,module,exports){
'use strict';
module.exports = function(Chart) {
Chart.Doughnut = function(context, config) {
config.type = 'doughnut';
return new Chart(context, config);
};
};
},{}],11:[function(require,module,exports){
'use strict';
module.exports = function(Chart) {
Chart.Line = function(context, config) {
config.type = 'line';
return new Chart(context, config);
};
};
},{}],12:[function(require,module,exports){
'use strict';
module.exports = function(Chart) {
Chart.PolarArea = function(context, config) {
config.type = 'polarArea';
return new Chart(context, config);
};
};
},{}],13:[function(require,module,exports){
'use strict';
module.exports = function(Chart) {
Chart.Radar = function(context, config) {
config.type = 'radar';
return new Chart(context, config);
};
};
},{}],14:[function(require,module,exports){
'use strict';
module.exports = function(Chart) {
Chart.Scatter = function(context, config) {
config.type = 'scatter';
return new Chart(context, config);
};
};
},{}],15:[function(require,module,exports){
'use strict';
var defaults = require(25);
var elements = require(40);
var helpers = require(45);
defaults._set('bar', {
hover: {
mode: 'label'
},
scales: {
xAxes: [{
type: 'category',
// Specific to Bar Controller
categoryPercentage: 0.8,
barPercentage: 0.9,
// offset settings
offset: true,
// grid line settings
gridLines: {
offsetGridLines: true
}
}],
yAxes: [{
type: 'linear'
}]
}
});
defaults._set('horizontalBar', {
hover: {
mode: 'index',
axis: 'y'
},
scales: {
xAxes: [{
type: 'linear',
position: 'bottom'
}],
yAxes: [{
position: 'left',
type: 'category',
// Specific to Horizontal Bar Controller
categoryPercentage: 0.8,
barPercentage: 0.9,
// offset settings
offset: true,
// grid line settings
gridLines: {
offsetGridLines: true
}
}]
},
elements: {
rectangle: {
borderSkipped: 'left'
}
},
tooltips: {
callbacks: {
title: function(item, data) {
// Pick first xLabel for now
var title = '';
if (item.length > 0) {
if (item[0].yLabel) {
title = item[0].yLabel;
} else if (data.labels.length > 0 && item[0].index < data.labels.length) {
title = data.labels[item[0].index];
}
}
return title;
},
label: function(item, data) {
var datasetLabel = data.datasets[item.datasetIndex].label || '';
return datasetLabel + ': ' + item.xLabel;
}
},
mode: 'index',
axis: 'y'
}
});
/**
* Computes the "optimal" sample size to maintain bars equally sized while preventing overlap.
* @private
*/
function computeMinSampleSize(scale, pixels) {
var min = scale.isHorizontal() ? scale.width : scale.height;
var ticks = scale.getTicks();
var prev, curr, i, ilen;
for (i = 1, ilen = pixels.length; i < ilen; ++i) {
min = Math.min(min, pixels[i] - pixels[i - 1]);
}
for (i = 0, ilen = ticks.length; i < ilen; ++i) {
curr = scale.getPixelForTick(i);
min = i > 0 ? Math.min(min, curr - prev) : min;
prev = curr;
}
return min;
}
/**
* Computes an "ideal" category based on the absolute bar thickness or, if undefined or null,
* uses the smallest interval (see computeMinSampleSize) that prevents bar overlapping. This
* mode currently always generates bars equally sized (until we introduce scriptable options?).
* @private
*/
function computeFitCategoryTraits(index, ruler, options) {
var thickness = options.barThickness;
var count = ruler.stackCount;
var curr = ruler.pixels[index];
var size, ratio;
if (helpers.isNullOrUndef(thickness)) {
size = ruler.min * options.categoryPercentage;
ratio = options.barPercentage;
} else {
// When bar thickness is enforced, category and bar percentages are ignored.
// Note(SB): we could add support for relative bar thickness (e.g. barThickness: '50%')
// and deprecate barPercentage since this value is ignored when thickness is absolute.
size = thickness * count;
ratio = 1;
}
return {
chunk: size / count,
ratio: ratio,
start: curr - (size / 2)
};
}
/**
* Computes an "optimal" category that globally arranges bars side by side (no gap when
* percentage options are 1), based on the previous and following categories. This mode
* generates bars with different widths when data are not evenly spaced.
* @private
*/
function computeFlexCategoryTraits(index, ruler, options) {
var pixels = ruler.pixels;
var curr = pixels[index];
var prev = index > 0 ? pixels[index - 1] : null;
var next = index < pixels.length - 1 ? pixels[index + 1] : null;
var percent = options.categoryPercentage;
var start, size;
if (prev === null) {
// first data: its size is double based on the next point or,
// if it's also the last data, we use the scale end extremity.
prev = curr - (next === null ? ruler.end - curr : next - curr);
}
if (next === null) {
// last data: its size is also double based on the previous point.
next = curr + curr - prev;
}
start = curr - ((curr - prev) / 2) * percent;
size = ((next - prev) / 2) * percent;
return {
chunk: size / ruler.stackCount,
ratio: options.barPercentage,
start: start
};
}
module.exports = function(Chart) {
Chart.controllers.bar = Chart.DatasetController.extend({
dataElementType: elements.Rectangle,
initialize: function() {
var me = this;
var meta;
Chart.DatasetController.prototype.initialize.apply(me, arguments);
meta = me.getMeta();
meta.stack = me.getDataset().stack;
meta.bar = true;
},
update: function(reset) {
var me = this;
var rects = me.getMeta().data;
var i, ilen;
me._ruler = me.getRuler();
for (i = 0, ilen = rects.length; i < ilen; ++i) {
me.updateElement(rects[i], i, reset);
}
},
updateElement: function(rectangle, index, reset) {
var me = this;
var chart = me.chart;
var meta = me.getMeta();
var dataset = me.getDataset();
var custom = rectangle.custom || {};
var rectangleOptions = chart.options.elements.rectangle;
rectangle._xScale = me.getScaleForId(meta.xAxisID);
rectangle._yScale = me.getScaleForId(meta.yAxisID);
rectangle._datasetIndex = me.index;
rectangle._index = index;
rectangle._model = {
datasetLabel: dataset.label,
label: chart.data.labels[index],
borderSkipped: custom.borderSkipped ? custom.borderSkipped : rectangleOptions.borderSkipped,
backgroundColor: custom.backgroundColor ? custom.backgroundColor : helpers.valueAtIndexOrDefault(dataset.backgroundColor, index, rectangleOptions.backgroundColor),
borderColor: custom.borderColor ? custom.borderColor : helpers.valueAtIndexOrDefault(dataset.borderColor, index, rectangleOptions.borderColor),
borderWidth: custom.borderWidth ? custom.borderWidth : helpers.valueAtIndexOrDefault(dataset.borderWidth, index, rectangleOptions.borderWidth)
};
me.updateElementGeometry(rectangle, index, reset);
rectangle.pivot();
},
/**
* @private
*/
updateElementGeometry: function(rectangle, index, reset) {
var me = this;
var model = rectangle._model;
var vscale = me.getValueScale();
var base = vscale.getBasePixel();
var horizontal = vscale.isHorizontal();
var ruler = me._ruler || me.getRuler();
var vpixels = me.calculateBarValuePixels(me.index, index);
var ipixels = me.calculateBarIndexPixels(me.index, index, ruler);
model.horizontal = horizontal;
model.base = reset ? base : vpixels.base;
model.x = horizontal ? reset ? base : vpixels.head : ipixels.center;
model.y = horizontal ? ipixels.center : reset ? base : vpixels.head;
model.height = horizontal ? ipixels.size : undefined;
model.width = horizontal ? undefined : ipixels.size;
},
/**
* @private
*/
getValueScaleId: function() {
return this.getMeta().yAxisID;
},
/**
* @private
*/
getIndexScaleId: function() {
return this.getMeta().xAxisID;
},
/**
* @private
*/
getValueScale: function() {
return this.getScaleForId(this.getValueScaleId());
},
/**
* @private
*/
getIndexScale: function() {
return this.getScaleForId(this.getIndexScaleId());
},
/**
* Returns the stacks based on groups and bar visibility.
* @param {Number} [last] - The dataset index
* @returns {Array} The stack list
* @private
*/
_getStacks: function(last) {
var me = this;
var chart = me.chart;
var scale = me.getIndexScale();
var stacked = scale.options.stacked;
var ilen = last === undefined ? chart.data.datasets.length : last + 1;
var stacks = [];
var i, meta;
for (i = 0; i < ilen; ++i) {
meta = chart.getDatasetMeta(i);
if (meta.bar && chart.isDatasetVisible(i) &&
(stacked === false ||
(stacked === true && stacks.indexOf(meta.stack) === -1) ||
(stacked === undefined && (meta.stack === undefined || stacks.indexOf(meta.stack) === -1)))) {
stacks.push(meta.stack);
}
}
return stacks;
},
/**
* Returns the effective number of stacks based on groups and bar visibility.
* @private
*/
getStackCount: function() {
return this._getStacks().length;
},
/**
* Returns the stack index for the given dataset based on groups and bar visibility.
* @param {Number} [datasetIndex] - The dataset index
* @param {String} [name] - The stack name to find
* @returns {Number} The stack index
* @private
*/
getStackIndex: function(datasetIndex, name) {
var stacks = this._getStacks(datasetIndex);
var index = (name !== undefined)
? stacks.indexOf(name)
: -1; // indexOf returns -1 if element is not present
return (index === -1)
? stacks.length - 1
: index;
},
/**
* @private
*/
getRuler: function() {
var me = this;
var scale = me.getIndexScale();
var stackCount = me.getStackCount();
var datasetIndex = me.index;
var isHorizontal = scale.isHorizontal();
var start = isHorizontal ? scale.left : scale.top;
var end = start + (isHorizontal ? scale.width : scale.height);
var pixels = [];
var i, ilen, min;
for (i = 0, ilen = me.getMeta().data.length; i < ilen; ++i) {
pixels.push(scale.getPixelForValue(null, i, datasetIndex));
}
min = helpers.isNullOrUndef(scale.options.barThickness)
? computeMinSampleSize(scale, pixels)
: -1;
return {
min: min,
pixels: pixels,
start: start,
end: end,
stackCount: stackCount,
scale: scale
};
},
/**
* Note: pixel values are not clamped to the scale area.
* @private
*/
calculateBarValuePixels: function(datasetIndex, index) {
var me = this;
var chart = me.chart;
var meta = me.getMeta();
var scale = me.getValueScale();
var datasets = chart.data.datasets;
var value = scale.getRightValue(datasets[datasetIndex].data[index]);
var stacked = scale.options.stacked;
var stack = meta.stack;
var start = 0;
var i, imeta, ivalue, base, head, size;
if (stacked || (stacked === undefined && stack !== undefined)) {
for (i = 0; i < datasetIndex; ++i) {
imeta = chart.getDatasetMeta(i);
if (imeta.bar &&
imeta.stack === stack &&
imeta.controller.getValueScaleId() === scale.id &&
chart.isDatasetVisible(i)) {
ivalue = scale.getRightValue(datasets[i].data[index]);
if ((value < 0 && ivalue < 0) || (value >= 0 && ivalue > 0)) {
start += ivalue;
}
}
}
}
base = scale.getPixelForValue(start);
head = scale.getPixelForValue(start + value);
size = (head - base) / 2;
return {
size: size,
base: base,
head: head,
center: head + size / 2
};
},
/**
* @private
*/
calculateBarIndexPixels: function(datasetIndex, index, ruler) {
var me = this;
var options = ruler.scale.options;
var range = options.barThickness === 'flex'
? computeFlexCategoryTraits(index, ruler, options)
: computeFitCategoryTraits(index, ruler, options);
var stackIndex = me.getStackIndex(datasetIndex, me.getMeta().stack);
var center = range.start + (range.chunk * stackIndex) + (range.chunk / 2);
var size = Math.min(
helpers.valueOrDefault(options.maxBarThickness, Infinity),
range.chunk * range.ratio);
return {
base: center - size / 2,
head: center + size / 2,
center: center,
size: size
};
},
draw: function() {
var me = this;
var chart = me.chart;
var scale = me.getValueScale();
var rects = me.getMeta().data;
var dataset = me.getDataset();
var ilen = rects.length;
var i = 0;
helpers.canvas.clipArea(chart.ctx, chart.chartArea);
for (; i < ilen; ++i) {
if (!isNaN(scale.getRightValue(dataset.data[i]))) {
rects[i].draw();
}
}
helpers.canvas.unclipArea(chart.ctx);
},
setHoverStyle: function(rectangle) {
var dataset = this.chart.data.datasets[rectangle._datasetIndex];
var index = rectangle._index;
var custom = rectangle.custom || {};
var model = rectangle._model;
model.backgroundColor = custom.hoverBackgroundColor ? custom.hoverBackgroundColor : helpers.valueAtIndexOrDefault(dataset.hoverBackgroundColor, index, helpers.getHoverColor(model.backgroundColor));
model.borderColor = custom.hoverBorderColor ? custom.hoverBorderColor : helpers.valueAtIndexOrDefault(dataset.hoverBorderColor, index, helpers.getHoverColor(model.borderColor));
model.borderWidth = custom.hoverBorderWidth ? custom.hoverBorderWidth : helpers.valueAtIndexOrDefault(dataset.hoverBorderWidth, index, model.borderWidth);
},
removeHoverStyle: function(rectangle) {
var dataset = this.chart.data.datasets[rectangle._datasetIndex];
var index = rectangle._index;
var custom = rectangle.custom || {};
var model = rectangle._model;
var rectangleElementOptions = this.chart.options.elements.rectangle;
model.backgroundColor = custom.backgroundColor ? custom.backgroundColor : helpers.valueAtIndexOrDefault(dataset.backgroundColor, index, rectangleElementOptions.backgroundColor);
model.borderColor = custom.borderColor ? custom.borderColor : helpers.valueAtIndexOrDefault(dataset.borderColor, index, rectangleElementOptions.borderColor);
model.borderWidth = custom.borderWidth ? custom.borderWidth : helpers.valueAtIndexOrDefault(dataset.borderWidth, index, rectangleElementOptions.borderWidth);
}
});
Chart.controllers.horizontalBar = Chart.controllers.bar.extend({
/**
* @private
*/
getValueScaleId: function() {
return this.getMeta().xAxisID;
},
/**
* @private
*/
getIndexScaleId: function() {
return this.getMeta().yAxisID;
}
});
};
},{"25":25,"40":40,"45":45}],16:[function(require,module,exports){
'use strict';
var defaults = require(25);
var elements = require(40);
var helpers = require(45);
defaults._set('bubble', {
hover: {
mode: 'single'
},
scales: {
xAxes: [{
type: 'linear', // bubble should probably use a linear scale by default
position: 'bottom',
id: 'x-axis-0' // need an ID so datasets can reference the scale
}],
yAxes: [{
type: 'linear',
position: 'left',
id: 'y-axis-0'
}]
},
tooltips: {
callbacks: {
title: function() {
// Title doesn't make sense for scatter since we format the data as a point
return '';
},
label: function(item, data) {
var datasetLabel = data.datasets[item.datasetIndex].label || '';
var dataPoint = data.datasets[item.datasetIndex].data[item.index];
return datasetLabel + ': (' + item.xLabel + ', ' + item.yLabel + ', ' + dataPoint.r + ')';
}
}
}
});
module.exports = function(Chart) {
Chart.controllers.bubble = Chart.DatasetController.extend({
/**
* @protected
*/
dataElementType: elements.Point,
/**
* @protected
*/
update: function(reset) {
var me = this;
var meta = me.getMeta();
var points = meta.data;
// Update Points
helpers.each(points, function(point, index) {
me.updateElement(point, index, reset);
});
},
/**
* @protected
*/
updateElement: function(point, index, reset) {
var me = this;
var meta = me.getMeta();
var custom = point.custom || {};
var xScale = me.getScaleForId(meta.xAxisID);
var yScale = me.getScaleForId(meta.yAxisID);
var options = me._resolveElementOptions(point, index);
var data = me.getDataset().data[index];
var dsIndex = me.index;
var x = reset ? xScale.getPixelForDecimal(0.5) : xScale.getPixelForValue(typeof data === 'object' ? data : NaN, index, dsIndex);
var y = reset ? yScale.getBasePixel() : yScale.getPixelForValue(data, index, dsIndex);
point._xScale = xScale;
point._yScale = yScale;
point._options = options;
point._datasetIndex = dsIndex;
point._index = index;
point._model = {
backgroundColor: options.backgroundColor,
borderColor: options.borderColor,
borderWidth: options.borderWidth,
hitRadius: options.hitRadius,
pointStyle: options.pointStyle,
radius: reset ? 0 : options.radius,
skip: custom.skip || isNaN(x) || isNaN(y),
x: x,
y: y,
};
point.pivot();
},
/**
* @protected
*/
setHoverStyle: function(point) {
var model = point._model;
var options = point._options;
model.backgroundColor = helpers.valueOrDefault(options.hoverBackgroundColor, helpers.getHoverColor(options.backgroundColor));
model.borderColor = helpers.valueOrDefault(options.hoverBorderColor, helpers.getHoverColor(options.borderColor));
model.borderWidth = helpers.valueOrDefault(options.hoverBorderWidth, options.borderWidth);
model.radius = options.radius + options.hoverRadius;
},
/**
* @protected
*/
removeHoverStyle: function(point) {
var model = point._model;
var options = point._options;
model.backgroundColor = options.backgroundColor;
model.borderColor = options.borderColor;
model.borderWidth = options.borderWidth;
model.radius = options.radius;
},
/**
* @private
*/
_resolveElementOptions: function(point, index) {
var me = this;
var chart = me.chart;
var datasets = chart.data.datasets;
var dataset = datasets[me.index];
var custom = point.custom || {};
var options = chart.options.elements.point;
var resolve = helpers.options.resolve;
var data = dataset.data[index];
var values = {};
var i, ilen, key;
// Scriptable options
var context = {
chart: chart,
dataIndex: index,
dataset: dataset,
datasetIndex: me.index
};
var keys = [
'backgroundColor',
'borderColor',
'borderWidth',
'hoverBackgroundColor',
'hoverBorderColor',
'hoverBorderWidth',
'hoverRadius',
'hitRadius',
'pointStyle'
];
for (i = 0, ilen = keys.length; i < ilen; ++i) {
key = keys[i];
values[key] = resolve([
custom[key],
dataset[key],
options[key]
], context, index);
}
// Custom radius resolution
values.radius = resolve([
custom.radius,
data ? data.r : undefined,
dataset.radius,
options.radius
], context, index);
return values;
}
});
};
},{"25":25,"40":40,"45":45}],17:[function(require,module,exports){
'use strict';
var defaults = require(25);
var elements = require(40);
var helpers = require(45);
defaults._set('doughnut', {
animation: {
// Boolean - Whether we animate the rotation of the Doughnut
animateRotate: true,
// Boolean - Whether we animate scaling the Doughnut from the centre
animateScale: false
},
hover: {
mode: 'single'
},
legendCallback: function(chart) {
var text = [];
text.push('
');
var data = chart.data;
var datasets = data.datasets;
var labels = data.labels;
if (datasets.length) {
for (var i = 0; i < datasets[0].data.length; ++i) {
text.push('
');
if (labels[i]) {
text.push(labels[i]);
}
text.push('
');
}
}
text.push('
');
return text.join('');
},
legend: {
labels: {
generateLabels: function(chart) {
var data = chart.data;
if (data.labels.length && data.datasets.length) {
return data.labels.map(function(label, i) {
var meta = chart.getDatasetMeta(0);
var ds = data.datasets[0];
var arc = meta.data[i];
var custom = arc && arc.custom || {};
var valueAtIndexOrDefault = helpers.valueAtIndexOrDefault;
var arcOpts = chart.options.elements.arc;
var fill = custom.backgroundColor ? custom.backgroundColor : valueAtIndexOrDefault(ds.backgroundColor, i, arcOpts.backgroundColor);
var stroke = custom.borderColor ? custom.borderColor : valueAtIndexOrDefault(ds.borderColor, i, arcOpts.borderColor);
var bw = custom.borderWidth ? custom.borderWidth : valueAtIndexOrDefault(ds.borderWidth, i, arcOpts.borderWidth);
return {
text: label,
fillStyle: fill,
strokeStyle: stroke,
lineWidth: bw,
hidden: isNaN(ds.data[i]) || meta.data[i].hidden,
// Extra data used for toggling the correct item
index: i
};
});
}
return [];
}
},
onClick: function(e, legendItem) {
var index = legendItem.index;
var chart = this.chart;
var i, ilen, meta;
for (i = 0, ilen = (chart.data.datasets || []).length; i < ilen; ++i) {
meta = chart.getDatasetMeta(i);
// toggle visibility of index if exists
if (meta.data[index]) {
meta.data[index].hidden = !meta.data[index].hidden;
}
}
chart.update();
}
},
// The percentage of the chart that we cut out of the middle.
cutoutPercentage: 50,
// The rotation of the chart, where the first data arc begins.
rotation: Math.PI * -0.5,
// The total circumference of the chart.
circumference: Math.PI * 2.0,
// Need to override these to give a nice default
tooltips: {
callbacks: {
title: function() {
return '';
},
label: function(tooltipItem, data) {
var dataLabel = data.labels[tooltipItem.index];
var value = ': ' + data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index];
if (helpers.isArray(dataLabel)) {
// show value on first line of multiline label
// need to clone because we are changing the value
dataLabel = dataLabel.slice();
dataLabel[0] += value;
} else {
dataLabel += value;
}
return dataLabel;
}
}
}
});
defaults._set('pie', helpers.clone(defaults.doughnut));
defaults._set('pie', {
cutoutPercentage: 0
});
module.exports = function(Chart) {
Chart.controllers.doughnut = Chart.controllers.pie = Chart.DatasetController.extend({
dataElementType: elements.Arc,
linkScales: helpers.noop,
// Get index of the dataset in relation to the visible datasets. This allows determining the inner and outer radius correctly
getRingIndex: function(datasetIndex) {
var ringIndex = 0;
for (var j = 0; j < datasetIndex; ++j) {
if (this.chart.isDatasetVisible(j)) {
++ringIndex;
}
}
return ringIndex;
},
update: function(reset) {
var me = this;
var chart = me.chart;
var chartArea = chart.chartArea;
var opts = chart.options;
var arcOpts = opts.elements.arc;
var availableWidth = chartArea.right - chartArea.left - arcOpts.borderWidth;
var availableHeight = chartArea.bottom - chartArea.top - arcOpts.borderWidth;
var minSize = Math.min(availableWidth, availableHeight);
var offset = {x: 0, y: 0};
var meta = me.getMeta();
var cutoutPercentage = opts.cutoutPercentage;
var circumference = opts.circumference;
// If the chart's circumference isn't a full circle, calculate minSize as a ratio of the width/height of the arc
if (circumference < Math.PI * 2.0) {
var startAngle = opts.rotation % (Math.PI * 2.0);
startAngle += Math.PI * 2.0 * (startAngle >= Math.PI ? -1 : startAngle < -Math.PI ? 1 : 0);
var endAngle = startAngle + circumference;
var start = {x: Math.cos(startAngle), y: Math.sin(startAngle)};
var end = {x: Math.cos(endAngle), y: Math.sin(endAngle)};
var contains0 = (startAngle <= 0 && endAngle >= 0) || (startAngle <= Math.PI * 2.0 && Math.PI * 2.0 <= endAngle);
var contains90 = (startAngle <= Math.PI * 0.5 && Math.PI * 0.5 <= endAngle) || (startAngle <= Math.PI * 2.5 && Math.PI * 2.5 <= endAngle);
var contains180 = (startAngle <= -Math.PI && -Math.PI <= endAngle) || (startAngle <= Math.PI && Math.PI <= endAngle);
var contains270 = (startAngle <= -Math.PI * 0.5 && -Math.PI * 0.5 <= endAngle) || (startAngle <= Math.PI * 1.5 && Math.PI * 1.5 <= endAngle);
var cutout = cutoutPercentage / 100.0;
var min = {x: contains180 ? -1 : Math.min(start.x * (start.x < 0 ? 1 : cutout), end.x * (end.x < 0 ? 1 : cutout)), y: contains270 ? -1 : Math.min(start.y * (start.y < 0 ? 1 : cutout), end.y * (end.y < 0 ? 1 : cutout))};
var max = {x: contains0 ? 1 : Math.max(start.x * (start.x > 0 ? 1 : cutout), end.x * (end.x > 0 ? 1 : cutout)), y: contains90 ? 1 : Math.max(start.y * (start.y > 0 ? 1 : cutout), end.y * (end.y > 0 ? 1 : cutout))};
var size = {width: (max.x - min.x) * 0.5, height: (max.y - min.y) * 0.5};
minSize = Math.min(availableWidth / size.width, availableHeight / size.height);
offset = {x: (max.x + min.x) * -0.5, y: (max.y + min.y) * -0.5};
}
chart.borderWidth = me.getMaxBorderWidth(meta.data);
chart.outerRadius = Math.max((minSize - chart.borderWidth) / 2, 0);
chart.innerRadius = Math.max(cutoutPercentage ? (chart.outerRadius / 100) * (cutoutPercentage) : 0, 0);
chart.radiusLength = (chart.outerRadius - chart.innerRadius) / chart.getVisibleDatasetCount();
chart.offsetX = offset.x * chart.outerRadius;
chart.offsetY = offset.y * chart.outerRadius;
meta.total = me.calculateTotal();
me.outerRadius = chart.outerRadius - (chart.radiusLength * me.getRingIndex(me.index));
me.innerRadius = Math.max(me.outerRadius - chart.radiusLength, 0);
helpers.each(meta.data, function(arc, index) {
me.updateElement(arc, index, reset);
});
},
updateElement: function(arc, index, reset) {
var me = this;
var chart = me.chart;
var chartArea = chart.chartArea;
var opts = chart.options;
var animationOpts = opts.animation;
var centerX = (chartArea.left + chartArea.right) / 2;
var centerY = (chartArea.top + chartArea.bottom) / 2;
var startAngle = opts.rotation; // non reset case handled later
var endAngle = opts.rotation; // non reset case handled later
var dataset = me.getDataset();
var circumference = reset && animationOpts.animateRotate ? 0 : arc.hidden ? 0 : me.calculateCircumference(dataset.data[index]) * (opts.circumference / (2.0 * Math.PI));
var innerRadius = reset && animationOpts.animateScale ? 0 : me.innerRadius;
var outerRadius = reset && animationOpts.animateScale ? 0 : me.outerRadius;
var valueAtIndexOrDefault = helpers.valueAtIndexOrDefault;
helpers.extend(arc, {
// Utility
_datasetIndex: me.index,
_index: index,
// Desired view properties
_model: {
x: centerX + chart.offsetX,
y: centerY + chart.offsetY,
startAngle: startAngle,
endAngle: endAngle,
circumference: circumference,
outerRadius: outerRadius,
innerRadius: innerRadius,
label: valueAtIndexOrDefault(dataset.label, index, chart.data.labels[index])
}
});
var model = arc._model;
// Resets the visual styles
this.removeHoverStyle(arc);
// Set correct angles if not resetting
if (!reset || !animationOpts.animateRotate) {
if (index === 0) {
model.startAngle = opts.rotation;
} else {
model.startAngle = me.getMeta().data[index - 1]._model.endAngle;
}
model.endAngle = model.startAngle + model.circumference;
}
arc.pivot();
},
removeHoverStyle: function(arc) {
Chart.DatasetController.prototype.removeHoverStyle.call(this, arc, this.chart.options.elements.arc);
},
calculateTotal: function() {
var dataset = this.getDataset();
var meta = this.getMeta();
var total = 0;
var value;
helpers.each(meta.data, function(element, index) {
value = dataset.data[index];
if (!isNaN(value) && !element.hidden) {
total += Math.abs(value);
}
});
/* if (total === 0) {
total = NaN;
}*/
return total;
},
calculateCircumference: function(value) {
var total = this.getMeta().total;
if (total > 0 && !isNaN(value)) {
return (Math.PI * 2.0) * (Math.abs(value) / total);
}
return 0;
},
// gets the max border or hover width to properly scale pie charts
getMaxBorderWidth: function(arcs) {
var max = 0;
var index = this.index;
var length = arcs.length;
var borderWidth;
var hoverWidth;
for (var i = 0; i < length; i++) {
borderWidth = arcs[i]._model ? arcs[i]._model.borderWidth : 0;
hoverWidth = arcs[i]._chart ? arcs[i]._chart.config.data.datasets[index].hoverBorderWidth : 0;
max = borderWidth > max ? borderWidth : max;
max = hoverWidth > max ? hoverWidth : max;
}
return max;
}
});
};
},{"25":25,"40":40,"45":45}],18:[function(require,module,exports){
'use strict';
var defaults = require(25);
var elements = require(40);
var helpers = require(45);
defaults._set('line', {
showLines: true,
spanGaps: false,
hover: {
mode: 'label'
},
scales: {
xAxes: [{
type: 'category',
id: 'x-axis-0'
}],
yAxes: [{
type: 'linear',
id: 'y-axis-0'
}]
}
});
module.exports = function(Chart) {
function lineEnabled(dataset, options) {
return helpers.valueOrDefault(dataset.showLine, options.showLines);
}
Chart.controllers.line = Chart.DatasetController.extend({
datasetElementType: elements.Line,
dataElementType: elements.Point,
update: function(reset) {
var me = this;
var meta = me.getMeta();
var line = meta.dataset;
var points = meta.data || [];
var options = me.chart.options;
var lineElementOptions = options.elements.line;
var scale = me.getScaleForId(meta.yAxisID);
var i, ilen, custom;
var dataset = me.getDataset();
var showLine = lineEnabled(dataset, options);
// Update Line
if (showLine) {
custom = line.custom || {};
// Compatibility: If the properties are defined with only the old name, use those values
if ((dataset.tension !== undefined) && (dataset.lineTension === undefined)) {
dataset.lineTension = dataset.tension;
}
// Utility
line._scale = scale;
line._datasetIndex = me.index;
// Data
line._children = points;
// Model
line._model = {
// Appearance
// The default behavior of lines is to break at null values, according
// to https://github.com/chartjs/Chart.js/issues/2435#issuecomment-216718158
// This option gives lines the ability to span gaps
spanGaps: dataset.spanGaps ? dataset.spanGaps : options.spanGaps,
tension: custom.tension ? custom.tension : helpers.valueOrDefault(dataset.lineTension, lineElementOptions.tension),
backgroundColor: custom.backgroundColor ? custom.backgroundColor : (dataset.backgroundColor || lineElementOptions.backgroundColor),
borderWidth: custom.borderWidth ? custom.borderWidth : (dataset.borderWidth || lineElementOptions.borderWidth),
borderColor: custom.borderColor ? custom.borderColor : (dataset.borderColor || lineElementOptions.borderColor),
borderCapStyle: custom.borderCapStyle ? custom.borderCapStyle : (dataset.borderCapStyle || lineElementOptions.borderCapStyle),
borderDash: custom.borderDash ? custom.borderDash : (dataset.borderDash || lineElementOptions.borderDash),
borderDashOffset: custom.borderDashOffset ? custom.borderDashOffset : (dataset.borderDashOffset || lineElementOptions.borderDashOffset),
borderJoinStyle: custom.borderJoinStyle ? custom.borderJoinStyle : (dataset.borderJoinStyle || lineElementOptions.borderJoinStyle),
fill: custom.fill ? custom.fill : (dataset.fill !== undefined ? dataset.fill : lineElementOptions.fill),
steppedLine: custom.steppedLine ? custom.steppedLine : helpers.valueOrDefault(dataset.steppedLine, lineElementOptions.stepped),
cubicInterpolationMode: custom.cubicInterpolationMode ? custom.cubicInterpolationMode : helpers.valueOrDefault(dataset.cubicInterpolationMode, lineElementOptions.cubicInterpolationMode),
};
line.pivot();
}
// Update Points
for (i = 0, ilen = points.length; i < ilen; ++i) {
me.updateElement(points[i], i, reset);
}
if (showLine && line._model.tension !== 0) {
me.updateBezierControlPoints();
}
// Now pivot the point for animation
for (i = 0, ilen = points.length; i < ilen; ++i) {
points[i].pivot();
}
},
getPointBackgroundColor: function(point, index) {
var backgroundColor = this.chart.options.elements.point.backgroundColor;
var dataset = this.getDataset();
var custom = point.custom || {};
if (custom.backgroundColor) {
backgroundColor = custom.backgroundColor;
} else if (dataset.pointBackgroundColor) {
backgroundColor = helpers.valueAtIndexOrDefault(dataset.pointBackgroundColor, index, backgroundColor);
} else if (dataset.backgroundColor) {
backgroundColor = dataset.backgroundColor;
}
return backgroundColor;
},
getPointBorderColor: function(point, index) {
var borderColor = this.chart.options.elements.point.borderColor;
var dataset = this.getDataset();
var custom = point.custom || {};
if (custom.borderColor) {
borderColor = custom.borderColor;
} else if (dataset.pointBorderColor) {
borderColor = helpers.valueAtIndexOrDefault(dataset.pointBorderColor, index, borderColor);
} else if (dataset.borderColor) {
borderColor = dataset.borderColor;
}
return borderColor;
},
getPointBorderWidth: function(point, index) {
var borderWidth = this.chart.options.elements.point.borderWidth;
var dataset = this.getDataset();
var custom = point.custom || {};
if (!isNaN(custom.borderWidth)) {
borderWidth = custom.borderWidth;
} else if (!isNaN(dataset.pointBorderWidth) || helpers.isArray(dataset.pointBorderWidth)) {
borderWidth = helpers.valueAtIndexOrDefault(dataset.pointBorderWidth, index, borderWidth);
} else if (!isNaN(dataset.borderWidth)) {
borderWidth = dataset.borderWidth;
}
return borderWidth;
},
updateElement: function(point, index, reset) {
var me = this;
var meta = me.getMeta();
var custom = point.custom || {};
var dataset = me.getDataset();
var datasetIndex = me.index;
var value = dataset.data[index];
var yScale = me.getScaleForId(meta.yAxisID);
var xScale = me.getScaleForId(meta.xAxisID);
var pointOptions = me.chart.options.elements.point;
var x, y;
// Compatibility: If the properties are defined with only the old name, use those values
if ((dataset.radius !== undefined) && (dataset.pointRadius === undefined)) {
dataset.pointRadius = dataset.radius;
}
if ((dataset.hitRadius !== undefined) && (dataset.pointHitRadius === undefined)) {
dataset.pointHitRadius = dataset.hitRadius;
}
x = xScale.getPixelForValue(typeof value === 'object' ? value : NaN, index, datasetIndex);
y = reset ? yScale.getBasePixel() : me.calculatePointY(value, index, datasetIndex);
// Utility
point._xScale = xScale;
point._yScale = yScale;
point._datasetIndex = datasetIndex;
point._index = index;
// Desired view properties
point._model = {
x: x,
y: y,
skip: custom.skip || isNaN(x) || isNaN(y),
// Appearance
radius: custom.radius || helpers.valueAtIndexOrDefault(dataset.pointRadius, index, pointOptions.radius),
pointStyle: custom.pointStyle || helpers.valueAtIndexOrDefault(dataset.pointStyle, index, pointOptions.pointStyle),
backgroundColor: me.getPointBackgroundColor(point, index),
borderColor: me.getPointBorderColor(point, index),
borderWidth: me.getPointBorderWidth(point, index),
tension: meta.dataset._model ? meta.dataset._model.tension : 0,
steppedLine: meta.dataset._model ? meta.dataset._model.steppedLine : false,
// Tooltip
hitRadius: custom.hitRadius || helpers.valueAtIndexOrDefault(dataset.pointHitRadius, index, pointOptions.hitRadius)
};
},
calculatePointY: function(value, index, datasetIndex) {
var me = this;
var chart = me.chart;
var meta = me.getMeta();
var yScale = me.getScaleForId(meta.yAxisID);
var sumPos = 0;
var sumNeg = 0;
var i, ds, dsMeta;
if (yScale.options.stacked) {
for (i = 0; i < datasetIndex; i++) {
ds = chart.data.datasets[i];
dsMeta = chart.getDatasetMeta(i);
if (dsMeta.type === 'line' && dsMeta.yAxisID === yScale.id && chart.isDatasetVisible(i)) {
var stackedRightValue = Number(yScale.getRightValue(ds.data[index]));
if (stackedRightValue < 0) {
sumNeg += stackedRightValue || 0;
} else {
sumPos += stackedRightValue || 0;
}
}
}
var rightValue = Number(yScale.getRightValue(value));
if (rightValue < 0) {
return yScale.getPixelForValue(sumNeg + rightValue);
}
return yScale.getPixelForValue(sumPos + rightValue);
}
return yScale.getPixelForValue(value);
},
updateBezierControlPoints: function() {
var me = this;
var meta = me.getMeta();
var area = me.chart.chartArea;
var points = (meta.data || []);
var i, ilen, point, model, controlPoints;
// Only consider points that are drawn in case the spanGaps option is used
if (meta.dataset._model.spanGaps) {
points = points.filter(function(pt) {
return !pt._model.skip;
});
}
function capControlPoint(pt, min, max) {
return Math.max(Math.min(pt, max), min);
}
if (meta.dataset._model.cubicInterpolationMode === 'monotone') {
helpers.splineCurveMonotone(points);
} else {
for (i = 0, ilen = points.length; i < ilen; ++i) {
point = points[i];
model = point._model;
controlPoints = helpers.splineCurve(
helpers.previousItem(points, i)._model,
model,
helpers.nextItem(points, i)._model,
meta.dataset._model.tension
);
model.controlPointPreviousX = controlPoints.previous.x;
model.controlPointPreviousY = controlPoints.previous.y;
model.controlPointNextX = controlPoints.next.x;
model.controlPointNextY = controlPoints.next.y;
}
}
if (me.chart.options.elements.line.capBezierPoints) {
for (i = 0, ilen = points.length; i < ilen; ++i) {
model = points[i]._model;
model.controlPointPreviousX = capControlPoint(model.controlPointPreviousX, area.left, area.right);
model.controlPointPreviousY = capControlPoint(model.controlPointPreviousY, area.top, area.bottom);
model.controlPointNextX = capControlPoint(model.controlPointNextX, area.left, area.right);
model.controlPointNextY = capControlPoint(model.controlPointNextY, area.top, area.bottom);
}
}
},
draw: function() {
var me = this;
var chart = me.chart;
var meta = me.getMeta();
var points = meta.data || [];
var area = chart.chartArea;
var ilen = points.length;
var i = 0;
helpers.canvas.clipArea(chart.ctx, area);
if (lineEnabled(me.getDataset(), chart.options)) {
meta.dataset.draw();
}
helpers.canvas.unclipArea(chart.ctx);
// Draw the points
for (; i < ilen; ++i) {
points[i].draw(area);
}
},
setHoverStyle: function(point) {
// Point
var dataset = this.chart.data.datasets[point._datasetIndex];
var index = point._index;
var custom = point.custom || {};
var model = point._model;
model.radius = custom.hoverRadius || helpers.valueAtIndexOrDefault(dataset.pointHoverRadius, index, this.chart.options.elements.point.hoverRadius);
model.backgroundColor = custom.hoverBackgroundColor || helpers.valueAtIndexOrDefault(dataset.pointHoverBackgroundColor, index, helpers.getHoverColor(model.backgroundColor));
model.borderColor = custom.hoverBorderColor || helpers.valueAtIndexOrDefault(dataset.pointHoverBorderColor, index, helpers.getHoverColor(model.borderColor));
model.borderWidth = custom.hoverBorderWidth || helpers.valueAtIndexOrDefault(dataset.pointHoverBorderWidth, index, model.borderWidth);
},
removeHoverStyle: function(point) {
var me = this;
var dataset = me.chart.data.datasets[point._datasetIndex];
var index = point._index;
var custom = point.custom || {};
var model = point._model;
// Compatibility: If the properties are defined with only the old name, use those values
if ((dataset.radius !== undefined) && (dataset.pointRadius === undefined)) {
dataset.pointRadius = dataset.radius;
}
model.radius = custom.radius || helpers.valueAtIndexOrDefault(dataset.pointRadius, index, me.chart.options.elements.point.radius);
model.backgroundColor = me.getPointBackgroundColor(point, index);
model.borderColor = me.getPointBorderColor(point, index);
model.borderWidth = me.getPointBorderWidth(point, index);
}
});
};
},{"25":25,"40":40,"45":45}],19:[function(require,module,exports){
'use strict';
var defaults = require(25);
var elements = require(40);
var helpers = require(45);
defaults._set('polarArea', {
scale: {
type: 'radialLinear',
angleLines: {
display: false
},
gridLines: {
circular: true
},
pointLabels: {
display: false
},
ticks: {
beginAtZero: true
}
},
// Boolean - Whether to animate the rotation of the chart
animation: {
animateRotate: true,
animateScale: true
},
startAngle: -0.5 * Math.PI,
legendCallback: function(chart) {
var text = [];
text.push('
');
var data = chart.data;
var datasets = data.datasets;
var labels = data.labels;
if (datasets.length) {
for (var i = 0; i < datasets[0].data.length; ++i) {
text.push('
');
if (labels[i]) {
text.push(labels[i]);
}
text.push('
');
}
}
text.push('
');
return text.join('');
},
legend: {
labels: {
generateLabels: function(chart) {
var data = chart.data;
if (data.labels.length && data.datasets.length) {
return data.labels.map(function(label, i) {
var meta = chart.getDatasetMeta(0);
var ds = data.datasets[0];
var arc = meta.data[i];
var custom = arc.custom || {};
var valueAtIndexOrDefault = helpers.valueAtIndexOrDefault;
var arcOpts = chart.options.elements.arc;
var fill = custom.backgroundColor ? custom.backgroundColor : valueAtIndexOrDefault(ds.backgroundColor, i, arcOpts.backgroundColor);
var stroke = custom.borderColor ? custom.borderColor : valueAtIndexOrDefault(ds.borderColor, i, arcOpts.borderColor);
var bw = custom.borderWidth ? custom.borderWidth : valueAtIndexOrDefault(ds.borderWidth, i, arcOpts.borderWidth);
return {
text: label,
fillStyle: fill,
strokeStyle: stroke,
lineWidth: bw,
hidden: isNaN(ds.data[i]) || meta.data[i].hidden,
// Extra data used for toggling the correct item
index: i
};
});
}
return [];
}
},
onClick: function(e, legendItem) {
var index = legendItem.index;
var chart = this.chart;
var i, ilen, meta;
for (i = 0, ilen = (chart.data.datasets || []).length; i < ilen; ++i) {
meta = chart.getDatasetMeta(i);
meta.data[index].hidden = !meta.data[index].hidden;
}
chart.update();
}
},
// Need to override these to give a nice default
tooltips: {
callbacks: {
title: function() {
return '';
},
label: function(item, data) {
return data.labels[item.index] + ': ' + item.yLabel;
}
}
}
});
module.exports = function(Chart) {
Chart.controllers.polarArea = Chart.DatasetController.extend({
dataElementType: elements.Arc,
linkScales: helpers.noop,
update: function(reset) {
var me = this;
var chart = me.chart;
var chartArea = chart.chartArea;
var meta = me.getMeta();
var opts = chart.options;
var arcOpts = opts.elements.arc;
var minSize = Math.min(chartArea.right - chartArea.left, chartArea.bottom - chartArea.top);
chart.outerRadius = Math.max((minSize - arcOpts.borderWidth / 2) / 2, 0);
chart.innerRadius = Math.max(opts.cutoutPercentage ? (chart.outerRadius / 100) * (opts.cutoutPercentage) : 1, 0);
chart.radiusLength = (chart.outerRadius - chart.innerRadius) / chart.getVisibleDatasetCount();
me.outerRadius = chart.outerRadius - (chart.radiusLength * me.index);
me.innerRadius = me.outerRadius - chart.radiusLength;
meta.count = me.countVisibleElements();
helpers.each(meta.data, function(arc, index) {
me.updateElement(arc, index, reset);
});
},
updateElement: function(arc, index, reset) {
var me = this;
var chart = me.chart;
var dataset = me.getDataset();
var opts = chart.options;
var animationOpts = opts.animation;
var scale = chart.scale;
var labels = chart.data.labels;
var circumference = me.calculateCircumference(dataset.data[index]);
var centerX = scale.xCenter;
var centerY = scale.yCenter;
// If there is NaN data before us, we need to calculate the starting angle correctly.
// We could be way more efficient here, but its unlikely that the polar area chart will have a lot of data
var visibleCount = 0;
var meta = me.getMeta();
for (var i = 0; i < index; ++i) {
if (!isNaN(dataset.data[i]) && !meta.data[i].hidden) {
++visibleCount;
}
}
// var negHalfPI = -0.5 * Math.PI;
var datasetStartAngle = opts.startAngle;
var distance = arc.hidden ? 0 : scale.getDistanceFromCenterForValue(dataset.data[index]);
var startAngle = datasetStartAngle + (circumference * visibleCount);
var endAngle = startAngle + (arc.hidden ? 0 : circumference);
var resetRadius = animationOpts.animateScale ? 0 : scale.getDistanceFromCenterForValue(dataset.data[index]);
helpers.extend(arc, {
// Utility
_datasetIndex: me.index,
_index: index,
_scale: scale,
// Desired view properties
_model: {
x: centerX,
y: centerY,
innerRadius: 0,
outerRadius: reset ? resetRadius : distance,
startAngle: reset && animationOpts.animateRotate ? datasetStartAngle : startAngle,
endAngle: reset && animationOpts.animateRotate ? datasetStartAngle : endAngle,
label: helpers.valueAtIndexOrDefault(labels, index, labels[index])
}
});
// Apply border and fill style
me.removeHoverStyle(arc);
arc.pivot();
},
removeHoverStyle: function(arc) {
Chart.DatasetController.prototype.removeHoverStyle.call(this, arc, this.chart.options.elements.arc);
},
countVisibleElements: function() {
var dataset = this.getDataset();
var meta = this.getMeta();
var count = 0;
helpers.each(meta.data, function(element, index) {
if (!isNaN(dataset.data[index]) && !element.hidden) {
count++;
}
});
return count;
},
calculateCircumference: function(value) {
var count = this.getMeta().count;
if (count > 0 && !isNaN(value)) {
return (2 * Math.PI) / count;
}
return 0;
}
});
};
},{"25":25,"40":40,"45":45}],20:[function(require,module,exports){
'use strict';
var defaults = require(25);
var elements = require(40);
var helpers = require(45);
defaults._set('radar', {
scale: {
type: 'radialLinear'
},
elements: {
line: {
tension: 0 // no bezier in radar
}
}
});
module.exports = function(Chart) {
Chart.controllers.radar = Chart.DatasetController.extend({
datasetElementType: elements.Line,
dataElementType: elements.Point,
linkScales: helpers.noop,
update: function(reset) {
var me = this;
var meta = me.getMeta();
var line = meta.dataset;
var points = meta.data;
var custom = line.custom || {};
var dataset = me.getDataset();
var lineElementOptions = me.chart.options.elements.line;
var scale = me.chart.scale;
// Compatibility: If the properties are defined with only the old name, use those values
if ((dataset.tension !== undefined) && (dataset.lineTension === undefined)) {
dataset.lineTension = dataset.tension;
}
helpers.extend(meta.dataset, {
// Utility
_datasetIndex: me.index,
_scale: scale,
// Data
_children: points,
_loop: true,
// Model
_model: {
// Appearance
tension: custom.tension ? custom.tension : helpers.valueOrDefault(dataset.lineTension, lineElementOptions.tension),
backgroundColor: custom.backgroundColor ? custom.backgroundColor : (dataset.backgroundColor || lineElementOptions.backgroundColor),
borderWidth: custom.borderWidth ? custom.borderWidth : (dataset.borderWidth || lineElementOptions.borderWidth),
borderColor: custom.borderColor ? custom.borderColor : (dataset.borderColor || lineElementOptions.borderColor),
fill: custom.fill ? custom.fill : (dataset.fill !== undefined ? dataset.fill : lineElementOptions.fill),
borderCapStyle: custom.borderCapStyle ? custom.borderCapStyle : (dataset.borderCapStyle || lineElementOptions.borderCapStyle),
borderDash: custom.borderDash ? custom.borderDash : (dataset.borderDash || lineElementOptions.borderDash),
borderDashOffset: custom.borderDashOffset ? custom.borderDashOffset : (dataset.borderDashOffset || lineElementOptions.borderDashOffset),
borderJoinStyle: custom.borderJoinStyle ? custom.borderJoinStyle : (dataset.borderJoinStyle || lineElementOptions.borderJoinStyle),
}
});
meta.dataset.pivot();
// Update Points
helpers.each(points, function(point, index) {
me.updateElement(point, index, reset);
}, me);
// Update bezier control points
me.updateBezierControlPoints();
},
updateElement: function(point, index, reset) {
var me = this;
var custom = point.custom || {};
var dataset = me.getDataset();
var scale = me.chart.scale;
var pointElementOptions = me.chart.options.elements.point;
var pointPosition = scale.getPointPositionForValue(index, dataset.data[index]);
// Compatibility: If the properties are defined with only the old name, use those values
if ((dataset.radius !== undefined) && (dataset.pointRadius === undefined)) {
dataset.pointRadius = dataset.radius;
}
if ((dataset.hitRadius !== undefined) && (dataset.pointHitRadius === undefined)) {
dataset.pointHitRadius = dataset.hitRadius;
}
helpers.extend(point, {
// Utility
_datasetIndex: me.index,
_index: index,
_scale: scale,
// Desired view properties
_model: {
x: reset ? scale.xCenter : pointPosition.x, // value not used in dataset scale, but we want a consistent API between scales
y: reset ? scale.yCenter : pointPosition.y,
// Appearance
tension: custom.tension ? custom.tension : helpers.valueOrDefault(dataset.lineTension, me.chart.options.elements.line.tension),
radius: custom.radius ? custom.radius : helpers.valueAtIndexOrDefault(dataset.pointRadius, index, pointElementOptions.radius),
backgroundColor: custom.backgroundColor ? custom.backgroundColor : helpers.valueAtIndexOrDefault(dataset.pointBackgroundColor, index, pointElementOptions.backgroundColor),
borderColor: custom.borderColor ? custom.borderColor : helpers.valueAtIndexOrDefault(dataset.pointBorderColor, index, pointElementOptions.borderColor),
borderWidth: custom.borderWidth ? custom.borderWidth : helpers.valueAtIndexOrDefault(dataset.pointBorderWidth, index, pointElementOptions.borderWidth),
pointStyle: custom.pointStyle ? custom.pointStyle : helpers.valueAtIndexOrDefault(dataset.pointStyle, index, pointElementOptions.pointStyle),
// Tooltip
hitRadius: custom.hitRadius ? custom.hitRadius : helpers.valueAtIndexOrDefault(dataset.pointHitRadius, index, pointElementOptions.hitRadius)
}
});
point._model.skip = custom.skip ? custom.skip : (isNaN(point._model.x) || isNaN(point._model.y));
},
updateBezierControlPoints: function() {
var chartArea = this.chart.chartArea;
var meta = this.getMeta();
helpers.each(meta.data, function(point, index) {
var model = point._model;
var controlPoints = helpers.splineCurve(
helpers.previousItem(meta.data, index, true)._model,
model,
helpers.nextItem(meta.data, index, true)._model,
model.tension
);
// Prevent the bezier going outside of the bounds of the graph
model.controlPointPreviousX = Math.max(Math.min(controlPoints.previous.x, chartArea.right), chartArea.left);
model.controlPointPreviousY = Math.max(Math.min(controlPoints.previous.y, chartArea.bottom), chartArea.top);
model.controlPointNextX = Math.max(Math.min(controlPoints.next.x, chartArea.right), chartArea.left);
model.controlPointNextY = Math.max(Math.min(controlPoints.next.y, chartArea.bottom), chartArea.top);
// Now pivot the point for animation
point.pivot();
});
},
setHoverStyle: function(point) {
// Point
var dataset = this.chart.data.datasets[point._datasetIndex];
var custom = point.custom || {};
var index = point._index;
var model = point._model;
model.radius = custom.hoverRadius ? custom.hoverRadius : helpers.valueAtIndexOrDefault(dataset.pointHoverRadius, index, this.chart.options.elements.point.hoverRadius);
model.backgroundColor = custom.hoverBackgroundColor ? custom.hoverBackgroundColor : helpers.valueAtIndexOrDefault(dataset.pointHoverBackgroundColor, index, helpers.getHoverColor(model.backgroundColor));
model.borderColor = custom.hoverBorderColor ? custom.hoverBorderColor : helpers.valueAtIndexOrDefault(dataset.pointHoverBorderColor, index, helpers.getHoverColor(model.borderColor));
model.borderWidth = custom.hoverBorderWidth ? custom.hoverBorderWidth : helpers.valueAtIndexOrDefault(dataset.pointHoverBorderWidth, index, model.borderWidth);
},
removeHoverStyle: function(point) {
var dataset = this.chart.data.datasets[point._datasetIndex];
var custom = point.custom || {};
var index = point._index;
var model = point._model;
var pointElementOptions = this.chart.options.elements.point;
model.radius = custom.radius ? custom.radius : helpers.valueAtIndexOrDefault(dataset.pointRadius, index, pointElementOptions.radius);
model.backgroundColor = custom.backgroundColor ? custom.backgroundColor : helpers.valueAtIndexOrDefault(dataset.pointBackgroundColor, index, pointElementOptions.backgroundColor);
model.borderColor = custom.borderColor ? custom.borderColor : helpers.valueAtIndexOrDefault(dataset.pointBorderColor, index, pointElementOptions.borderColor);
model.borderWidth = custom.borderWidth ? custom.borderWidth : helpers.valueAtIndexOrDefault(dataset.pointBorderWidth, index, pointElementOptions.borderWidth);
}
});
};
},{"25":25,"40":40,"45":45}],21:[function(require,module,exports){
'use strict';
var defaults = require(25);
defaults._set('scatter', {
hover: {
mode: 'single'
},
scales: {
xAxes: [{
id: 'x-axis-1', // need an ID so datasets can reference the scale
type: 'linear', // scatter should not use a category axis
position: 'bottom'
}],
yAxes: [{
id: 'y-axis-1',
type: 'linear',
position: 'left'
}]
},
showLines: false,
tooltips: {
callbacks: {
title: function() {
return ''; // doesn't make sense for scatter since data are formatted as a point
},
label: function(item) {
return '(' + item.xLabel + ', ' + item.yLabel + ')';
}
}
}
});
module.exports = function(Chart) {
// Scatter charts use line controllers
Chart.controllers.scatter = Chart.controllers.line;
};
},{"25":25}],22:[function(require,module,exports){
/* global window: false */
'use strict';
var defaults = require(25);
var Element = require(26);
var helpers = require(45);
defaults._set('global', {
animation: {
duration: 1000,
easing: 'easeOutQuart',
onProgress: helpers.noop,
onComplete: helpers.noop
}
});
module.exports = function(Chart) {
Chart.Animation = Element.extend({
chart: null, // the animation associated chart instance
currentStep: 0, // the current animation step
numSteps: 60, // default number of steps
easing: '', // the easing to use for this animation
render: null, // render function used by the animation service
onAnimationProgress: null, // user specified callback to fire on each step of the animation
onAnimationComplete: null, // user specified callback to fire when the animation finishes
});
Chart.animationService = {
frameDuration: 17,
animations: [],
dropFrames: 0,
request: null,
/**
* @param {Chart} chart - The chart to animate.
* @param {Chart.Animation} animation - The animation that we will animate.
* @param {Number} duration - The animation duration in ms.
* @param {Boolean} lazy - if true, the chart is not marked as animating to enable more responsive interactions
*/
addAnimation: function(chart, animation, duration, lazy) {
var animations = this.animations;
var i, ilen;
animation.chart = chart;
if (!lazy) {
chart.animating = true;
}
for (i = 0, ilen = animations.length; i < ilen; ++i) {
if (animations[i].chart === chart) {
animations[i] = animation;
return;
}
}
animations.push(animation);
// If there are no animations queued, manually kickstart a digest, for lack of a better word
if (animations.length === 1) {
this.requestAnimationFrame();
}
},
cancelAnimation: function(chart) {
var index = helpers.findIndex(this.animations, function(animation) {
return animation.chart === chart;
});
if (index !== -1) {
this.animations.splice(index, 1);
chart.animating = false;
}
},
requestAnimationFrame: function() {
var me = this;
if (me.request === null) {
// Skip animation frame requests until the active one is executed.
// This can happen when processing mouse events, e.g. 'mousemove'
// and 'mouseout' events will trigger multiple renders.
me.request = helpers.requestAnimFrame.call(window, function() {
me.request = null;
me.startDigest();
});
}
},
/**
* @private
*/
startDigest: function() {
var me = this;
var startTime = Date.now();
var framesToDrop = 0;
if (me.dropFrames > 1) {
framesToDrop = Math.floor(me.dropFrames);
me.dropFrames = me.dropFrames % 1;
}
me.advance(1 + framesToDrop);
var endTime = Date.now();
me.dropFrames += (endTime - startTime) / me.frameDuration;
// Do we have more stuff to animate?
if (me.animations.length > 0) {
me.requestAnimationFrame();
}
},
/**
* @private
*/
advance: function(count) {
var animations = this.animations;
var animation, chart;
var i = 0;
while (i < animations.length) {
animation = animations[i];
chart = animation.chart;
animation.currentStep = (animation.currentStep || 0) + count;
animation.currentStep = Math.min(animation.currentStep, animation.numSteps);
helpers.callback(animation.render, [chart, animation], chart);
helpers.callback(animation.onAnimationProgress, [animation], chart);
if (animation.currentStep >= animation.numSteps) {
helpers.callback(animation.onAnimationComplete, [animation], chart);
chart.animating = false;
animations.splice(i, 1);
} else {
++i;
}
}
}
};
/**
* Provided for backward compatibility, use Chart.Animation instead
* @prop Chart.Animation#animationObject
* @deprecated since version 2.6.0
* @todo remove at version 3
*/
Object.defineProperty(Chart.Animation.prototype, 'animationObject', {
get: function() {
return this;
}
});
/**
* Provided for backward compatibility, use Chart.Animation#chart instead
* @prop Chart.Animation#chartInstance
* @deprecated since version 2.6.0
* @todo remove at version 3
*/
Object.defineProperty(Chart.Animation.prototype, 'chartInstance', {
get: function() {
return this.chart;
},
set: function(value) {
this.chart = value;
}
});
};
},{"25":25,"26":26,"45":45}],23:[function(require,module,exports){
'use strict';
var defaults = require(25);
var helpers = require(45);
var Interaction = require(28);
var layouts = require(30);
var platform = require(48);
var plugins = require(31);
module.exports = function(Chart) {
// Create a dictionary of chart types, to allow for extension of existing types
Chart.types = {};
// Store a reference to each instance - allowing us to globally resize chart instances on window resize.
// Destroy method on the chart will remove the instance of the chart from this reference.
Chart.instances = {};
// Controllers available for dataset visualization eg. bar, line, slice, etc.
Chart.controllers = {};
/**
* Initializes the given config with global and chart default values.
*/
function initConfig(config) {
config = config || {};
// Do NOT use configMerge() for the data object because this method merges arrays
// and so would change references to labels and datasets, preventing data updates.
var data = config.data = config.data || {};
data.datasets = data.datasets || [];
data.labels = data.labels || [];
config.options = helpers.configMerge(
defaults.global,
defaults[config.type],
config.options || {});
return config;
}
/**
* Updates the config of the chart
* @param chart {Chart} chart to update the options for
*/
function updateConfig(chart) {
var newOptions = chart.options;
helpers.each(chart.scales, function(scale) {
layouts.removeBox(chart, scale);
});
newOptions = helpers.configMerge(
Chart.defaults.global,
Chart.defaults[chart.config.type],
newOptions);
chart.options = chart.config.options = newOptions;
chart.ensureScalesHaveIDs();
chart.buildOrUpdateScales();
// Tooltip
chart.tooltip._options = newOptions.tooltips;
chart.tooltip.initialize();
}
function positionIsHorizontal(position) {
return position === 'top' || position === 'bottom';
}
helpers.extend(Chart.prototype, /** @lends Chart */ {
/**
* @private
*/
construct: function(item, config) {
var me = this;
config = initConfig(config);
var context = platform.acquireContext(item, config);
var canvas = context && context.canvas;
var height = canvas && canvas.height;
var width = canvas && canvas.width;
me.id = helpers.uid();
me.ctx = context;
me.canvas = canvas;
me.config = config;
me.width = width;
me.height = height;
me.aspectRatio = height ? width / height : null;
me.options = config.options;
me._bufferedRender = false;
/**
* Provided for backward compatibility, Chart and Chart.Controller have been merged,
* the "instance" still need to be defined since it might be called from plugins.
* @prop Chart#chart
* @deprecated since version 2.6.0
* @todo remove at version 3
* @private
*/
me.chart = me;
me.controller = me; // chart.chart.controller #inception
// Add the chart instance to the global namespace
Chart.instances[me.id] = me;
// Define alias to the config data: `chart.data === chart.config.data`
Object.defineProperty(me, 'data', {
get: function() {
return me.config.data;
},
set: function(value) {
me.config.data = value;
}
});
if (!context || !canvas) {
// The given item is not a compatible context2d element, let's return before finalizing
// the chart initialization but after setting basic chart / controller properties that
// can help to figure out that the chart is not valid (e.g chart.canvas !== null);
// https://github.com/chartjs/Chart.js/issues/2807
console.error("Failed to create chart: can't acquire context from the given item");
return;
}
me.initialize();
me.update();
},
/**
* @private
*/
initialize: function() {
var me = this;
// Before init plugin notification
plugins.notify(me, 'beforeInit');
helpers.retinaScale(me, me.options.devicePixelRatio);
me.bindEvents();
if (me.options.responsive) {
// Initial resize before chart draws (must be silent to preserve initial animations).
me.resize(true);
}
// Make sure scales have IDs and are built before we build any controllers.
me.ensureScalesHaveIDs();
me.buildOrUpdateScales();
me.initToolTip();
// After init plugin notification
plugins.notify(me, 'afterInit');
return me;
},
clear: function() {
helpers.canvas.clear(this);
return this;
},
stop: function() {
// Stops any current animation loop occurring
Chart.animationService.cancelAnimation(this);
return this;
},
resize: function(silent) {
var me = this;
var options = me.options;
var canvas = me.canvas;
var aspectRatio = (options.maintainAspectRatio && me.aspectRatio) || null;
// the canvas render width and height will be casted to integers so make sure that
// the canvas display style uses the same integer values to avoid blurring effect.
// Set to 0 instead of canvas.size because the size defaults to 300x150 if the element is collased
var newWidth = Math.max(0, Math.floor(helpers.getMaximumWidth(canvas)));
var newHeight = Math.max(0, Math.floor(aspectRatio ? newWidth / aspectRatio : helpers.getMaximumHeight(canvas)));
if (me.width === newWidth && me.height === newHeight) {
return;
}
canvas.width = me.width = newWidth;
canvas.height = me.height = newHeight;
canvas.style.width = newWidth + 'px';
canvas.style.height = newHeight + 'px';
helpers.retinaScale(me, options.devicePixelRatio);
if (!silent) {
// Notify any plugins about the resize
var newSize = {width: newWidth, height: newHeight};
plugins.notify(me, 'resize', [newSize]);
// Notify of resize
if (me.options.onResize) {
me.options.onResize(me, newSize);
}
me.stop();
me.update(me.options.responsiveAnimationDuration);
}
},
ensureScalesHaveIDs: function() {
var options = this.options;
var scalesOptions = options.scales || {};
var scaleOptions = options.scale;
helpers.each(scalesOptions.xAxes, function(xAxisOptions, index) {
xAxisOptions.id = xAxisOptions.id || ('x-axis-' + index);
});
helpers.each(scalesOptions.yAxes, function(yAxisOptions, index) {
yAxisOptions.id = yAxisOptions.id || ('y-axis-' + index);
});
if (scaleOptions) {
scaleOptions.id = scaleOptions.id || 'scale';
}
},
/**
* Builds a map of scale ID to scale object for future lookup.
*/
buildOrUpdateScales: function() {
var me = this;
var options = me.options;
var scales = me.scales || {};
var items = [];
var updated = Object.keys(scales).reduce(function(obj, id) {
obj[id] = false;
return obj;
}, {});
if (options.scales) {
items = items.concat(
(options.scales.xAxes || []).map(function(xAxisOptions) {
return {options: xAxisOptions, dtype: 'category', dposition: 'bottom'};
}),
(options.scales.yAxes || []).map(function(yAxisOptions) {
return {options: yAxisOptions, dtype: 'linear', dposition: 'left'};
})
);
}
if (options.scale) {
items.push({
options: options.scale,
dtype: 'radialLinear',
isDefault: true,
dposition: 'chartArea'
});
}
helpers.each(items, function(item) {
var scaleOptions = item.options;
var id = scaleOptions.id;
var scaleType = helpers.valueOrDefault(scaleOptions.type, item.dtype);
if (positionIsHorizontal(scaleOptions.position) !== positionIsHorizontal(item.dposition)) {
scaleOptions.position = item.dposition;
}
updated[id] = true;
var scale = null;
if (id in scales && scales[id].type === scaleType) {
scale = scales[id];
scale.options = scaleOptions;
scale.ctx = me.ctx;
scale.chart = me;
} else {
var scaleClass = Chart.scaleService.getScaleConstructor(scaleType);
if (!scaleClass) {
return;
}
scale = new scaleClass({
id: id,
type: scaleType,
options: scaleOptions,
ctx: me.ctx,
chart: me
});
scales[scale.id] = scale;
}
scale.mergeTicksOptions();
// TODO(SB): I think we should be able to remove this custom case (options.scale)
// and consider it as a regular scale part of the "scales"" map only! This would
// make the logic easier and remove some useless? custom code.
if (item.isDefault) {
me.scale = scale;
}
});
// clear up discarded scales
helpers.each(updated, function(hasUpdated, id) {
if (!hasUpdated) {
delete scales[id];
}
});
me.scales = scales;
Chart.scaleService.addScalesToLayout(this);
},
buildOrUpdateControllers: function() {
var me = this;
var types = [];
var newControllers = [];
helpers.each(me.data.datasets, function(dataset, datasetIndex) {
var meta = me.getDatasetMeta(datasetIndex);
var type = dataset.type || me.config.type;
if (meta.type && meta.type !== type) {
me.destroyDatasetMeta(datasetIndex);
meta = me.getDatasetMeta(datasetIndex);
}
meta.type = type;
types.push(meta.type);
if (meta.controller) {
meta.controller.updateIndex(datasetIndex);
meta.controller.linkScales();
} else {
var ControllerClass = Chart.controllers[meta.type];
if (ControllerClass === undefined) {
throw new Error('"' + meta.type + '" is not a chart type.');
}
meta.controller = new ControllerClass(me, datasetIndex);
newControllers.push(meta.controller);
}
}, me);
return newControllers;
},
/**
* Reset the elements of all datasets
* @private
*/
resetElements: function() {
var me = this;
helpers.each(me.data.datasets, function(dataset, datasetIndex) {
me.getDatasetMeta(datasetIndex).controller.reset();
}, me);
},
/**
* Resets the chart back to it's state before the initial animation
*/
reset: function() {
this.resetElements();
this.tooltip.initialize();
},
update: function(config) {
var me = this;
if (!config || typeof config !== 'object') {
// backwards compatibility
config = {
duration: config,
lazy: arguments[1]
};
}
updateConfig(me);
// plugins options references might have change, let's invalidate the cache
// https://github.com/chartjs/Chart.js/issues/5111#issuecomment-355934167
plugins._invalidate(me);
if (plugins.notify(me, 'beforeUpdate') === false) {
return;
}
// In case the entire data object changed
me.tooltip._data = me.data;
// Make sure dataset controllers are updated and new controllers are reset
var newControllers = me.buildOrUpdateControllers();
// Make sure all dataset controllers have correct meta data counts
helpers.each(me.data.datasets, function(dataset, datasetIndex) {
me.getDatasetMeta(datasetIndex).controller.buildOrUpdateElements();
}, me);
me.updateLayout();
// Can only reset the new controllers after the scales have been updated
if (me.options.animation && me.options.animation.duration) {
helpers.each(newControllers, function(controller) {
controller.reset();
});
}
me.updateDatasets();
// Need to reset tooltip in case it is displayed with elements that are removed
// after update.
me.tooltip.initialize();
// Last active contains items that were previously in the tooltip.
// When we reset the tooltip, we need to clear it
me.lastActive = [];
// Do this before render so that any plugins that need final scale updates can use it
plugins.notify(me, 'afterUpdate');
if (me._bufferedRender) {
me._bufferedRequest = {
duration: config.duration,
easing: config.easing,
lazy: config.lazy
};
} else {
me.render(config);
}
},
/**
* Updates the chart layout unless a plugin returns `false` to the `beforeLayout`
* hook, in which case, plugins will not be called on `afterLayout`.
* @private
*/
updateLayout: function() {
var me = this;
if (plugins.notify(me, 'beforeLayout') === false) {
return;
}
layouts.update(this, this.width, this.height);
/**
* Provided for backward compatibility, use `afterLayout` instead.
* @method IPlugin#afterScaleUpdate
* @deprecated since version 2.5.0
* @todo remove at version 3
* @private
*/
plugins.notify(me, 'afterScaleUpdate');
plugins.notify(me, 'afterLayout');
},
/**
* Updates all datasets unless a plugin returns `false` to the `beforeDatasetsUpdate`
* hook, in which case, plugins will not be called on `afterDatasetsUpdate`.
* @private
*/
updateDatasets: function() {
var me = this;
if (plugins.notify(me, 'beforeDatasetsUpdate') === false) {
return;
}
for (var i = 0, ilen = me.data.datasets.length; i < ilen; ++i) {
me.updateDataset(i);
}
plugins.notify(me, 'afterDatasetsUpdate');
},
/**
* Updates dataset at index unless a plugin returns `false` to the `beforeDatasetUpdate`
* hook, in which case, plugins will not be called on `afterDatasetUpdate`.
* @private
*/
updateDataset: function(index) {
var me = this;
var meta = me.getDatasetMeta(index);
var args = {
meta: meta,
index: index
};
if (plugins.notify(me, 'beforeDatasetUpdate', [args]) === false) {
return;
}
meta.controller.update();
plugins.notify(me, 'afterDatasetUpdate', [args]);
},
render: function(config) {
var me = this;
if (!config || typeof config !== 'object') {
// backwards compatibility
config = {
duration: config,
lazy: arguments[1]
};
}
var duration = config.duration;
var lazy = config.lazy;
if (plugins.notify(me, 'beforeRender') === false) {
return;
}
var animationOptions = me.options.animation;
var onComplete = function(animation) {
plugins.notify(me, 'afterRender');
helpers.callback(animationOptions && animationOptions.onComplete, [animation], me);
};
if (animationOptions && ((typeof duration !== 'undefined' && duration !== 0) || (typeof duration === 'undefined' && animationOptions.duration !== 0))) {
var animation = new Chart.Animation({
numSteps: (duration || animationOptions.duration) / 16.66, // 60 fps
easing: config.easing || animationOptions.easing,
render: function(chart, animationObject) {
var easingFunction = helpers.easing.effects[animationObject.easing];
var currentStep = animationObject.currentStep;
var stepDecimal = currentStep / animationObject.numSteps;
chart.draw(easingFunction(stepDecimal), stepDecimal, currentStep);
},
onAnimationProgress: animationOptions.onProgress,
onAnimationComplete: onComplete
});
Chart.animationService.addAnimation(me, animation, duration, lazy);
} else {
me.draw();
// See https://github.com/chartjs/Chart.js/issues/3781
onComplete(new Chart.Animation({numSteps: 0, chart: me}));
}
return me;
},
draw: function(easingValue) {
var me = this;
me.clear();
if (helpers.isNullOrUndef(easingValue)) {
easingValue = 1;
}
me.transition(easingValue);
if (plugins.notify(me, 'beforeDraw', [easingValue]) === false) {
return;
}
// Draw all the scales
helpers.each(me.boxes, function(box) {
box.draw(me.chartArea);
}, me);
if (me.scale) {
me.scale.draw();
}
me.drawDatasets(easingValue);
me._drawTooltip(easingValue);
plugins.notify(me, 'afterDraw', [easingValue]);
},
/**
* @private
*/
transition: function(easingValue) {
var me = this;
for (var i = 0, ilen = (me.data.datasets || []).length; i < ilen; ++i) {
if (me.isDatasetVisible(i)) {
me.getDatasetMeta(i).controller.transition(easingValue);
}
}
me.tooltip.transition(easingValue);
},
/**
* Draws all datasets unless a plugin returns `false` to the `beforeDatasetsDraw`
* hook, in which case, plugins will not be called on `afterDatasetsDraw`.
* @private
*/
drawDatasets: function(easingValue) {
var me = this;
if (plugins.notify(me, 'beforeDatasetsDraw', [easingValue]) === false) {
return;
}
// Draw datasets reversed to support proper line stacking
for (var i = (me.data.datasets || []).length - 1; i >= 0; --i) {
if (me.isDatasetVisible(i)) {
me.drawDataset(i, easingValue);
}
}
plugins.notify(me, 'afterDatasetsDraw', [easingValue]);
},
/**
* Draws dataset at index unless a plugin returns `false` to the `beforeDatasetDraw`
* hook, in which case, plugins will not be called on `afterDatasetDraw`.
* @private
*/
drawDataset: function(index, easingValue) {
var me = this;
var meta = me.getDatasetMeta(index);
var args = {
meta: meta,
index: index,
easingValue: easingValue
};
if (plugins.notify(me, 'beforeDatasetDraw', [args]) === false) {
return;
}
meta.controller.draw(easingValue);
plugins.notify(me, 'afterDatasetDraw', [args]);
},
/**
* Draws tooltip unless a plugin returns `false` to the `beforeTooltipDraw`
* hook, in which case, plugins will not be called on `afterTooltipDraw`.
* @private
*/
_drawTooltip: function(easingValue) {
var me = this;
var tooltip = me.tooltip;
var args = {
tooltip: tooltip,
easingValue: easingValue
};
if (plugins.notify(me, 'beforeTooltipDraw', [args]) === false) {
return;
}
tooltip.draw();
plugins.notify(me, 'afterTooltipDraw', [args]);
},
// Get the single element that was clicked on
// @return : An object containing the dataset index and element index of the matching element. Also contains the rectangle that was draw
getElementAtEvent: function(e) {
return Interaction.modes.single(this, e);
},
getElementsAtEvent: function(e) {
return Interaction.modes.label(this, e, {intersect: true});
},
getElementsAtXAxis: function(e) {
return Interaction.modes['x-axis'](this, e, {intersect: true});
},
getElementsAtEventForMode: function(e, mode, options) {
var method = Interaction.modes[mode];
if (typeof method === 'function') {
return method(this, e, options);
}
return [];
},
getDatasetAtEvent: function(e) {
return Interaction.modes.dataset(this, e, {intersect: true});
},
getDatasetMeta: function(datasetIndex) {
var me = this;
var dataset = me.data.datasets[datasetIndex];
if (!dataset._meta) {
dataset._meta = {};
}
var meta = dataset._meta[me.id];
if (!meta) {
meta = dataset._meta[me.id] = {
type: null,
data: [],
dataset: null,
controller: null,
hidden: null, // See isDatasetVisible() comment
xAxisID: null,
yAxisID: null
};
}
return meta;
},
getVisibleDatasetCount: function() {
var count = 0;
for (var i = 0, ilen = this.data.datasets.length; i < ilen; ++i) {
if (this.isDatasetVisible(i)) {
count++;
}
}
return count;
},
isDatasetVisible: function(datasetIndex) {
var meta = this.getDatasetMeta(datasetIndex);
// meta.hidden is a per chart dataset hidden flag override with 3 states: if true or false,
// the dataset.hidden value is ignored, else if null, the dataset hidden state is returned.
return typeof meta.hidden === 'boolean' ? !meta.hidden : !this.data.datasets[datasetIndex].hidden;
},
generateLegend: function() {
return this.options.legendCallback(this);
},
/**
* @private
*/
destroyDatasetMeta: function(datasetIndex) {
var id = this.id;
var dataset = this.data.datasets[datasetIndex];
var meta = dataset._meta && dataset._meta[id];
if (meta) {
meta.controller.destroy();
delete dataset._meta[id];
}
},
destroy: function() {
var me = this;
var canvas = me.canvas;
var i, ilen;
me.stop();
// dataset controllers need to cleanup associated data
for (i = 0, ilen = me.data.datasets.length; i < ilen; ++i) {
me.destroyDatasetMeta(i);
}
if (canvas) {
me.unbindEvents();
helpers.canvas.clear(me);
platform.releaseContext(me.ctx);
me.canvas = null;
me.ctx = null;
}
plugins.notify(me, 'destroy');
delete Chart.instances[me.id];
},
toBase64Image: function() {
return this.canvas.toDataURL.apply(this.canvas, arguments);
},
initToolTip: function() {
var me = this;
me.tooltip = new Chart.Tooltip({
_chart: me,
_chartInstance: me, // deprecated, backward compatibility
_data: me.data,
_options: me.options.tooltips
}, me);
},
/**
* @private
*/
bindEvents: function() {
var me = this;
var listeners = me._listeners = {};
var listener = function() {
me.eventHandler.apply(me, arguments);
};
helpers.each(me.options.events, function(type) {
platform.addEventListener(me, type, listener);
listeners[type] = listener;
});
// Elements used to detect size change should not be injected for non responsive charts.
// See https://github.com/chartjs/Chart.js/issues/2210
if (me.options.responsive) {
listener = function() {
me.resize();
};
platform.addEventListener(me, 'resize', listener);
listeners.resize = listener;
}
},
/**
* @private
*/
unbindEvents: function() {
var me = this;
var listeners = me._listeners;
if (!listeners) {
return;
}
delete me._listeners;
helpers.each(listeners, function(listener, type) {
platform.removeEventListener(me, type, listener);
});
},
updateHoverStyle: function(elements, mode, enabled) {
var method = enabled ? 'setHoverStyle' : 'removeHoverStyle';
var element, i, ilen;
for (i = 0, ilen = elements.length; i < ilen; ++i) {
element = elements[i];
if (element) {
this.getDatasetMeta(element._datasetIndex).controller[method](element);
}
}
},
/**
* @private
*/
eventHandler: function(e) {
var me = this;
var tooltip = me.tooltip;
if (plugins.notify(me, 'beforeEvent', [e]) === false) {
return;
}
// Buffer any update calls so that renders do not occur
me._bufferedRender = true;
me._bufferedRequest = null;
var changed = me.handleEvent(e);
// for smooth tooltip animations issue #4989
// the tooltip should be the source of change
// Animation check workaround:
// tooltip._start will be null when tooltip isn't animating
if (tooltip) {
changed = tooltip._start
? tooltip.handleEvent(e)
: changed | tooltip.handleEvent(e);
}
plugins.notify(me, 'afterEvent', [e]);
var bufferedRequest = me._bufferedRequest;
if (bufferedRequest) {
// If we have an update that was triggered, we need to do a normal render
me.render(bufferedRequest);
} else if (changed && !me.animating) {
// If entering, leaving, or changing elements, animate the change via pivot
me.stop();
// We only need to render at this point. Updating will cause scales to be
// recomputed generating flicker & using more memory than necessary.
me.render(me.options.hover.animationDuration, true);
}
me._bufferedRender = false;
me._bufferedRequest = null;
return me;
},
/**
* Handle an event
* @private
* @param {IEvent} event the event to handle
* @return {Boolean} true if the chart needs to re-render
*/
handleEvent: function(e) {
var me = this;
var options = me.options || {};
var hoverOptions = options.hover;
var changed = false;
me.lastActive = me.lastActive || [];
// Find Active Elements for hover and tooltips
if (e.type === 'mouseout') {
me.active = [];
} else {
me.active = me.getElementsAtEventForMode(e, hoverOptions.mode, hoverOptions);
}
// Invoke onHover hook
// Need to call with native event here to not break backwards compatibility
helpers.callback(options.onHover || options.hover.onHover, [e.native, me.active], me);
if (e.type === 'mouseup' || e.type === 'click') {
if (options.onClick) {
// Use e.native here for backwards compatibility
options.onClick.call(me, e.native, me.active);
}
}
// Remove styling for last active (even if it may still be active)
if (me.lastActive.length) {
me.updateHoverStyle(me.lastActive, hoverOptions.mode, false);
}
// Built in hover styling
if (me.active.length && hoverOptions.mode) {
me.updateHoverStyle(me.active, hoverOptions.mode, true);
}
changed = !helpers.arrayEquals(me.active, me.lastActive);
// Remember Last Actives
me.lastActive = me.active;
return changed;
}
});
/**
* Provided for backward compatibility, use Chart instead.
* @class Chart.Controller
* @deprecated since version 2.6.0
* @todo remove at version 3
* @private
*/
Chart.Controller = Chart;
};
},{"25":25,"28":28,"30":30,"31":31,"45":45,"48":48}],24:[function(require,module,exports){
'use strict';
var helpers = require(45);
module.exports = function(Chart) {
var arrayEvents = ['push', 'pop', 'shift', 'splice', 'unshift'];
/**
* Hooks the array methods that add or remove values ('push', pop', 'shift', 'splice',
* 'unshift') and notify the listener AFTER the array has been altered. Listeners are
* called on the 'onData*' callbacks (e.g. onDataPush, etc.) with same arguments.
*/
function listenArrayEvents(array, listener) {
if (array._chartjs) {
array._chartjs.listeners.push(listener);
return;
}
Object.defineProperty(array, '_chartjs', {
configurable: true,
enumerable: false,
value: {
listeners: [listener]
}
});
arrayEvents.forEach(function(key) {
var method = 'onData' + key.charAt(0).toUpperCase() + key.slice(1);
var base = array[key];
Object.defineProperty(array, key, {
configurable: true,
enumerable: false,
value: function() {
var args = Array.prototype.slice.call(arguments);
var res = base.apply(this, args);
helpers.each(array._chartjs.listeners, function(object) {
if (typeof object[method] === 'function') {
object[method].apply(object, args);
}
});
return res;
}
});
});
}
/**
* Removes the given array event listener and cleanup extra attached properties (such as
* the _chartjs stub and overridden methods) if array doesn't have any more listeners.
*/
function unlistenArrayEvents(array, listener) {
var stub = array._chartjs;
if (!stub) {
return;
}
var listeners = stub.listeners;
var index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
}
if (listeners.length > 0) {
return;
}
arrayEvents.forEach(function(key) {
delete array[key];
});
delete array._chartjs;
}
// Base class for all dataset controllers (line, bar, etc)
Chart.DatasetController = function(chart, datasetIndex) {
this.initialize(chart, datasetIndex);
};
helpers.extend(Chart.DatasetController.prototype, {
/**
* Element type used to generate a meta dataset (e.g. Chart.element.Line).
* @type {Chart.core.element}
*/
datasetElementType: null,
/**
* Element type used to generate a meta data (e.g. Chart.element.Point).
* @type {Chart.core.element}
*/
dataElementType: null,
initialize: function(chart, datasetIndex) {
var me = this;
me.chart = chart;
me.index = datasetIndex;
me.linkScales();
me.addElements();
},
updateIndex: function(datasetIndex) {
this.index = datasetIndex;
},
linkScales: function() {
var me = this;
var meta = me.getMeta();
var dataset = me.getDataset();
if (meta.xAxisID === null || !(meta.xAxisID in me.chart.scales)) {
meta.xAxisID = dataset.xAxisID || me.chart.options.scales.xAxes[0].id;
}
if (meta.yAxisID === null || !(meta.yAxisID in me.chart.scales)) {
meta.yAxisID = dataset.yAxisID || me.chart.options.scales.yAxes[0].id;
}
},
getDataset: function() {
return this.chart.data.datasets[this.index];
},
getMeta: function() {
return this.chart.getDatasetMeta(this.index);
},
getScaleForId: function(scaleID) {
return this.chart.scales[scaleID];
},
reset: function() {
this.update(true);
},
/**
* @private
*/
destroy: function() {
if (this._data) {
unlistenArrayEvents(this._data, this);
}
},
createMetaDataset: function() {
var me = this;
var type = me.datasetElementType;
return type && new type({
_chart: me.chart,
_datasetIndex: me.index
});
},
createMetaData: function(index) {
var me = this;
var type = me.dataElementType;
return type && new type({
_chart: me.chart,
_datasetIndex: me.index,
_index: index
});
},
addElements: function() {
var me = this;
var meta = me.getMeta();
var data = me.getDataset().data || [];
var metaData = meta.data;
var i, ilen;
for (i = 0, ilen = data.length; i < ilen; ++i) {
metaData[i] = metaData[i] || me.createMetaData(i);
}
meta.dataset = meta.dataset || me.createMetaDataset();
},
addElementAndReset: function(index) {
var element = this.createMetaData(index);
this.getMeta().data.splice(index, 0, element);
this.updateElement(element, index, true);
},
buildOrUpdateElements: function() {
var me = this;
var dataset = me.getDataset();
var data = dataset.data || (dataset.data = []);
// In order to correctly handle data addition/deletion animation (an thus simulate
// real-time charts), we need to monitor these data modifications and synchronize
// the internal meta data accordingly.
if (me._data !== data) {
if (me._data) {
// This case happens when the user replaced the data array instance.
unlistenArrayEvents(me._data, me);
}
listenArrayEvents(data, me);
me._data = data;
}
// Re-sync meta data in case the user replaced the data array or if we missed
// any updates and so make sure that we handle number of datapoints changing.
me.resyncElements();
},
update: helpers.noop,
transition: function(easingValue) {
var meta = this.getMeta();
var elements = meta.data || [];
var ilen = elements.length;
var i = 0;
for (; i < ilen; ++i) {
elements[i].transition(easingValue);
}
if (meta.dataset) {
meta.dataset.transition(easingValue);
}
},
draw: function() {
var meta = this.getMeta();
var elements = meta.data || [];
var ilen = elements.length;
var i = 0;
if (meta.dataset) {
meta.dataset.draw();
}
for (; i < ilen; ++i) {
elements[i].draw();
}
},
removeHoverStyle: function(element, elementOpts) {
var dataset = this.chart.data.datasets[element._datasetIndex];
var index = element._index;
var custom = element.custom || {};
var valueOrDefault = helpers.valueAtIndexOrDefault;
var model = element._model;
model.backgroundColor = custom.backgroundColor ? custom.backgroundColor : valueOrDefault(dataset.backgroundColor, index, elementOpts.backgroundColor);
model.borderColor = custom.borderColor ? custom.borderColor : valueOrDefault(dataset.borderColor, index, elementOpts.borderColor);
model.borderWidth = custom.borderWidth ? custom.borderWidth : valueOrDefault(dataset.borderWidth, index, elementOpts.borderWidth);
},
setHoverStyle: function(element) {
var dataset = this.chart.data.datasets[element._datasetIndex];
var index = element._index;
var custom = element.custom || {};
var valueOrDefault = helpers.valueAtIndexOrDefault;
var getHoverColor = helpers.getHoverColor;
var model = element._model;
model.backgroundColor = custom.hoverBackgroundColor ? custom.hoverBackgroundColor : valueOrDefault(dataset.hoverBackgroundColor, index, getHoverColor(model.backgroundColor));
model.borderColor = custom.hoverBorderColor ? custom.hoverBorderColor : valueOrDefault(dataset.hoverBorderColor, index, getHoverColor(model.borderColor));
model.borderWidth = custom.hoverBorderWidth ? custom.hoverBorderWidth : valueOrDefault(dataset.hoverBorderWidth, index, model.borderWidth);
},
/**
* @private
*/
resyncElements: function() {
var me = this;
var meta = me.getMeta();
var data = me.getDataset().data;
var numMeta = meta.data.length;
var numData = data.length;
if (numData < numMeta) {
meta.data.splice(numData, numMeta - numData);
} else if (numData > numMeta) {
me.insertElements(numMeta, numData - numMeta);
}
},
/**
* @private
*/
insertElements: function(start, count) {
for (var i = 0; i < count; ++i) {
this.addElementAndReset(start + i);
}
},
/**
* @private
*/
onDataPush: function() {
this.insertElements(this.getDataset().data.length - 1, arguments.length);
},
/**
* @private
*/
onDataPop: function() {
this.getMeta().data.pop();
},
/**
* @private
*/
onDataShift: function() {
this.getMeta().data.shift();
},
/**
* @private
*/
onDataSplice: function(start, count) {
this.getMeta().data.splice(start, count);
this.insertElements(start, arguments.length - 2);
},
/**
* @private
*/
onDataUnshift: function() {
this.insertElements(0, arguments.length);
}
});
Chart.DatasetController.extend = helpers.inherits;
};
},{"45":45}],25:[function(require,module,exports){
'use strict';
var helpers = require(45);
module.exports = {
/**
* @private
*/
_set: function(scope, values) {
return helpers.merge(this[scope] || (this[scope] = {}), values);
}
};
},{"45":45}],26:[function(require,module,exports){
'use strict';
var color = require(3);
var helpers = require(45);
function interpolate(start, view, model, ease) {
var keys = Object.keys(model);
var i, ilen, key, actual, origin, target, type, c0, c1;
for (i = 0, ilen = keys.length; i < ilen; ++i) {
key = keys[i];
target = model[key];
// if a value is added to the model after pivot() has been called, the view
// doesn't contain it, so let's initialize the view to the target value.
if (!view.hasOwnProperty(key)) {
view[key] = target;
}
actual = view[key];
if (actual === target || key[0] === '_') {
continue;
}
if (!start.hasOwnProperty(key)) {
start[key] = actual;
}
origin = start[key];
type = typeof target;
if (type === typeof origin) {
if (type === 'string') {
c0 = color(origin);
if (c0.valid) {
c1 = color(target);
if (c1.valid) {
view[key] = c1.mix(c0, ease).rgbString();
continue;
}
}
} else if (type === 'number' && isFinite(origin) && isFinite(target)) {
view[key] = origin + (target - origin) * ease;
continue;
}
}
view[key] = target;
}
}
var Element = function(configuration) {
helpers.extend(this, configuration);
this.initialize.apply(this, arguments);
};
helpers.extend(Element.prototype, {
initialize: function() {
this.hidden = false;
},
pivot: function() {
var me = this;
if (!me._view) {
me._view = helpers.clone(me._model);
}
me._start = {};
return me;
},
transition: function(ease) {
var me = this;
var model = me._model;
var start = me._start;
var view = me._view;
// No animation -> No Transition
if (!model || ease === 1) {
me._view = model;
me._start = null;
return me;
}
if (!view) {
view = me._view = {};
}
if (!start) {
start = me._start = {};
}
interpolate(start, view, model, ease);
return me;
},
tooltipPosition: function() {
return {
x: this._model.x,
y: this._model.y
};
},
hasValue: function() {
return helpers.isNumber(this._model.x) && helpers.isNumber(this._model.y);
}
});
Element.extend = helpers.inherits;
module.exports = Element;
},{"3":3,"45":45}],27:[function(require,module,exports){
/* global window: false */
/* global document: false */
'use strict';
var color = require(3);
var defaults = require(25);
var helpers = require(45);
module.exports = function(Chart) {
// -- Basic js utility methods
helpers.configMerge = function(/* objects ... */) {
return helpers.merge(helpers.clone(arguments[0]), [].slice.call(arguments, 1), {
merger: function(key, target, source, options) {
var tval = target[key] || {};
var sval = source[key];
if (key === 'scales') {
// scale config merging is complex. Add our own function here for that
target[key] = helpers.scaleMerge(tval, sval);
} else if (key === 'scale') {
// used in polar area & radar charts since there is only one scale
target[key] = helpers.merge(tval, [Chart.scaleService.getScaleDefaults(sval.type), sval]);
} else {
helpers._merger(key, target, source, options);
}
}
});
};
helpers.scaleMerge = function(/* objects ... */) {
return helpers.merge(helpers.clone(arguments[0]), [].slice.call(arguments, 1), {
merger: function(key, target, source, options) {
if (key === 'xAxes' || key === 'yAxes') {
var slen = source[key].length;
var i, type, scale;
if (!target[key]) {
target[key] = [];
}
for (i = 0; i < slen; ++i) {
scale = source[key][i];
type = helpers.valueOrDefault(scale.type, key === 'xAxes' ? 'category' : 'linear');
if (i >= target[key].length) {
target[key].push({});
}
if (!target[key][i].type || (scale.type && scale.type !== target[key][i].type)) {
// new/untyped scale or type changed: let's apply the new defaults
// then merge source scale to correctly overwrite the defaults.
helpers.merge(target[key][i], [Chart.scaleService.getScaleDefaults(type), scale]);
} else {
// scales type are the same
helpers.merge(target[key][i], scale);
}
}
} else {
helpers._merger(key, target, source, options);
}
}
});
};
helpers.where = function(collection, filterCallback) {
if (helpers.isArray(collection) && Array.prototype.filter) {
return collection.filter(filterCallback);
}
var filtered = [];
helpers.each(collection, function(item) {
if (filterCallback(item)) {
filtered.push(item);
}
});
return filtered;
};
helpers.findIndex = Array.prototype.findIndex ?
function(array, callback, scope) {
return array.findIndex(callback, scope);
} :
function(array, callback, scope) {
scope = scope === undefined ? array : scope;
for (var i = 0, ilen = array.length; i < ilen; ++i) {
if (callback.call(scope, array[i], i, array)) {
return i;
}
}
return -1;
};
helpers.findNextWhere = function(arrayToSearch, filterCallback, startIndex) {
// Default to start of the array
if (helpers.isNullOrUndef(startIndex)) {
startIndex = -1;
}
for (var i = startIndex + 1; i < arrayToSearch.length; i++) {
var currentItem = arrayToSearch[i];
if (filterCallback(currentItem)) {
return currentItem;
}
}
};
helpers.findPreviousWhere = function(arrayToSearch, filterCallback, startIndex) {
// Default to end of the array
if (helpers.isNullOrUndef(startIndex)) {
startIndex = arrayToSearch.length;
}
for (var i = startIndex - 1; i >= 0; i--) {
var currentItem = arrayToSearch[i];
if (filterCallback(currentItem)) {
return currentItem;
}
}
};
// -- Math methods
helpers.isNumber = function(n) {
return !isNaN(parseFloat(n)) && isFinite(n);
};
helpers.almostEquals = function(x, y, epsilon) {
return Math.abs(x - y) < epsilon;
};
helpers.almostWhole = function(x, epsilon) {
var rounded = Math.round(x);
return (((rounded - epsilon) < x) && ((rounded + epsilon) > x));
};
helpers.max = function(array) {
return array.reduce(function(max, value) {
if (!isNaN(value)) {
return Math.max(max, value);
}
return max;
}, Number.NEGATIVE_INFINITY);
};
helpers.min = function(array) {
return array.reduce(function(min, value) {
if (!isNaN(value)) {
return Math.min(min, value);
}
return min;
}, Number.POSITIVE_INFINITY);
};
helpers.sign = Math.sign ?
function(x) {
return Math.sign(x);
} :
function(x) {
x = +x; // convert to a number
if (x === 0 || isNaN(x)) {
return x;
}
return x > 0 ? 1 : -1;
};
helpers.log10 = Math.log10 ?
function(x) {
return Math.log10(x);
} :
function(x) {
var exponent = Math.log(x) * Math.LOG10E; // Math.LOG10E = 1 / Math.LN10.
// Check for whole powers of 10,
// which due to floating point rounding error should be corrected.
var powerOf10 = Math.round(exponent);
var isPowerOf10 = x === Math.pow(10, powerOf10);
return isPowerOf10 ? powerOf10 : exponent;
};
helpers.toRadians = function(degrees) {
return degrees * (Math.PI / 180);
};
helpers.toDegrees = function(radians) {
return radians * (180 / Math.PI);
};
// Gets the angle from vertical upright to the point about a centre.
helpers.getAngleFromPoint = function(centrePoint, anglePoint) {
var distanceFromXCenter = anglePoint.x - centrePoint.x;
var distanceFromYCenter = anglePoint.y - centrePoint.y;
var radialDistanceFromCenter = Math.sqrt(distanceFromXCenter * distanceFromXCenter + distanceFromYCenter * distanceFromYCenter);
var angle = Math.atan2(distanceFromYCenter, distanceFromXCenter);
if (angle < (-0.5 * Math.PI)) {
angle += 2.0 * Math.PI; // make sure the returned angle is in the range of (-PI/2, 3PI/2]
}
return {
angle: angle,
distance: radialDistanceFromCenter
};
};
helpers.distanceBetweenPoints = function(pt1, pt2) {
return Math.sqrt(Math.pow(pt2.x - pt1.x, 2) + Math.pow(pt2.y - pt1.y, 2));
};
helpers.aliasPixel = function(pixelWidth) {
return (pixelWidth % 2 === 0) ? 0 : 0.5;
};
helpers.splineCurve = function(firstPoint, middlePoint, afterPoint, t) {
// Props to Rob Spencer at scaled innovation for his post on splining between points
// http://scaledinnovation.com/analytics/splines/aboutSplines.html
// This function must also respect "skipped" points
var previous = firstPoint.skip ? middlePoint : firstPoint;
var current = middlePoint;
var next = afterPoint.skip ? middlePoint : afterPoint;
var d01 = Math.sqrt(Math.pow(current.x - previous.x, 2) + Math.pow(current.y - previous.y, 2));
var d12 = Math.sqrt(Math.pow(next.x - current.x, 2) + Math.pow(next.y - current.y, 2));
var s01 = d01 / (d01 + d12);
var s12 = d12 / (d01 + d12);
// If all points are the same, s01 & s02 will be inf
s01 = isNaN(s01) ? 0 : s01;
s12 = isNaN(s12) ? 0 : s12;
var fa = t * s01; // scaling factor for triangle Ta
var fb = t * s12;
return {
previous: {
x: current.x - fa * (next.x - previous.x),
y: current.y - fa * (next.y - previous.y)
},
next: {
x: current.x + fb * (next.x - previous.x),
y: current.y + fb * (next.y - previous.y)
}
};
};
helpers.EPSILON = Number.EPSILON || 1e-14;
helpers.splineCurveMonotone = function(points) {
// This function calculates Bézier control points in a similar way than |splineCurve|,
// but preserves monotonicity of the provided data and ensures no local extremums are added
// between the dataset discrete points due to the interpolation.
// See : https://en.wikipedia.org/wiki/Monotone_cubic_interpolation
var pointsWithTangents = (points || []).map(function(point) {
return {
model: point._model,
deltaK: 0,
mK: 0
};
});
// Calculate slopes (deltaK) and initialize tangents (mK)
var pointsLen = pointsWithTangents.length;
var i, pointBefore, pointCurrent, pointAfter;
for (i = 0; i < pointsLen; ++i) {
pointCurrent = pointsWithTangents[i];
if (pointCurrent.model.skip) {
continue;
}
pointBefore = i > 0 ? pointsWithTangents[i - 1] : null;
pointAfter = i < pointsLen - 1 ? pointsWithTangents[i + 1] : null;
if (pointAfter && !pointAfter.model.skip) {
var slopeDeltaX = (pointAfter.model.x - pointCurrent.model.x);
// In the case of two points that appear at the same x pixel, slopeDeltaX is 0
pointCurrent.deltaK = slopeDeltaX !== 0 ? (pointAfter.model.y - pointCurrent.model.y) / slopeDeltaX : 0;
}
if (!pointBefore || pointBefore.model.skip) {
pointCurrent.mK = pointCurrent.deltaK;
} else if (!pointAfter || pointAfter.model.skip) {
pointCurrent.mK = pointBefore.deltaK;
} else if (this.sign(pointBefore.deltaK) !== this.sign(pointCurrent.deltaK)) {
pointCurrent.mK = 0;
} else {
pointCurrent.mK = (pointBefore.deltaK + pointCurrent.deltaK) / 2;
}
}
// Adjust tangents to ensure monotonic properties
var alphaK, betaK, tauK, squaredMagnitude;
for (i = 0; i < pointsLen - 1; ++i) {
pointCurrent = pointsWithTangents[i];
pointAfter = pointsWithTangents[i + 1];
if (pointCurrent.model.skip || pointAfter.model.skip) {
continue;
}
if (helpers.almostEquals(pointCurrent.deltaK, 0, this.EPSILON)) {
pointCurrent.mK = pointAfter.mK = 0;
continue;
}
alphaK = pointCurrent.mK / pointCurrent.deltaK;
betaK = pointAfter.mK / pointCurrent.deltaK;
squaredMagnitude = Math.pow(alphaK, 2) + Math.pow(betaK, 2);
if (squaredMagnitude <= 9) {
continue;
}
tauK = 3 / Math.sqrt(squaredMagnitude);
pointCurrent.mK = alphaK * tauK * pointCurrent.deltaK;
pointAfter.mK = betaK * tauK * pointCurrent.deltaK;
}
// Compute control points
var deltaX;
for (i = 0; i < pointsLen; ++i) {
pointCurrent = pointsWithTangents[i];
if (pointCurrent.model.skip) {
continue;
}
pointBefore = i > 0 ? pointsWithTangents[i - 1] : null;
pointAfter = i < pointsLen - 1 ? pointsWithTangents[i + 1] : null;
if (pointBefore && !pointBefore.model.skip) {
deltaX = (pointCurrent.model.x - pointBefore.model.x) / 3;
pointCurrent.model.controlPointPreviousX = pointCurrent.model.x - deltaX;
pointCurrent.model.controlPointPreviousY = pointCurrent.model.y - deltaX * pointCurrent.mK;
}
if (pointAfter && !pointAfter.model.skip) {
deltaX = (pointAfter.model.x - pointCurrent.model.x) / 3;
pointCurrent.model.controlPointNextX = pointCurrent.model.x + deltaX;
pointCurrent.model.controlPointNextY = pointCurrent.model.y + deltaX * pointCurrent.mK;
}
}
};
helpers.nextItem = function(collection, index, loop) {
if (loop) {
return index >= collection.length - 1 ? collection[0] : collection[index + 1];
}
return index >= collection.length - 1 ? collection[collection.length - 1] : collection[index + 1];
};
helpers.previousItem = function(collection, index, loop) {
if (loop) {
return index <= 0 ? collection[collection.length - 1] : collection[index - 1];
}
return index <= 0 ? collection[0] : collection[index - 1];
};
// Implementation of the nice number algorithm used in determining where axis labels will go
helpers.niceNum = function(range, round) {
var exponent = Math.floor(helpers.log10(range));
var fraction = range / Math.pow(10, exponent);
var niceFraction;
if (round) {
if (fraction < 1.5) {
niceFraction = 1;
} else if (fraction < 3) {
niceFraction = 2;
} else if (fraction < 7) {
niceFraction = 5;
} else {
niceFraction = 10;
}
} else if (fraction <= 1.0) {
niceFraction = 1;
} else if (fraction <= 2) {
niceFraction = 2;
} else if (fraction <= 5) {
niceFraction = 5;
} else {
niceFraction = 10;
}
return niceFraction * Math.pow(10, exponent);
};
// Request animation polyfill - http://www.paulirish.com/2011/requestanimationframe-for-smart-animating/
helpers.requestAnimFrame = (function() {
if (typeof window === 'undefined') {
return function(callback) {
callback();
};
}
return window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame ||
window.msRequestAnimationFrame ||
function(callback) {
return window.setTimeout(callback, 1000 / 60);
};
}());
// -- DOM methods
helpers.getRelativePosition = function(evt, chart) {
var mouseX, mouseY;
var e = evt.originalEvent || evt;
var canvas = evt.currentTarget || evt.srcElement;
var boundingRect = canvas.getBoundingClientRect();
var touches = e.touches;
if (touches && touches.length > 0) {
mouseX = touches[0].clientX;
mouseY = touches[0].clientY;
} else {
mouseX = e.clientX;
mouseY = e.clientY;
}
// Scale mouse coordinates into canvas coordinates
// by following the pattern laid out by 'jerryj' in the comments of
// http://www.html5canvastutorials.com/advanced/html5-canvas-mouse-coordinates/
var paddingLeft = parseFloat(helpers.getStyle(canvas, 'padding-left'));
var paddingTop = parseFloat(helpers.getStyle(canvas, 'padding-top'));
var paddingRight = parseFloat(helpers.getStyle(canvas, 'padding-right'));
var paddingBottom = parseFloat(helpers.getStyle(canvas, 'padding-bottom'));
var width = boundingRect.right - boundingRect.left - paddingLeft - paddingRight;
var height = boundingRect.bottom - boundingRect.top - paddingTop - paddingBottom;
// We divide by the current device pixel ratio, because the canvas is scaled up by that amount in each direction. However
// the backend model is in unscaled coordinates. Since we are going to deal with our model coordinates, we go back here
mouseX = Math.round((mouseX - boundingRect.left - paddingLeft) / (width) * canvas.width / chart.currentDevicePixelRatio);
mouseY = Math.round((mouseY - boundingRect.top - paddingTop) / (height) * canvas.height / chart.currentDevicePixelRatio);
return {
x: mouseX,
y: mouseY
};
};
// Private helper function to convert max-width/max-height values that may be percentages into a number
function parseMaxStyle(styleValue, node, parentProperty) {
var valueInPixels;
if (typeof styleValue === 'string') {
valueInPixels = parseInt(styleValue, 10);
if (styleValue.indexOf('%') !== -1) {
// percentage * size in dimension
valueInPixels = valueInPixels / 100 * node.parentNode[parentProperty];
}
} else {
valueInPixels = styleValue;
}
return valueInPixels;
}
/**
* Returns if the given value contains an effective constraint.
* @private
*/
function isConstrainedValue(value) {
return value !== undefined && value !== null && value !== 'none';
}
// Private helper to get a constraint dimension
// @param domNode : the node to check the constraint on
// @param maxStyle : the style that defines the maximum for the direction we are using (maxWidth / maxHeight)
// @param percentageProperty : property of parent to use when calculating width as a percentage
// @see http://www.nathanaeljones.com/blog/2013/reading-max-width-cross-browser
function getConstraintDimension(domNode, maxStyle, percentageProperty) {
var view = document.defaultView;
var parentNode = domNode.parentNode;
var constrainedNode = view.getComputedStyle(domNode)[maxStyle];
var constrainedContainer = view.getComputedStyle(parentNode)[maxStyle];
var hasCNode = isConstrainedValue(constrainedNode);
var hasCContainer = isConstrainedValue(constrainedContainer);
var infinity = Number.POSITIVE_INFINITY;
if (hasCNode || hasCContainer) {
return Math.min(
hasCNode ? parseMaxStyle(constrainedNode, domNode, percentageProperty) : infinity,
hasCContainer ? parseMaxStyle(constrainedContainer, parentNode, percentageProperty) : infinity);
}
return 'none';
}
// returns Number or undefined if no constraint
helpers.getConstraintWidth = function(domNode) {
return getConstraintDimension(domNode, 'max-width', 'clientWidth');
};
// returns Number or undefined if no constraint
helpers.getConstraintHeight = function(domNode) {
return getConstraintDimension(domNode, 'max-height', 'clientHeight');
};
helpers.getMaximumWidth = function(domNode) {
var container = domNode.parentNode;
if (!container) {
return domNode.clientWidth;
}
var paddingLeft = parseInt(helpers.getStyle(container, 'padding-left'), 10);
var paddingRight = parseInt(helpers.getStyle(container, 'padding-right'), 10);
var w = container.clientWidth - paddingLeft - paddingRight;
var cw = helpers.getConstraintWidth(domNode);
return isNaN(cw) ? w : Math.min(w, cw);
};
helpers.getMaximumHeight = function(domNode) {
var container = domNode.parentNode;
if (!container) {
return domNode.clientHeight;
}
var paddingTop = parseInt(helpers.getStyle(container, 'padding-top'), 10);
var paddingBottom = parseInt(helpers.getStyle(container, 'padding-bottom'), 10);
var h = container.clientHeight - paddingTop - paddingBottom;
var ch = helpers.getConstraintHeight(domNode);
return isNaN(ch) ? h : Math.min(h, ch);
};
helpers.getStyle = function(el, property) {
return el.currentStyle ?
el.currentStyle[property] :
document.defaultView.getComputedStyle(el, null).getPropertyValue(property);
};
helpers.retinaScale = function(chart, forceRatio) {
var pixelRatio = chart.currentDevicePixelRatio = forceRatio || window.devicePixelRatio || 1;
if (pixelRatio === 1) {
return;
}
var canvas = chart.canvas;
var height = chart.height;
var width = chart.width;
canvas.height = height * pixelRatio;
canvas.width = width * pixelRatio;
chart.ctx.scale(pixelRatio, pixelRatio);
// If no style has been set on the canvas, the render size is used as display size,
// making the chart visually bigger, so let's enforce it to the "correct" values.
// See https://github.com/chartjs/Chart.js/issues/3575
if (!canvas.style.height && !canvas.style.width) {
canvas.style.height = height + 'px';
canvas.style.width = width + 'px';
}
};
// -- Canvas methods
helpers.fontString = function(pixelSize, fontStyle, fontFamily) {
return fontStyle + ' ' + pixelSize + 'px ' + fontFamily;
};
helpers.longestText = function(ctx, font, arrayOfThings, cache) {
cache = cache || {};
var data = cache.data = cache.data || {};
var gc = cache.garbageCollect = cache.garbageCollect || [];
if (cache.font !== font) {
data = cache.data = {};
gc = cache.garbageCollect = [];
cache.font = font;
}
ctx.font = font;
var longest = 0;
helpers.each(arrayOfThings, function(thing) {
// Undefined strings and arrays should not be measured
if (thing !== undefined && thing !== null && helpers.isArray(thing) !== true) {
longest = helpers.measureText(ctx, data, gc, longest, thing);
} else if (helpers.isArray(thing)) {
// if it is an array lets measure each element
// to do maybe simplify this function a bit so we can do this more recursively?
helpers.each(thing, function(nestedThing) {
// Undefined strings and arrays should not be measured
if (nestedThing !== undefined && nestedThing !== null && !helpers.isArray(nestedThing)) {
longest = helpers.measureText(ctx, data, gc, longest, nestedThing);
}
});
}
});
var gcLen = gc.length / 2;
if (gcLen > arrayOfThings.length) {
for (var i = 0; i < gcLen; i++) {
delete data[gc[i]];
}
gc.splice(0, gcLen);
}
return longest;
};
helpers.measureText = function(ctx, data, gc, longest, string) {
var textWidth = data[string];
if (!textWidth) {
textWidth = data[string] = ctx.measureText(string).width;
gc.push(string);
}
if (textWidth > longest) {
longest = textWidth;
}
return longest;
};
helpers.numberOfLabelLines = function(arrayOfThings) {
var numberOfLines = 1;
helpers.each(arrayOfThings, function(thing) {
if (helpers.isArray(thing)) {
if (thing.length > numberOfLines) {
numberOfLines = thing.length;
}
}
});
return numberOfLines;
};
helpers.color = !color ?
function(value) {
console.error('Color.js not found!');
return value;
} :
function(value) {
/* global CanvasGradient */
if (value instanceof CanvasGradient) {
value = defaults.global.defaultColor;
}
return color(value);
};
helpers.getHoverColor = function(colorValue) {
/* global CanvasPattern */
return (colorValue instanceof CanvasPattern) ?
colorValue :
helpers.color(colorValue).saturate(0.5).darken(0.1).rgbString();
};
};
},{"25":25,"3":3,"45":45}],28:[function(require,module,exports){
'use strict';
var helpers = require(45);
/**
* Helper function to get relative position for an event
* @param {Event|IEvent} event - The event to get the position for
* @param {Chart} chart - The chart
* @returns {Point} the event position
*/
function getRelativePosition(e, chart) {
if (e.native) {
return {
x: e.x,
y: e.y
};
}
return helpers.getRelativePosition(e, chart);
}
/**
* Helper function to traverse all of the visible elements in the chart
* @param chart {chart} the chart
* @param handler {Function} the callback to execute for each visible item
*/
function parseVisibleItems(chart, handler) {
var datasets = chart.data.datasets;
var meta, i, j, ilen, jlen;
for (i = 0, ilen = datasets.length; i < ilen; ++i) {
if (!chart.isDatasetVisible(i)) {
continue;
}
meta = chart.getDatasetMeta(i);
for (j = 0, jlen = meta.data.length; j < jlen; ++j) {
var element = meta.data[j];
if (!element._view.skip) {
handler(element);
}
}
}
}
/**
* Helper function to get the items that intersect the event position
* @param items {ChartElement[]} elements to filter
* @param position {Point} the point to be nearest to
* @return {ChartElement[]} the nearest items
*/
function getIntersectItems(chart, position) {
var elements = [];
parseVisibleItems(chart, function(element) {
if (element.inRange(position.x, position.y)) {
elements.push(element);
}
});
return elements;
}
/**
* Helper function to get the items nearest to the event position considering all visible items in teh chart
* @param chart {Chart} the chart to look at elements from
* @param position {Point} the point to be nearest to
* @param intersect {Boolean} if true, only consider items that intersect the position
* @param distanceMetric {Function} function to provide the distance between points
* @return {ChartElement[]} the nearest items
*/
function getNearestItems(chart, position, intersect, distanceMetric) {
var minDistance = Number.POSITIVE_INFINITY;
var nearestItems = [];
parseVisibleItems(chart, function(element) {
if (intersect && !element.inRange(position.x, position.y)) {
return;
}
var center = element.getCenterPoint();
var distance = distanceMetric(position, center);
if (distance < minDistance) {
nearestItems = [element];
minDistance = distance;
} else if (distance === minDistance) {
// Can have multiple items at the same distance in which case we sort by size
nearestItems.push(element);
}
});
return nearestItems;
}
/**
* Get a distance metric function for two points based on the
* axis mode setting
* @param {String} axis the axis mode. x|y|xy
*/
function getDistanceMetricForAxis(axis) {
var useX = axis.indexOf('x') !== -1;
var useY = axis.indexOf('y') !== -1;
return function(pt1, pt2) {
var deltaX = useX ? Math.abs(pt1.x - pt2.x) : 0;
var deltaY = useY ? Math.abs(pt1.y - pt2.y) : 0;
return Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));
};
}
function indexMode(chart, e, options) {
var position = getRelativePosition(e, chart);
// Default axis for index mode is 'x' to match old behaviour
options.axis = options.axis || 'x';
var distanceMetric = getDistanceMetricForAxis(options.axis);
var items = options.intersect ? getIntersectItems(chart, position) : getNearestItems(chart, position, false, distanceMetric);
var elements = [];
if (!items.length) {
return [];
}
chart.data.datasets.forEach(function(dataset, datasetIndex) {
if (chart.isDatasetVisible(datasetIndex)) {
var meta = chart.getDatasetMeta(datasetIndex);
var element = meta.data[items[0]._index];
// don't count items that are skipped (null data)
if (element && !element._view.skip) {
elements.push(element);
}
}
});
return elements;
}
/**
* @interface IInteractionOptions
*/
/**
* If true, only consider items that intersect the point
* @name IInterfaceOptions#boolean
* @type Boolean
*/
/**
* Contains interaction related functions
* @namespace Chart.Interaction
*/
module.exports = {
// Helper function for different modes
modes: {
single: function(chart, e) {
var position = getRelativePosition(e, chart);
var elements = [];
parseVisibleItems(chart, function(element) {
if (element.inRange(position.x, position.y)) {
elements.push(element);
return elements;
}
});
return elements.slice(0, 1);
},
/**
* @function Chart.Interaction.modes.label
* @deprecated since version 2.4.0
* @todo remove at version 3
* @private
*/
label: indexMode,
/**
* Returns items at the same index. If the options.intersect parameter is true, we only return items if we intersect something
* If the options.intersect mode is false, we find the nearest item and return the items at the same index as that item
* @function Chart.Interaction.modes.index
* @since v2.4.0
* @param chart {chart} the chart we are returning items from
* @param e {Event} the event we are find things at
* @param options {IInteractionOptions} options to use during interaction
* @return {Chart.Element[]} Array of elements that are under the point. If none are found, an empty array is returned
*/
index: indexMode,
/**
* Returns items in the same dataset. If the options.intersect parameter is true, we only return items if we intersect something
* If the options.intersect is false, we find the nearest item and return the items in that dataset
* @function Chart.Interaction.modes.dataset
* @param chart {chart} the chart we are returning items from
* @param e {Event} the event we are find things at
* @param options {IInteractionOptions} options to use during interaction
* @return {Chart.Element[]} Array of elements that are under the point. If none are found, an empty array is returned
*/
dataset: function(chart, e, options) {
var position = getRelativePosition(e, chart);
options.axis = options.axis || 'xy';
var distanceMetric = getDistanceMetricForAxis(options.axis);
var items = options.intersect ? getIntersectItems(chart, position) : getNearestItems(chart, position, false, distanceMetric);
if (items.length > 0) {
items = chart.getDatasetMeta(items[0]._datasetIndex).data;
}
return items;
},
/**
* @function Chart.Interaction.modes.x-axis
* @deprecated since version 2.4.0. Use index mode and intersect == true
* @todo remove at version 3
* @private
*/
'x-axis': function(chart, e) {
return indexMode(chart, e, {intersect: false});
},
/**
* Point mode returns all elements that hit test based on the event position
* of the event
* @function Chart.Interaction.modes.intersect
* @param chart {chart} the chart we are returning items from
* @param e {Event} the event we are find things at
* @return {Chart.Element[]} Array of elements that are under the point. If none are found, an empty array is returned
*/
point: function(chart, e) {
var position = getRelativePosition(e, chart);
return getIntersectItems(chart, position);
},
/**
* nearest mode returns the element closest to the point
* @function Chart.Interaction.modes.intersect
* @param chart {chart} the chart we are returning items from
* @param e {Event} the event we are find things at
* @param options {IInteractionOptions} options to use
* @return {Chart.Element[]} Array of elements that are under the point. If none are found, an empty array is returned
*/
nearest: function(chart, e, options) {
var position = getRelativePosition(e, chart);
options.axis = options.axis || 'xy';
var distanceMetric = getDistanceMetricForAxis(options.axis);
var nearestItems = getNearestItems(chart, position, options.intersect, distanceMetric);
// We have multiple items at the same distance from the event. Now sort by smallest
if (nearestItems.length > 1) {
nearestItems.sort(function(a, b) {
var sizeA = a.getArea();
var sizeB = b.getArea();
var ret = sizeA - sizeB;
if (ret === 0) {
// if equal sort by dataset index
ret = a._datasetIndex - b._datasetIndex;
}
return ret;
});
}
// Return only 1 item
return nearestItems.slice(0, 1);
},
/**
* x mode returns the elements that hit-test at the current x coordinate
* @function Chart.Interaction.modes.x
* @param chart {chart} the chart we are returning items from
* @param e {Event} the event we are find things at
* @param options {IInteractionOptions} options to use
* @return {Chart.Element[]} Array of elements that are under the point. If none are found, an empty array is returned
*/
x: function(chart, e, options) {
var position = getRelativePosition(e, chart);
var items = [];
var intersectsItem = false;
parseVisibleItems(chart, function(element) {
if (element.inXRange(position.x)) {
items.push(element);
}
if (element.inRange(position.x, position.y)) {
intersectsItem = true;
}
});
// If we want to trigger on an intersect and we don't have any items
// that intersect the position, return nothing
if (options.intersect && !intersectsItem) {
items = [];
}
return items;
},
/**
* y mode returns the elements that hit-test at the current y coordinate
* @function Chart.Interaction.modes.y
* @param chart {chart} the chart we are returning items from
* @param e {Event} the event we are find things at
* @param options {IInteractionOptions} options to use
* @return {Chart.Element[]} Array of elements that are under the point. If none are found, an empty array is returned
*/
y: function(chart, e, options) {
var position = getRelativePosition(e, chart);
var items = [];
var intersectsItem = false;
parseVisibleItems(chart, function(element) {
if (element.inYRange(position.y)) {
items.push(element);
}
if (element.inRange(position.x, position.y)) {
intersectsItem = true;
}
});
// If we want to trigger on an intersect and we don't have any items
// that intersect the position, return nothing
if (options.intersect && !intersectsItem) {
items = [];
}
return items;
}
}
};
},{"45":45}],29:[function(require,module,exports){
'use strict';
var defaults = require(25);
defaults._set('global', {
responsive: true,
responsiveAnimationDuration: 0,
maintainAspectRatio: true,
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'],
hover: {
onHover: null,
mode: 'nearest',
intersect: true,
animationDuration: 400
},
onClick: null,
defaultColor: 'rgba(0,0,0,0.1)',
defaultFontColor: '#666',
defaultFontFamily: "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
defaultFontSize: 12,
defaultFontStyle: 'normal',
showLines: true,
// Element defaults defined in element extensions
elements: {},
// Layout options such as padding
layout: {
padding: {
top: 0,
right: 0,
bottom: 0,
left: 0
}
}
});
module.exports = function() {
// Occupy the global variable of Chart, and create a simple base class
var Chart = function(item, config) {
this.construct(item, config);
return this;
};
Chart.Chart = Chart;
return Chart;
};
},{"25":25}],30:[function(require,module,exports){
'use strict';
var helpers = require(45);
function filterByPosition(array, position) {
return helpers.where(array, function(v) {
return v.position === position;
});
}
function sortByWeight(array, reverse) {
array.forEach(function(v, i) {
v._tmpIndex_ = i;
return v;
});
array.sort(function(a, b) {
var v0 = reverse ? b : a;
var v1 = reverse ? a : b;
return v0.weight === v1.weight ?
v0._tmpIndex_ - v1._tmpIndex_ :
v0.weight - v1.weight;
});
array.forEach(function(v) {
delete v._tmpIndex_;
});
}
/**
* @interface ILayoutItem
* @prop {String} position - The position of the item in the chart layout. Possible values are
* 'left', 'top', 'right', 'bottom', and 'chartArea'
* @prop {Number} weight - The weight used to sort the item. Higher weights are further away from the chart area
* @prop {Boolean} fullWidth - if true, and the item is horizontal, then push vertical boxes down
* @prop {Function} isHorizontal - returns true if the layout item is horizontal (ie. top or bottom)
* @prop {Function} update - Takes two parameters: width and height. Returns size of item
* @prop {Function} getPadding - Returns an object with padding on the edges
* @prop {Number} width - Width of item. Must be valid after update()
* @prop {Number} height - Height of item. Must be valid after update()
* @prop {Number} left - Left edge of the item. Set by layout system and cannot be used in update
* @prop {Number} top - Top edge of the item. Set by layout system and cannot be used in update
* @prop {Number} right - Right edge of the item. Set by layout system and cannot be used in update
* @prop {Number} bottom - Bottom edge of the item. Set by layout system and cannot be used in update
*/
// The layout service is very self explanatory. It's responsible for the layout within a chart.
// Scales, Legends and Plugins all rely on the layout service and can easily register to be placed anywhere they need
// It is this service's responsibility of carrying out that layout.
module.exports = {
defaults: {},
/**
* Register a box to a chart.
* A box is simply a reference to an object that requires layout. eg. Scales, Legend, Title.
* @param {Chart} chart - the chart to use
* @param {ILayoutItem} item - the item to add to be layed out
*/
addBox: function(chart, item) {
if (!chart.boxes) {
chart.boxes = [];
}
// initialize item with default values
item.fullWidth = item.fullWidth || false;
item.position = item.position || 'top';
item.weight = item.weight || 0;
chart.boxes.push(item);
},
/**
* Remove a layoutItem from a chart
* @param {Chart} chart - the chart to remove the box from
* @param {Object} layoutItem - the item to remove from the layout
*/
removeBox: function(chart, layoutItem) {
var index = chart.boxes ? chart.boxes.indexOf(layoutItem) : -1;
if (index !== -1) {
chart.boxes.splice(index, 1);
}
},
/**
* Sets (or updates) options on the given `item`.
* @param {Chart} chart - the chart in which the item lives (or will be added to)
* @param {Object} item - the item to configure with the given options
* @param {Object} options - the new item options.
*/
configure: function(chart, item, options) {
var props = ['fullWidth', 'position', 'weight'];
var ilen = props.length;
var i = 0;
var prop;
for (; i < ilen; ++i) {
prop = props[i];
if (options.hasOwnProperty(prop)) {
item[prop] = options[prop];
}
}
},
/**
* Fits boxes of the given chart into the given size by having each box measure itself
* then running a fitting algorithm
* @param {Chart} chart - the chart
* @param {Number} width - the width to fit into
* @param {Number} height - the height to fit into
*/
update: function(chart, width, height) {
if (!chart) {
return;
}
var layoutOptions = chart.options.layout || {};
var padding = helpers.options.toPadding(layoutOptions.padding);
var leftPadding = padding.left;
var rightPadding = padding.right;
var topPadding = padding.top;
var bottomPadding = padding.bottom;
var leftBoxes = filterByPosition(chart.boxes, 'left');
var rightBoxes = filterByPosition(chart.boxes, 'right');
var topBoxes = filterByPosition(chart.boxes, 'top');
var bottomBoxes = filterByPosition(chart.boxes, 'bottom');
var chartAreaBoxes = filterByPosition(chart.boxes, 'chartArea');
// Sort boxes by weight. A higher weight is further away from the chart area
sortByWeight(leftBoxes, true);
sortByWeight(rightBoxes, false);
sortByWeight(topBoxes, true);
sortByWeight(bottomBoxes, false);
// Essentially we now have any number of boxes on each of the 4 sides.
// Our canvas looks like the following.
// The areas L1 and L2 are the left axes. R1 is the right axis, T1 is the top axis and
// B1 is the bottom axis
// There are also 4 quadrant-like locations (left to right instead of clockwise) reserved for chart overlays
// These locations are single-box locations only, when trying to register a chartArea location that is already taken,
// an error will be thrown.
//
// |----------------------------------------------------|
// | T1 (Full Width) |
// |----------------------------------------------------|
// | | | T2 | |
// | |----|-------------------------------------|----|
// | | | C1 | | C2 | |
// | | |----| |----| |
// | | | | |
// | L1 | L2 | ChartArea (C0) | R1 |
// | | | | |
// | | |----| |----| |
// | | | C3 | | C4 | |
// | |----|-------------------------------------|----|
// | | | B1 | |
// |----------------------------------------------------|
// | B2 (Full Width) |
// |----------------------------------------------------|
//
// What we do to find the best sizing, we do the following
// 1. Determine the minimum size of the chart area.
// 2. Split the remaining width equally between each vertical axis
// 3. Split the remaining height equally between each horizontal axis
// 4. Give each layout the maximum size it can be. The layout will return it's minimum size
// 5. Adjust the sizes of each axis based on it's minimum reported size.
// 6. Refit each axis
// 7. Position each axis in the final location
// 8. Tell the chart the final location of the chart area
// 9. Tell any axes that overlay the chart area the positions of the chart area
// Step 1
var chartWidth = width - leftPadding - rightPadding;
var chartHeight = height - topPadding - bottomPadding;
var chartAreaWidth = chartWidth / 2; // min 50%
var chartAreaHeight = chartHeight / 2; // min 50%
// Step 2
var verticalBoxWidth = (width - chartAreaWidth) / (leftBoxes.length + rightBoxes.length);
// Step 3
var horizontalBoxHeight = (height - chartAreaHeight) / (topBoxes.length + bottomBoxes.length);
// Step 4
var maxChartAreaWidth = chartWidth;
var maxChartAreaHeight = chartHeight;
var minBoxSizes = [];
function getMinimumBoxSize(box) {
var minSize;
var isHorizontal = box.isHorizontal();
if (isHorizontal) {
minSize = box.update(box.fullWidth ? chartWidth : maxChartAreaWidth, horizontalBoxHeight);
maxChartAreaHeight -= minSize.height;
} else {
minSize = box.update(verticalBoxWidth, maxChartAreaHeight);
maxChartAreaWidth -= minSize.width;
}
minBoxSizes.push({
horizontal: isHorizontal,
minSize: minSize,
box: box,
});
}
helpers.each(leftBoxes.concat(rightBoxes, topBoxes, bottomBoxes), getMinimumBoxSize);
// If a horizontal box has padding, we move the left boxes over to avoid ugly charts (see issue #2478)
var maxHorizontalLeftPadding = 0;
var maxHorizontalRightPadding = 0;
var maxVerticalTopPadding = 0;
var maxVerticalBottomPadding = 0;
helpers.each(topBoxes.concat(bottomBoxes), function(horizontalBox) {
if (horizontalBox.getPadding) {
var boxPadding = horizontalBox.getPadding();
maxHorizontalLeftPadding = Math.max(maxHorizontalLeftPadding, boxPadding.left);
maxHorizontalRightPadding = Math.max(maxHorizontalRightPadding, boxPadding.right);
}
});
helpers.each(leftBoxes.concat(rightBoxes), function(verticalBox) {
if (verticalBox.getPadding) {
var boxPadding = verticalBox.getPadding();
maxVerticalTopPadding = Math.max(maxVerticalTopPadding, boxPadding.top);
maxVerticalBottomPadding = Math.max(maxVerticalBottomPadding, boxPadding.bottom);
}
});
// At this point, maxChartAreaHeight and maxChartAreaWidth are the size the chart area could
// be if the axes are drawn at their minimum sizes.
// Steps 5 & 6
var totalLeftBoxesWidth = leftPadding;
var totalRightBoxesWidth = rightPadding;
var totalTopBoxesHeight = topPadding;
var totalBottomBoxesHeight = bottomPadding;
// Function to fit a box
function fitBox(box) {
var minBoxSize = helpers.findNextWhere(minBoxSizes, function(minBox) {
return minBox.box === box;
});
if (minBoxSize) {
if (box.isHorizontal()) {
var scaleMargin = {
left: Math.max(totalLeftBoxesWidth, maxHorizontalLeftPadding),
right: Math.max(totalRightBoxesWidth, maxHorizontalRightPadding),
top: 0,
bottom: 0
};
// Don't use min size here because of label rotation. When the labels are rotated, their rotation highly depends
// on the margin. Sometimes they need to increase in size slightly
box.update(box.fullWidth ? chartWidth : maxChartAreaWidth, chartHeight / 2, scaleMargin);
} else {
box.update(minBoxSize.minSize.width, maxChartAreaHeight);
}
}
}
// Update, and calculate the left and right margins for the horizontal boxes
helpers.each(leftBoxes.concat(rightBoxes), fitBox);
helpers.each(leftBoxes, function(box) {
totalLeftBoxesWidth += box.width;
});
helpers.each(rightBoxes, function(box) {
totalRightBoxesWidth += box.width;
});
// Set the Left and Right margins for the horizontal boxes
helpers.each(topBoxes.concat(bottomBoxes), fitBox);
// Figure out how much margin is on the top and bottom of the vertical boxes
helpers.each(topBoxes, function(box) {
totalTopBoxesHeight += box.height;
});
helpers.each(bottomBoxes, function(box) {
totalBottomBoxesHeight += box.height;
});
function finalFitVerticalBox(box) {
var minBoxSize = helpers.findNextWhere(minBoxSizes, function(minSize) {
return minSize.box === box;
});
var scaleMargin = {
left: 0,
right: 0,
top: totalTopBoxesHeight,
bottom: totalBottomBoxesHeight
};
if (minBoxSize) {
box.update(minBoxSize.minSize.width, maxChartAreaHeight, scaleMargin);
}
}
// Let the left layout know the final margin
helpers.each(leftBoxes.concat(rightBoxes), finalFitVerticalBox);
// Recalculate because the size of each layout might have changed slightly due to the margins (label rotation for instance)
totalLeftBoxesWidth = leftPadding;
totalRightBoxesWidth = rightPadding;
totalTopBoxesHeight = topPadding;
totalBottomBoxesHeight = bottomPadding;
helpers.each(leftBoxes, function(box) {
totalLeftBoxesWidth += box.width;
});
helpers.each(rightBoxes, function(box) {
totalRightBoxesWidth += box.width;
});
helpers.each(topBoxes, function(box) {
totalTopBoxesHeight += box.height;
});
helpers.each(bottomBoxes, function(box) {
totalBottomBoxesHeight += box.height;
});
// We may be adding some padding to account for rotated x axis labels
var leftPaddingAddition = Math.max(maxHorizontalLeftPadding - totalLeftBoxesWidth, 0);
totalLeftBoxesWidth += leftPaddingAddition;
totalRightBoxesWidth += Math.max(maxHorizontalRightPadding - totalRightBoxesWidth, 0);
var topPaddingAddition = Math.max(maxVerticalTopPadding - totalTopBoxesHeight, 0);
totalTopBoxesHeight += topPaddingAddition;
totalBottomBoxesHeight += Math.max(maxVerticalBottomPadding - totalBottomBoxesHeight, 0);
// Figure out if our chart area changed. This would occur if the dataset layout label rotation
// changed due to the application of the margins in step 6. Since we can only get bigger, this is safe to do
// without calling `fit` again
var newMaxChartAreaHeight = height - totalTopBoxesHeight - totalBottomBoxesHeight;
var newMaxChartAreaWidth = width - totalLeftBoxesWidth - totalRightBoxesWidth;
if (newMaxChartAreaWidth !== maxChartAreaWidth || newMaxChartAreaHeight !== maxChartAreaHeight) {
helpers.each(leftBoxes, function(box) {
box.height = newMaxChartAreaHeight;
});
helpers.each(rightBoxes, function(box) {
box.height = newMaxChartAreaHeight;
});
helpers.each(topBoxes, function(box) {
if (!box.fullWidth) {
box.width = newMaxChartAreaWidth;
}
});
helpers.each(bottomBoxes, function(box) {
if (!box.fullWidth) {
box.width = newMaxChartAreaWidth;
}
});
maxChartAreaHeight = newMaxChartAreaHeight;
maxChartAreaWidth = newMaxChartAreaWidth;
}
// Step 7 - Position the boxes
var left = leftPadding + leftPaddingAddition;
var top = topPadding + topPaddingAddition;
function placeBox(box) {
if (box.isHorizontal()) {
box.left = box.fullWidth ? leftPadding : totalLeftBoxesWidth;
box.right = box.fullWidth ? width - rightPadding : totalLeftBoxesWidth + maxChartAreaWidth;
box.top = top;
box.bottom = top + box.height;
// Move to next point
top = box.bottom;
} else {
box.left = left;
box.right = left + box.width;
box.top = totalTopBoxesHeight;
box.bottom = totalTopBoxesHeight + maxChartAreaHeight;
// Move to next point
left = box.right;
}
}
helpers.each(leftBoxes.concat(topBoxes), placeBox);
// Account for chart width and height
left += maxChartAreaWidth;
top += maxChartAreaHeight;
helpers.each(rightBoxes, placeBox);
helpers.each(bottomBoxes, placeBox);
// Step 8
chart.chartArea = {
left: totalLeftBoxesWidth,
top: totalTopBoxesHeight,
right: totalLeftBoxesWidth + maxChartAreaWidth,
bottom: totalTopBoxesHeight + maxChartAreaHeight
};
// Step 9
helpers.each(chartAreaBoxes, function(box) {
box.left = chart.chartArea.left;
box.top = chart.chartArea.top;
box.right = chart.chartArea.right;
box.bottom = chart.chartArea.bottom;
box.update(maxChartAreaWidth, maxChartAreaHeight);
});
}
};
},{"45":45}],31:[function(require,module,exports){
'use strict';
var defaults = require(25);
var helpers = require(45);
defaults._set('global', {
plugins: {}
});
/**
* The plugin service singleton
* @namespace Chart.plugins
* @since 2.1.0
*/
module.exports = {
/**
* Globally registered plugins.
* @private
*/
_plugins: [],
/**
* This identifier is used to invalidate the descriptors cache attached to each chart
* when a global plugin is registered or unregistered. In this case, the cache ID is
* incremented and descriptors are regenerated during following API calls.
* @private
*/
_cacheId: 0,
/**
* Registers the given plugin(s) if not already registered.
* @param {Array|Object} plugins plugin instance(s).
*/
register: function(plugins) {
var p = this._plugins;
([]).concat(plugins).forEach(function(plugin) {
if (p.indexOf(plugin) === -1) {
p.push(plugin);
}
});
this._cacheId++;
},
/**
* Unregisters the given plugin(s) only if registered.
* @param {Array|Object} plugins plugin instance(s).
*/
unregister: function(plugins) {
var p = this._plugins;
([]).concat(plugins).forEach(function(plugin) {
var idx = p.indexOf(plugin);
if (idx !== -1) {
p.splice(idx, 1);
}
});
this._cacheId++;
},
/**
* Remove all registered plugins.
* @since 2.1.5
*/
clear: function() {
this._plugins = [];
this._cacheId++;
},
/**
* Returns the number of registered plugins?
* @returns {Number}
* @since 2.1.5
*/
count: function() {
return this._plugins.length;
},
/**
* Returns all registered plugin instances.
* @returns {Array} array of plugin objects.
* @since 2.1.5
*/
getAll: function() {
return this._plugins;
},
/**
* Calls enabled plugins for `chart` on the specified hook and with the given args.
* This method immediately returns as soon as a plugin explicitly returns false. The
* returned value can be used, for instance, to interrupt the current action.
* @param {Object} chart - The chart instance for which plugins should be called.
* @param {String} hook - The name of the plugin method to call (e.g. 'beforeUpdate').
* @param {Array} [args] - Extra arguments to apply to the hook call.
* @returns {Boolean} false if any of the plugins return false, else returns true.
*/
notify: function(chart, hook, args) {
var descriptors = this.descriptors(chart);
var ilen = descriptors.length;
var i, descriptor, plugin, params, method;
for (i = 0; i < ilen; ++i) {
descriptor = descriptors[i];
plugin = descriptor.plugin;
method = plugin[hook];
if (typeof method === 'function') {
params = [chart].concat(args || []);
params.push(descriptor.options);
if (method.apply(plugin, params) === false) {
return false;
}
}
}
return true;
},
/**
* Returns descriptors of enabled plugins for the given chart.
* @returns {Array} [{ plugin, options }]
* @private
*/
descriptors: function(chart) {
var cache = chart.$plugins || (chart.$plugins = {});
if (cache.id === this._cacheId) {
return cache.descriptors;
}
var plugins = [];
var descriptors = [];
var config = (chart && chart.config) || {};
var options = (config.options && config.options.plugins) || {};
this._plugins.concat(config.plugins || []).forEach(function(plugin) {
var idx = plugins.indexOf(plugin);
if (idx !== -1) {
return;
}
var id = plugin.id;
var opts = options[id];
if (opts === false) {
return;
}
if (opts === true) {
opts = helpers.clone(defaults.global.plugins[id]);
}
plugins.push(plugin);
descriptors.push({
plugin: plugin,
options: opts || {}
});
});
cache.descriptors = descriptors;
cache.id = this._cacheId;
return descriptors;
},
/**
* Invalidates cache for the given chart: descriptors hold a reference on plugin option,
* but in some cases, this reference can be changed by the user when updating options.
* https://github.com/chartjs/Chart.js/issues/5111#issuecomment-355934167
* @private
*/
_invalidate: function(chart) {
delete chart.$plugins;
}
};
/**
* Plugin extension hooks.
* @interface IPlugin
* @since 2.1.0
*/
/**
* @method IPlugin#beforeInit
* @desc Called before initializing `chart`.
* @param {Chart.Controller} chart - The chart instance.
* @param {Object} options - The plugin options.
*/
/**
* @method IPlugin#afterInit
* @desc Called after `chart` has been initialized and before the first update.
* @param {Chart.Controller} chart - The chart instance.
* @param {Object} options - The plugin options.
*/
/**
* @method IPlugin#beforeUpdate
* @desc Called before updating `chart`. If any plugin returns `false`, the update
* is cancelled (and thus subsequent render(s)) until another `update` is triggered.
* @param {Chart.Controller} chart - The chart instance.
* @param {Object} options - The plugin options.
* @returns {Boolean} `false` to cancel the chart update.
*/
/**
* @method IPlugin#afterUpdate
* @desc Called after `chart` has been updated and before rendering. Note that this
* hook will not be called if the chart update has been previously cancelled.
* @param {Chart.Controller} chart - The chart instance.
* @param {Object} options - The plugin options.
*/
/**
* @method IPlugin#beforeDatasetsUpdate
* @desc Called before updating the `chart` datasets. If any plugin returns `false`,
* the datasets update is cancelled until another `update` is triggered.
* @param {Chart.Controller} chart - The chart instance.
* @param {Object} options - The plugin options.
* @returns {Boolean} false to cancel the datasets update.
* @since version 2.1.5
*/
/**
* @method IPlugin#afterDatasetsUpdate
* @desc Called after the `chart` datasets have been updated. Note that this hook
* will not be called if the datasets update has been previously cancelled.
* @param {Chart.Controller} chart - The chart instance.
* @param {Object} options - The plugin options.
* @since version 2.1.5
*/
/**
* @method IPlugin#beforeDatasetUpdate
* @desc Called before updating the `chart` dataset at the given `args.index`. If any plugin
* returns `false`, the datasets update is cancelled until another `update` is triggered.
* @param {Chart} chart - The chart instance.
* @param {Object} args - The call arguments.
* @param {Number} args.index - The dataset index.
* @param {Object} args.meta - The dataset metadata.
* @param {Object} options - The plugin options.
* @returns {Boolean} `false` to cancel the chart datasets drawing.
*/
/**
* @method IPlugin#afterDatasetUpdate
* @desc Called after the `chart` datasets at the given `args.index` has been updated. Note
* that this hook will not be called if the datasets update has been previously cancelled.
* @param {Chart} chart - The chart instance.
* @param {Object} args - The call arguments.
* @param {Number} args.index - The dataset index.
* @param {Object} args.meta - The dataset metadata.
* @param {Object} options - The plugin options.
*/
/**
* @method IPlugin#beforeLayout
* @desc Called before laying out `chart`. If any plugin returns `false`,
* the layout update is cancelled until another `update` is triggered.
* @param {Chart.Controller} chart - The chart instance.
* @param {Object} options - The plugin options.
* @returns {Boolean} `false` to cancel the chart layout.
*/
/**
* @method IPlugin#afterLayout
* @desc Called after the `chart` has been layed out. Note that this hook will not
* be called if the layout update has been previously cancelled.
* @param {Chart.Controller} chart - The chart instance.
* @param {Object} options - The plugin options.
*/
/**
* @method IPlugin#beforeRender
* @desc Called before rendering `chart`. If any plugin returns `false`,
* the rendering is cancelled until another `render` is triggered.
* @param {Chart.Controller} chart - The chart instance.
* @param {Object} options - The plugin options.
* @returns {Boolean} `false` to cancel the chart rendering.
*/
/**
* @method IPlugin#afterRender
* @desc Called after the `chart` has been fully rendered (and animation completed). Note
* that this hook will not be called if the rendering has been previously cancelled.
* @param {Chart.Controller} chart - The chart instance.
* @param {Object} options - The plugin options.
*/
/**
* @method IPlugin#beforeDraw
* @desc Called before drawing `chart` at every animation frame specified by the given
* easing value. If any plugin returns `false`, the frame drawing is cancelled until
* another `render` is triggered.
* @param {Chart.Controller} chart - The chart instance.
* @param {Number} easingValue - The current animation value, between 0.0 and 1.0.
* @param {Object} options - The plugin options.
* @returns {Boolean} `false` to cancel the chart drawing.
*/
/**
* @method IPlugin#afterDraw
* @desc Called after the `chart` has been drawn for the specific easing value. Note
* that this hook will not be called if the drawing has been previously cancelled.
* @param {Chart.Controller} chart - The chart instance.
* @param {Number} easingValue - The current animation value, between 0.0 and 1.0.
* @param {Object} options - The plugin options.
*/
/**
* @method IPlugin#beforeDatasetsDraw
* @desc Called before drawing the `chart` datasets. If any plugin returns `false`,
* the datasets drawing is cancelled until another `render` is triggered.
* @param {Chart.Controller} chart - The chart instance.
* @param {Number} easingValue - The current animation value, between 0.0 and 1.0.
* @param {Object} options - The plugin options.
* @returns {Boolean} `false` to cancel the chart datasets drawing.
*/
/**
* @method IPlugin#afterDatasetsDraw
* @desc Called after the `chart` datasets have been drawn. Note that this hook
* will not be called if the datasets drawing has been previously cancelled.
* @param {Chart.Controller} chart - The chart instance.
* @param {Number} easingValue - The current animation value, between 0.0 and 1.0.
* @param {Object} options - The plugin options.
*/
/**
* @method IPlugin#beforeDatasetDraw
* @desc Called before drawing the `chart` dataset at the given `args.index` (datasets
* are drawn in the reverse order). If any plugin returns `false`, the datasets drawing
* is cancelled until another `render` is triggered.
* @param {Chart} chart - The chart instance.
* @param {Object} args - The call arguments.
* @param {Number} args.index - The dataset index.
* @param {Object} args.meta - The dataset metadata.
* @param {Number} args.easingValue - The current animation value, between 0.0 and 1.0.
* @param {Object} options - The plugin options.
* @returns {Boolean} `false` to cancel the chart datasets drawing.
*/
/**
* @method IPlugin#afterDatasetDraw
* @desc Called after the `chart` datasets at the given `args.index` have been drawn
* (datasets are drawn in the reverse order). Note that this hook will not be called
* if the datasets drawing has been previously cancelled.
* @param {Chart} chart - The chart instance.
* @param {Object} args - The call arguments.
* @param {Number} args.index - The dataset index.
* @param {Object} args.meta - The dataset metadata.
* @param {Number} args.easingValue - The current animation value, between 0.0 and 1.0.
* @param {Object} options - The plugin options.
*/
/**
* @method IPlugin#beforeTooltipDraw
* @desc Called before drawing the `tooltip`. If any plugin returns `false`,
* the tooltip drawing is cancelled until another `render` is triggered.
* @param {Chart} chart - The chart instance.
* @param {Object} args - The call arguments.
* @param {Object} args.tooltip - The tooltip.
* @param {Number} args.easingValue - The current animation value, between 0.0 and 1.0.
* @param {Object} options - The plugin options.
* @returns {Boolean} `false` to cancel the chart tooltip drawing.
*/
/**
* @method IPlugin#afterTooltipDraw
* @desc Called after drawing the `tooltip`. Note that this hook will not
* be called if the tooltip drawing has been previously cancelled.
* @param {Chart} chart - The chart instance.
* @param {Object} args - The call arguments.
* @param {Object} args.tooltip - The tooltip.
* @param {Number} args.easingValue - The current animation value, between 0.0 and 1.0.
* @param {Object} options - The plugin options.
*/
/**
* @method IPlugin#beforeEvent
* @desc Called before processing the specified `event`. If any plugin returns `false`,
* the event will be discarded.
* @param {Chart.Controller} chart - The chart instance.
* @param {IEvent} event - The event object.
* @param {Object} options - The plugin options.
*/
/**
* @method IPlugin#afterEvent
* @desc Called after the `event` has been consumed. Note that this hook
* will not be called if the `event` has been previously discarded.
* @param {Chart.Controller} chart - The chart instance.
* @param {IEvent} event - The event object.
* @param {Object} options - The plugin options.
*/
/**
* @method IPlugin#resize
* @desc Called after the chart as been resized.
* @param {Chart.Controller} chart - The chart instance.
* @param {Number} size - The new canvas display size (eq. canvas.style width & height).
* @param {Object} options - The plugin options.
*/
/**
* @method IPlugin#destroy
* @desc Called after the chart as been destroyed.
* @param {Chart.Controller} chart - The chart instance.
* @param {Object} options - The plugin options.
*/
},{"25":25,"45":45}],32:[function(require,module,exports){
'use strict';
var defaults = require(25);
var Element = require(26);
var helpers = require(45);
var Ticks = require(34);
defaults._set('scale', {
display: true,
position: 'left',
offset: false,
// grid line settings
gridLines: {
display: true,
color: 'rgba(0, 0, 0, 0.1)',
lineWidth: 1,
drawBorder: true,
drawOnChartArea: true,
drawTicks: true,
tickMarkLength: 10,
zeroLineWidth: 1,
zeroLineColor: 'rgba(0,0,0,0.25)',
zeroLineBorderDash: [],
zeroLineBorderDashOffset: 0.0,
offsetGridLines: false,
borderDash: [],
borderDashOffset: 0.0
},
// scale label
scaleLabel: {
// display property
display: false,
// actual label
labelString: '',
// line height
lineHeight: 1.2,
// top/bottom padding
padding: {
top: 4,
bottom: 4
}
},
// label settings
ticks: {
beginAtZero: false,
minRotation: 0,
maxRotation: 50,
mirror: false,
padding: 0,
reverse: false,
display: true,
autoSkip: true,
autoSkipPadding: 0,
labelOffset: 0,
// We pass through arrays to be rendered as multiline labels, we convert Others to strings here.
callback: Ticks.formatters.values,
minor: {},
major: {}
}
});
function labelsFromTicks(ticks) {
var labels = [];
var i, ilen;
for (i = 0, ilen = ticks.length; i < ilen; ++i) {
labels.push(ticks[i].label);
}
return labels;
}
function getLineValue(scale, index, offsetGridLines) {
var lineValue = scale.getPixelForTick(index);
if (offsetGridLines) {
if (index === 0) {
lineValue -= (scale.getPixelForTick(1) - lineValue) / 2;
} else {
lineValue -= (lineValue - scale.getPixelForTick(index - 1)) / 2;
}
}
return lineValue;
}
module.exports = function(Chart) {
function computeTextSize(context, tick, font) {
return helpers.isArray(tick) ?
helpers.longestText(context, font, tick) :
context.measureText(tick).width;
}
function parseFontOptions(options) {
var valueOrDefault = helpers.valueOrDefault;
var globalDefaults = defaults.global;
var size = valueOrDefault(options.fontSize, globalDefaults.defaultFontSize);
var style = valueOrDefault(options.fontStyle, globalDefaults.defaultFontStyle);
var family = valueOrDefault(options.fontFamily, globalDefaults.defaultFontFamily);
return {
size: size,
style: style,
family: family,
font: helpers.fontString(size, style, family)
};
}
function parseLineHeight(options) {
return helpers.options.toLineHeight(
helpers.valueOrDefault(options.lineHeight, 1.2),
helpers.valueOrDefault(options.fontSize, defaults.global.defaultFontSize));
}
Chart.Scale = Element.extend({
/**
* Get the padding needed for the scale
* @method getPadding
* @private
* @returns {Padding} the necessary padding
*/
getPadding: function() {
var me = this;
return {
left: me.paddingLeft || 0,
top: me.paddingTop || 0,
right: me.paddingRight || 0,
bottom: me.paddingBottom || 0
};
},
/**
* Returns the scale tick objects ({label, major})
* @since 2.7
*/
getTicks: function() {
return this._ticks;
},
// These methods are ordered by lifecyle. Utilities then follow.
// Any function defined here is inherited by all scale types.
// Any function can be extended by the scale type
mergeTicksOptions: function() {
var ticks = this.options.ticks;
if (ticks.minor === false) {
ticks.minor = {
display: false
};
}
if (ticks.major === false) {
ticks.major = {
display: false
};
}
for (var key in ticks) {
if (key !== 'major' && key !== 'minor') {
if (typeof ticks.minor[key] === 'undefined') {
ticks.minor[key] = ticks[key];
}
if (typeof ticks.major[key] === 'undefined') {
ticks.major[key] = ticks[key];
}
}
}
},
beforeUpdate: function() {
helpers.callback(this.options.beforeUpdate, [this]);
},
update: function(maxWidth, maxHeight, margins) {
var me = this;
var i, ilen, labels, label, ticks, tick;
// Update Lifecycle - Probably don't want to ever extend or overwrite this function ;)
me.beforeUpdate();
// Absorb the master measurements
me.maxWidth = maxWidth;
me.maxHeight = maxHeight;
me.margins = helpers.extend({
left: 0,
right: 0,
top: 0,
bottom: 0
}, margins);
me.longestTextCache = me.longestTextCache || {};
// Dimensions
me.beforeSetDimensions();
me.setDimensions();
me.afterSetDimensions();
// Data min/max
me.beforeDataLimits();
me.determineDataLimits();
me.afterDataLimits();
// Ticks - `this.ticks` is now DEPRECATED!
// Internal ticks are now stored as objects in the PRIVATE `this._ticks` member
// and must not be accessed directly from outside this class. `this.ticks` being
// around for long time and not marked as private, we can't change its structure
// without unexpected breaking changes. If you need to access the scale ticks,
// use scale.getTicks() instead.
me.beforeBuildTicks();
// New implementations should return an array of objects but for BACKWARD COMPAT,
// we still support no return (`this.ticks` internally set by calling this method).
ticks = me.buildTicks() || [];
me.afterBuildTicks();
me.beforeTickToLabelConversion();
// New implementations should return the formatted tick labels but for BACKWARD
// COMPAT, we still support no return (`this.ticks` internally changed by calling
// this method and supposed to contain only string values).
labels = me.convertTicksToLabels(ticks) || me.ticks;
me.afterTickToLabelConversion();
me.ticks = labels; // BACKWARD COMPATIBILITY
// IMPORTANT: from this point, we consider that `this.ticks` will NEVER change!
// BACKWARD COMPAT: synchronize `_ticks` with labels (so potentially `this.ticks`)
for (i = 0, ilen = labels.length; i < ilen; ++i) {
label = labels[i];
tick = ticks[i];
if (!tick) {
ticks.push(tick = {
label: label,
major: false
});
} else {
tick.label = label;
}
}
me._ticks = ticks;
// Tick Rotation
me.beforeCalculateTickRotation();
me.calculateTickRotation();
me.afterCalculateTickRotation();
// Fit
me.beforeFit();
me.fit();
me.afterFit();
//
me.afterUpdate();
return me.minSize;
},
afterUpdate: function() {
helpers.callback(this.options.afterUpdate, [this]);
},
//
beforeSetDimensions: function() {
helpers.callback(this.options.beforeSetDimensions, [this]);
},
setDimensions: function() {
var me = this;
// Set the unconstrained dimension before label rotation
if (me.isHorizontal()) {
// Reset position before calculating rotation
me.width = me.maxWidth;
me.left = 0;
me.right = me.width;
} else {
me.height = me.maxHeight;
// Reset position before calculating rotation
me.top = 0;
me.bottom = me.height;
}
// Reset padding
me.paddingLeft = 0;
me.paddingTop = 0;
me.paddingRight = 0;
me.paddingBottom = 0;
},
afterSetDimensions: function() {
helpers.callback(this.options.afterSetDimensions, [this]);
},
// Data limits
beforeDataLimits: function() {
helpers.callback(this.options.beforeDataLimits, [this]);
},
determineDataLimits: helpers.noop,
afterDataLimits: function() {
helpers.callback(this.options.afterDataLimits, [this]);
},
//
beforeBuildTicks: function() {
helpers.callback(this.options.beforeBuildTicks, [this]);
},
buildTicks: helpers.noop,
afterBuildTicks: function() {
helpers.callback(this.options.afterBuildTicks, [this]);
},
beforeTickToLabelConversion: function() {
helpers.callback(this.options.beforeTickToLabelConversion, [this]);
},
convertTicksToLabels: function() {
var me = this;
// Convert ticks to strings
var tickOpts = me.options.ticks;
me.ticks = me.ticks.map(tickOpts.userCallback || tickOpts.callback, this);
},
afterTickToLabelConversion: function() {
helpers.callback(this.options.afterTickToLabelConversion, [this]);
},
//
beforeCalculateTickRotation: function() {
helpers.callback(this.options.beforeCalculateTickRotation, [this]);
},
calculateTickRotation: function() {
var me = this;
var context = me.ctx;
var tickOpts = me.options.ticks;
var labels = labelsFromTicks(me._ticks);
// Get the width of each grid by calculating the difference
// between x offsets between 0 and 1.
var tickFont = parseFontOptions(tickOpts);
context.font = tickFont.font;
var labelRotation = tickOpts.minRotation || 0;
if (labels.length && me.options.display && me.isHorizontal()) {
var originalLabelWidth = helpers.longestText(context, tickFont.font, labels, me.longestTextCache);
var labelWidth = originalLabelWidth;
var cosRotation, sinRotation;
// Allow 3 pixels x2 padding either side for label readability
var tickWidth = me.getPixelForTick(1) - me.getPixelForTick(0) - 6;
// Max label rotation can be set or default to 90 - also act as a loop counter
while (labelWidth > tickWidth && labelRotation < tickOpts.maxRotation) {
var angleRadians = helpers.toRadians(labelRotation);
cosRotation = Math.cos(angleRadians);
sinRotation = Math.sin(angleRadians);
if (sinRotation * originalLabelWidth > me.maxHeight) {
// go back one step
labelRotation--;
break;
}
labelRotation++;
labelWidth = cosRotation * originalLabelWidth;
}
}
me.labelRotation = labelRotation;
},
afterCalculateTickRotation: function() {
helpers.callback(this.options.afterCalculateTickRotation, [this]);
},
//
beforeFit: function() {
helpers.callback(this.options.beforeFit, [this]);
},
fit: function() {
var me = this;
// Reset
var minSize = me.minSize = {
width: 0,
height: 0
};
var labels = labelsFromTicks(me._ticks);
var opts = me.options;
var tickOpts = opts.ticks;
var scaleLabelOpts = opts.scaleLabel;
var gridLineOpts = opts.gridLines;
var display = opts.display;
var isHorizontal = me.isHorizontal();
var tickFont = parseFontOptions(tickOpts);
var tickMarkLength = opts.gridLines.tickMarkLength;
// Width
if (isHorizontal) {
// subtract the margins to line up with the chartArea if we are a full width scale
minSize.width = me.isFullWidth() ? me.maxWidth - me.margins.left - me.margins.right : me.maxWidth;
} else {
minSize.width = display && gridLineOpts.drawTicks ? tickMarkLength : 0;
}
// height
if (isHorizontal) {
minSize.height = display && gridLineOpts.drawTicks ? tickMarkLength : 0;
} else {
minSize.height = me.maxHeight; // fill all the height
}
// Are we showing a title for the scale?
if (scaleLabelOpts.display && display) {
var scaleLabelLineHeight = parseLineHeight(scaleLabelOpts);
var scaleLabelPadding = helpers.options.toPadding(scaleLabelOpts.padding);
var deltaHeight = scaleLabelLineHeight + scaleLabelPadding.height;
if (isHorizontal) {
minSize.height += deltaHeight;
} else {
minSize.width += deltaHeight;
}
}
// Don't bother fitting the ticks if we are not showing them
if (tickOpts.display && display) {
var largestTextWidth = helpers.longestText(me.ctx, tickFont.font, labels, me.longestTextCache);
var tallestLabelHeightInLines = helpers.numberOfLabelLines(labels);
var lineSpace = tickFont.size * 0.5;
var tickPadding = me.options.ticks.padding;
if (isHorizontal) {
// A horizontal axis is more constrained by the height.
me.longestLabelWidth = largestTextWidth;
var angleRadians = helpers.toRadians(me.labelRotation);
var cosRotation = Math.cos(angleRadians);
var sinRotation = Math.sin(angleRadians);
// TODO - improve this calculation
var labelHeight = (sinRotation * largestTextWidth)
+ (tickFont.size * tallestLabelHeightInLines)
+ (lineSpace * (tallestLabelHeightInLines - 1))
+ lineSpace; // padding
minSize.height = Math.min(me.maxHeight, minSize.height + labelHeight + tickPadding);
me.ctx.font = tickFont.font;
var firstLabelWidth = computeTextSize(me.ctx, labels[0], tickFont.font);
var lastLabelWidth = computeTextSize(me.ctx, labels[labels.length - 1], tickFont.font);
// Ensure that our ticks are always inside the canvas. When rotated, ticks are right aligned
// which means that the right padding is dominated by the font height
if (me.labelRotation !== 0) {
me.paddingLeft = opts.position === 'bottom' ? (cosRotation * firstLabelWidth) + 3 : (cosRotation * lineSpace) + 3; // add 3 px to move away from canvas edges
me.paddingRight = opts.position === 'bottom' ? (cosRotation * lineSpace) + 3 : (cosRotation * lastLabelWidth) + 3;
} else {
me.paddingLeft = firstLabelWidth / 2 + 3; // add 3 px to move away from canvas edges
me.paddingRight = lastLabelWidth / 2 + 3;
}
} else {
// A vertical axis is more constrained by the width. Labels are the
// dominant factor here, so get that length first and account for padding
if (tickOpts.mirror) {
largestTextWidth = 0;
} else {
// use lineSpace for consistency with horizontal axis
// tickPadding is not implemented for horizontal
largestTextWidth += tickPadding + lineSpace;
}
minSize.width = Math.min(me.maxWidth, minSize.width + largestTextWidth);
me.paddingTop = tickFont.size / 2;
me.paddingBottom = tickFont.size / 2;
}
}
me.handleMargins();
me.width = minSize.width;
me.height = minSize.height;
},
/**
* Handle margins and padding interactions
* @private
*/
handleMargins: function() {
var me = this;
if (me.margins) {
me.paddingLeft = Math.max(me.paddingLeft - me.margins.left, 0);
me.paddingTop = Math.max(me.paddingTop - me.margins.top, 0);
me.paddingRight = Math.max(me.paddingRight - me.margins.right, 0);
me.paddingBottom = Math.max(me.paddingBottom - me.margins.bottom, 0);
}
},
afterFit: function() {
helpers.callback(this.options.afterFit, [this]);
},
// Shared Methods
isHorizontal: function() {
return this.options.position === 'top' || this.options.position === 'bottom';
},
isFullWidth: function() {
return (this.options.fullWidth);
},
// Get the correct value. NaN bad inputs, If the value type is object get the x or y based on whether we are horizontal or not
getRightValue: function(rawValue) {
// Null and undefined values first
if (helpers.isNullOrUndef(rawValue)) {
return NaN;
}
// isNaN(object) returns true, so make sure NaN is checking for a number; Discard Infinite values
if (typeof rawValue === 'number' && !isFinite(rawValue)) {
return NaN;
}
// If it is in fact an object, dive in one more level
if (rawValue) {
if (this.isHorizontal()) {
if (rawValue.x !== undefined) {
return this.getRightValue(rawValue.x);
}
} else if (rawValue.y !== undefined) {
return this.getRightValue(rawValue.y);
}
}
// Value is good, return it
return rawValue;
},
/**
* Used to get the value to display in the tooltip for the data at the given index
* @param index
* @param datasetIndex
*/
getLabelForIndex: helpers.noop,
/**
* Returns the location of the given data point. Value can either be an index or a numerical value
* The coordinate (0, 0) is at the upper-left corner of the canvas
* @param value
* @param index
* @param datasetIndex
*/
getPixelForValue: helpers.noop,
/**
* Used to get the data value from a given pixel. This is the inverse of getPixelForValue
* The coordinate (0, 0) is at the upper-left corner of the canvas
* @param pixel
*/
getValueForPixel: helpers.noop,
/**
* Returns the location of the tick at the given index
* The coordinate (0, 0) is at the upper-left corner of the canvas
*/
getPixelForTick: function(index) {
var me = this;
var offset = me.options.offset;
if (me.isHorizontal()) {
var innerWidth = me.width - (me.paddingLeft + me.paddingRight);
var tickWidth = innerWidth / Math.max((me._ticks.length - (offset ? 0 : 1)), 1);
var pixel = (tickWidth * index) + me.paddingLeft;
if (offset) {
pixel += tickWidth / 2;
}
var finalVal = me.left + Math.round(pixel);
finalVal += me.isFullWidth() ? me.margins.left : 0;
return finalVal;
}
var innerHeight = me.height - (me.paddingTop + me.paddingBottom);
return me.top + (index * (innerHeight / (me._ticks.length - 1)));
},
/**
* Utility for getting the pixel location of a percentage of scale
* The coordinate (0, 0) is at the upper-left corner of the canvas
*/
getPixelForDecimal: function(decimal) {
var me = this;
if (me.isHorizontal()) {
var innerWidth = me.width - (me.paddingLeft + me.paddingRight);
var valueOffset = (innerWidth * decimal) + me.paddingLeft;
var finalVal = me.left + Math.round(valueOffset);
finalVal += me.isFullWidth() ? me.margins.left : 0;
return finalVal;
}
return me.top + (decimal * me.height);
},
/**
* Returns the pixel for the minimum chart value
* The coordinate (0, 0) is at the upper-left corner of the canvas
*/
getBasePixel: function() {
return this.getPixelForValue(this.getBaseValue());
},
getBaseValue: function() {
var me = this;
var min = me.min;
var max = me.max;
return me.beginAtZero ? 0 :
min < 0 && max < 0 ? max :
min > 0 && max > 0 ? min :
0;
},
/**
* Returns a subset of ticks to be plotted to avoid overlapping labels.
* @private
*/
_autoSkip: function(ticks) {
var skipRatio;
var me = this;
var isHorizontal = me.isHorizontal();
var optionTicks = me.options.ticks.minor;
var tickCount = ticks.length;
var labelRotationRadians = helpers.toRadians(me.labelRotation);
var cosRotation = Math.cos(labelRotationRadians);
var longestRotatedLabel = me.longestLabelWidth * cosRotation;
var result = [];
var i, tick, shouldSkip;
// figure out the maximum number of gridlines to show
var maxTicks;
if (optionTicks.maxTicksLimit) {
maxTicks = optionTicks.maxTicksLimit;
}
if (isHorizontal) {
skipRatio = false;
if ((longestRotatedLabel + optionTicks.autoSkipPadding) * tickCount > (me.width - (me.paddingLeft + me.paddingRight))) {
skipRatio = 1 + Math.floor(((longestRotatedLabel + optionTicks.autoSkipPadding) * tickCount) / (me.width - (me.paddingLeft + me.paddingRight)));
}
// if they defined a max number of optionTicks,
// increase skipRatio until that number is met
if (maxTicks && tickCount > maxTicks) {
skipRatio = Math.max(skipRatio, Math.floor(tickCount / maxTicks));
}
}
for (i = 0; i < tickCount; i++) {
tick = ticks[i];
// Since we always show the last tick,we need may need to hide the last shown one before
shouldSkip = (skipRatio > 1 && i % skipRatio > 0) || (i % skipRatio === 0 && i + skipRatio >= tickCount);
if (shouldSkip && i !== tickCount - 1) {
// leave tick in place but make sure it's not displayed (#4635)
delete tick.label;
}
result.push(tick);
}
return result;
},
// Actually draw the scale on the canvas
// @param {rectangle} chartArea : the area of the chart to draw full grid lines on
draw: function(chartArea) {
var me = this;
var options = me.options;
if (!options.display) {
return;
}
var context = me.ctx;
var globalDefaults = defaults.global;
var optionTicks = options.ticks.minor;
var optionMajorTicks = options.ticks.major || optionTicks;
var gridLines = options.gridLines;
var scaleLabel = options.scaleLabel;
var isRotated = me.labelRotation !== 0;
var isHorizontal = me.isHorizontal();
var ticks = optionTicks.autoSkip ? me._autoSkip(me.getTicks()) : me.getTicks();
var tickFontColor = helpers.valueOrDefault(optionTicks.fontColor, globalDefaults.defaultFontColor);
var tickFont = parseFontOptions(optionTicks);
var majorTickFontColor = helpers.valueOrDefault(optionMajorTicks.fontColor, globalDefaults.defaultFontColor);
var majorTickFont = parseFontOptions(optionMajorTicks);
var tl = gridLines.drawTicks ? gridLines.tickMarkLength : 0;
var scaleLabelFontColor = helpers.valueOrDefault(scaleLabel.fontColor, globalDefaults.defaultFontColor);
var scaleLabelFont = parseFontOptions(scaleLabel);
var scaleLabelPadding = helpers.options.toPadding(scaleLabel.padding);
var labelRotationRadians = helpers.toRadians(me.labelRotation);
var itemsToDraw = [];
var axisWidth = me.options.gridLines.lineWidth;
var xTickStart = options.position === 'right' ? me.right : me.right - axisWidth - tl;
var xTickEnd = options.position === 'right' ? me.right + tl : me.right;
var yTickStart = options.position === 'bottom' ? me.top + axisWidth : me.bottom - tl - axisWidth;
var yTickEnd = options.position === 'bottom' ? me.top + axisWidth + tl : me.bottom + axisWidth;
helpers.each(ticks, function(tick, index) {
// autoskipper skipped this tick (#4635)
if (helpers.isNullOrUndef(tick.label)) {
return;
}
var label = tick.label;
var lineWidth, lineColor, borderDash, borderDashOffset;
if (index === me.zeroLineIndex && options.offset === gridLines.offsetGridLines) {
// Draw the first index specially
lineWidth = gridLines.zeroLineWidth;
lineColor = gridLines.zeroLineColor;
borderDash = gridLines.zeroLineBorderDash;
borderDashOffset = gridLines.zeroLineBorderDashOffset;
} else {
lineWidth = helpers.valueAtIndexOrDefault(gridLines.lineWidth, index);
lineColor = helpers.valueAtIndexOrDefault(gridLines.color, index);
borderDash = helpers.valueOrDefault(gridLines.borderDash, globalDefaults.borderDash);
borderDashOffset = helpers.valueOrDefault(gridLines.borderDashOffset, globalDefaults.borderDashOffset);
}
// Common properties
var tx1, ty1, tx2, ty2, x1, y1, x2, y2, labelX, labelY;
var textAlign = 'middle';
var textBaseline = 'middle';
var tickPadding = optionTicks.padding;
if (isHorizontal) {
var labelYOffset = tl + tickPadding;
if (options.position === 'bottom') {
// bottom
textBaseline = !isRotated ? 'top' : 'middle';
textAlign = !isRotated ? 'center' : 'right';
labelY = me.top + labelYOffset;
} else {
// top
textBaseline = !isRotated ? 'bottom' : 'middle';
textAlign = !isRotated ? 'center' : 'left';
labelY = me.bottom - labelYOffset;
}
var xLineValue = getLineValue(me, index, gridLines.offsetGridLines && ticks.length > 1);
if (xLineValue < me.left) {
lineColor = 'rgba(0,0,0,0)';
}
xLineValue += helpers.aliasPixel(lineWidth);
labelX = me.getPixelForTick(index) + optionTicks.labelOffset; // x values for optionTicks (need to consider offsetLabel option)
tx1 = tx2 = x1 = x2 = xLineValue;
ty1 = yTickStart;
ty2 = yTickEnd;
y1 = chartArea.top;
y2 = chartArea.bottom + axisWidth;
} else {
var isLeft = options.position === 'left';
var labelXOffset;
if (optionTicks.mirror) {
textAlign = isLeft ? 'left' : 'right';
labelXOffset = tickPadding;
} else {
textAlign = isLeft ? 'right' : 'left';
labelXOffset = tl + tickPadding;
}
labelX = isLeft ? me.right - labelXOffset : me.left + labelXOffset;
var yLineValue = getLineValue(me, index, gridLines.offsetGridLines && ticks.length > 1);
if (yLineValue < me.top) {
lineColor = 'rgba(0,0,0,0)';
}
yLineValue += helpers.aliasPixel(lineWidth);
labelY = me.getPixelForTick(index) + optionTicks.labelOffset;
tx1 = xTickStart;
tx2 = xTickEnd;
x1 = chartArea.left;
x2 = chartArea.right + axisWidth;
ty1 = ty2 = y1 = y2 = yLineValue;
}
itemsToDraw.push({
tx1: tx1,
ty1: ty1,
tx2: tx2,
ty2: ty2,
x1: x1,
y1: y1,
x2: x2,
y2: y2,
labelX: labelX,
labelY: labelY,
glWidth: lineWidth,
glColor: lineColor,
glBorderDash: borderDash,
glBorderDashOffset: borderDashOffset,
rotation: -1 * labelRotationRadians,
label: label,
major: tick.major,
textBaseline: textBaseline,
textAlign: textAlign
});
});
// Draw all of the tick labels, tick marks, and grid lines at the correct places
helpers.each(itemsToDraw, function(itemToDraw) {
if (gridLines.display) {
context.save();
context.lineWidth = itemToDraw.glWidth;
context.strokeStyle = itemToDraw.glColor;
if (context.setLineDash) {
context.setLineDash(itemToDraw.glBorderDash);
context.lineDashOffset = itemToDraw.glBorderDashOffset;
}
context.beginPath();
if (gridLines.drawTicks) {
context.moveTo(itemToDraw.tx1, itemToDraw.ty1);
context.lineTo(itemToDraw.tx2, itemToDraw.ty2);
}
if (gridLines.drawOnChartArea) {
context.moveTo(itemToDraw.x1, itemToDraw.y1);
context.lineTo(itemToDraw.x2, itemToDraw.y2);
}
context.stroke();
context.restore();
}
if (optionTicks.display) {
// Make sure we draw text in the correct color and font
context.save();
context.translate(itemToDraw.labelX, itemToDraw.labelY);
context.rotate(itemToDraw.rotation);
context.font = itemToDraw.major ? majorTickFont.font : tickFont.font;
context.fillStyle = itemToDraw.major ? majorTickFontColor : tickFontColor;
context.textBaseline = itemToDraw.textBaseline;
context.textAlign = itemToDraw.textAlign;
var label = itemToDraw.label;
if (helpers.isArray(label)) {
var lineCount = label.length;
var lineHeight = tickFont.size * 1.5;
var y = me.isHorizontal() ? 0 : -lineHeight * (lineCount - 1) / 2;
for (var i = 0; i < lineCount; ++i) {
// We just make sure the multiline element is a string here..
context.fillText('' + label[i], 0, y);
// apply same lineSpacing as calculated @ L#320
y += lineHeight;
}
} else {
context.fillText(label, 0, 0);
}
context.restore();
}
});
if (scaleLabel.display) {
// Draw the scale label
var scaleLabelX;
var scaleLabelY;
var rotation = 0;
var halfLineHeight = parseLineHeight(scaleLabel) / 2;
if (isHorizontal) {
scaleLabelX = me.left + ((me.right - me.left) / 2); // midpoint of the width
scaleLabelY = options.position === 'bottom'
? me.bottom - halfLineHeight - scaleLabelPadding.bottom
: me.top + halfLineHeight + scaleLabelPadding.top;
} else {
var isLeft = options.position === 'left';
scaleLabelX = isLeft
? me.left + halfLineHeight + scaleLabelPadding.top
: me.right - halfLineHeight - scaleLabelPadding.top;
scaleLabelY = me.top + ((me.bottom - me.top) / 2);
rotation = isLeft ? -0.5 * Math.PI : 0.5 * Math.PI;
}
context.save();
context.translate(scaleLabelX, scaleLabelY);
context.rotate(rotation);
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillStyle = scaleLabelFontColor; // render in correct colour
context.font = scaleLabelFont.font;
context.fillText(scaleLabel.labelString, 0, 0);
context.restore();
}
if (gridLines.drawBorder) {
// Draw the line at the edge of the axis
context.lineWidth = helpers.valueAtIndexOrDefault(gridLines.lineWidth, 0);
context.strokeStyle = helpers.valueAtIndexOrDefault(gridLines.color, 0);
var x1 = me.left;
var x2 = me.right + axisWidth;
var y1 = me.top;
var y2 = me.bottom + axisWidth;
var aliasPixel = helpers.aliasPixel(context.lineWidth);
if (isHorizontal) {
y1 = y2 = options.position === 'top' ? me.bottom : me.top;
y1 += aliasPixel;
y2 += aliasPixel;
} else {
x1 = x2 = options.position === 'left' ? me.right : me.left;
x1 += aliasPixel;
x2 += aliasPixel;
}
context.beginPath();
context.moveTo(x1, y1);
context.lineTo(x2, y2);
context.stroke();
}
}
});
};
},{"25":25,"26":26,"34":34,"45":45}],33:[function(require,module,exports){
'use strict';
var defaults = require(25);
var helpers = require(45);
var layouts = require(30);
module.exports = function(Chart) {
Chart.scaleService = {
// Scale registration object. Extensions can register new scale types (such as log or DB scales) and then
// use the new chart options to grab the correct scale
constructors: {},
// Use a registration function so that we can move to an ES6 map when we no longer need to support
// old browsers
// Scale config defaults
defaults: {},
registerScaleType: function(type, scaleConstructor, scaleDefaults) {
this.constructors[type] = scaleConstructor;
this.defaults[type] = helpers.clone(scaleDefaults);
},
getScaleConstructor: function(type) {
return this.constructors.hasOwnProperty(type) ? this.constructors[type] : undefined;
},
getScaleDefaults: function(type) {
// Return the scale defaults merged with the global settings so that we always use the latest ones
return this.defaults.hasOwnProperty(type) ? helpers.merge({}, [defaults.scale, this.defaults[type]]) : {};
},
updateScaleDefaults: function(type, additions) {
var me = this;
if (me.defaults.hasOwnProperty(type)) {
me.defaults[type] = helpers.extend(me.defaults[type], additions);
}
},
addScalesToLayout: function(chart) {
// Adds each scale to the chart.boxes array to be sized accordingly
helpers.each(chart.scales, function(scale) {
// Set ILayoutItem parameters for backwards compatibility
scale.fullWidth = scale.options.fullWidth;
scale.position = scale.options.position;
scale.weight = scale.options.weight;
layouts.addBox(chart, scale);
});
}
};
};
},{"25":25,"30":30,"45":45}],34:[function(require,module,exports){
'use strict';
var helpers = require(45);
/**
* Namespace to hold static tick generation functions
* @namespace Chart.Ticks
*/
module.exports = {
/**
* Namespace to hold formatters for different types of ticks
* @namespace Chart.Ticks.formatters
*/
formatters: {
/**
* Formatter for value labels
* @method Chart.Ticks.formatters.values
* @param value the value to display
* @return {String|Array} the label to display
*/
values: function(value) {
return helpers.isArray(value) ? value : '' + value;
},
/**
* Formatter for linear numeric ticks
* @method Chart.Ticks.formatters.linear
* @param tickValue {Number} the value to be formatted
* @param index {Number} the position of the tickValue parameter in the ticks array
* @param ticks {Array} the list of ticks being converted
* @return {String} string representation of the tickValue parameter
*/
linear: function(tickValue, index, ticks) {
// If we have lots of ticks, don't use the ones
var delta = ticks.length > 3 ? ticks[2] - ticks[1] : ticks[1] - ticks[0];
// If we have a number like 2.5 as the delta, figure out how many decimal places we need
if (Math.abs(delta) > 1) {
if (tickValue !== Math.floor(tickValue)) {
// not an integer
delta = tickValue - Math.floor(tickValue);
}
}
var logDelta = helpers.log10(Math.abs(delta));
var tickString = '';
if (tickValue !== 0) {
var numDecimal = -1 * Math.floor(logDelta);
numDecimal = Math.max(Math.min(numDecimal, 20), 0); // toFixed has a max of 20 decimal places
tickString = tickValue.toFixed(numDecimal);
} else {
tickString = '0'; // never show decimal places for 0
}
return tickString;
},
logarithmic: function(tickValue, index, ticks) {
var remain = tickValue / (Math.pow(10, Math.floor(helpers.log10(tickValue))));
if (tickValue === 0) {
return '0';
} else if (remain === 1 || remain === 2 || remain === 5 || index === 0 || index === ticks.length - 1) {
return tickValue.toExponential();
}
return '';
}
}
};
},{"45":45}],35:[function(require,module,exports){
'use strict';
var defaults = require(25);
var Element = require(26);
var helpers = require(45);
defaults._set('global', {
tooltips: {
enabled: true,
custom: null,
mode: 'nearest',
position: 'average',
intersect: true,
backgroundColor: 'rgba(0,0,0,0.8)',
titleFontStyle: 'bold',
titleSpacing: 2,
titleMarginBottom: 6,
titleFontColor: '#fff',
titleAlign: 'left',
bodySpacing: 2,
bodyFontColor: '#fff',
bodyAlign: 'left',
footerFontStyle: 'bold',
footerSpacing: 2,
footerMarginTop: 6,
footerFontColor: '#fff',
footerAlign: 'left',
yPadding: 6,
xPadding: 6,
caretPadding: 2,
caretSize: 5,
cornerRadius: 6,
multiKeyBackground: '#fff',
displayColors: true,
borderColor: 'rgba(0,0,0,0)',
borderWidth: 0,
callbacks: {
// Args are: (tooltipItems, data)
beforeTitle: helpers.noop,
title: function(tooltipItems, data) {
// Pick first xLabel for now
var title = '';
var labels = data.labels;
var labelCount = labels ? labels.length : 0;
if (tooltipItems.length > 0) {
var item = tooltipItems[0];
if (item.xLabel) {
title = item.xLabel;
} else if (labelCount > 0 && item.index < labelCount) {
title = labels[item.index];
}
}
return title;
},
afterTitle: helpers.noop,
// Args are: (tooltipItems, data)
beforeBody: helpers.noop,
// Args are: (tooltipItem, data)
beforeLabel: helpers.noop,
label: function(tooltipItem, data) {
var label = data.datasets[tooltipItem.datasetIndex].label || '';
if (label) {
label += ': ';
}
label += tooltipItem.yLabel;
return label;
},
labelColor: function(tooltipItem, chart) {
var meta = chart.getDatasetMeta(tooltipItem.datasetIndex);
var activeElement = meta.data[tooltipItem.index];
var view = activeElement._view;
return {
borderColor: view.borderColor,
backgroundColor: view.backgroundColor
};
},
labelTextColor: function() {
return this._options.bodyFontColor;
},
afterLabel: helpers.noop,
// Args are: (tooltipItems, data)
afterBody: helpers.noop,
// Args are: (tooltipItems, data)
beforeFooter: helpers.noop,
footer: helpers.noop,
afterFooter: helpers.noop
}
}
});
module.exports = function(Chart) {
/**
* Helper method to merge the opacity into a color
*/
function mergeOpacity(colorString, opacity) {
var color = helpers.color(colorString);
return color.alpha(opacity * color.alpha()).rgbaString();
}
// Helper to push or concat based on if the 2nd parameter is an array or not
function pushOrConcat(base, toPush) {
if (toPush) {
if (helpers.isArray(toPush)) {
// base = base.concat(toPush);
Array.prototype.push.apply(base, toPush);
} else {
base.push(toPush);
}
}
return base;
}
// Private helper to create a tooltip item model
// @param element : the chart element (point, arc, bar) to create the tooltip item for
// @return : new tooltip item
function createTooltipItem(element) {
var xScale = element._xScale;
var yScale = element._yScale || element._scale; // handle radar || polarArea charts
var index = element._index;
var datasetIndex = element._datasetIndex;
return {
xLabel: xScale ? xScale.getLabelForIndex(index, datasetIndex) : '',
yLabel: yScale ? yScale.getLabelForIndex(index, datasetIndex) : '',
index: index,
datasetIndex: datasetIndex,
x: element._model.x,
y: element._model.y
};
}
/**
* Helper to get the reset model for the tooltip
* @param tooltipOpts {Object} the tooltip options
*/
function getBaseModel(tooltipOpts) {
var globalDefaults = defaults.global;
var valueOrDefault = helpers.valueOrDefault;
return {
// Positioning
xPadding: tooltipOpts.xPadding,
yPadding: tooltipOpts.yPadding,
xAlign: tooltipOpts.xAlign,
yAlign: tooltipOpts.yAlign,
// Body
bodyFontColor: tooltipOpts.bodyFontColor,
_bodyFontFamily: valueOrDefault(tooltipOpts.bodyFontFamily, globalDefaults.defaultFontFamily),
_bodyFontStyle: valueOrDefault(tooltipOpts.bodyFontStyle, globalDefaults.defaultFontStyle),
_bodyAlign: tooltipOpts.bodyAlign,
bodyFontSize: valueOrDefault(tooltipOpts.bodyFontSize, globalDefaults.defaultFontSize),
bodySpacing: tooltipOpts.bodySpacing,
// Title
titleFontColor: tooltipOpts.titleFontColor,
_titleFontFamily: valueOrDefault(tooltipOpts.titleFontFamily, globalDefaults.defaultFontFamily),
_titleFontStyle: valueOrDefault(tooltipOpts.titleFontStyle, globalDefaults.defaultFontStyle),
titleFontSize: valueOrDefault(tooltipOpts.titleFontSize, globalDefaults.defaultFontSize),
_titleAlign: tooltipOpts.titleAlign,
titleSpacing: tooltipOpts.titleSpacing,
titleMarginBottom: tooltipOpts.titleMarginBottom,
// Footer
footerFontColor: tooltipOpts.footerFontColor,
_footerFontFamily: valueOrDefault(tooltipOpts.footerFontFamily, globalDefaults.defaultFontFamily),
_footerFontStyle: valueOrDefault(tooltipOpts.footerFontStyle, globalDefaults.defaultFontStyle),
footerFontSize: valueOrDefault(tooltipOpts.footerFontSize, globalDefaults.defaultFontSize),
_footerAlign: tooltipOpts.footerAlign,
footerSpacing: tooltipOpts.footerSpacing,
footerMarginTop: tooltipOpts.footerMarginTop,
// Appearance
caretSize: tooltipOpts.caretSize,
cornerRadius: tooltipOpts.cornerRadius,
backgroundColor: tooltipOpts.backgroundColor,
opacity: 0,
legendColorBackground: tooltipOpts.multiKeyBackground,
displayColors: tooltipOpts.displayColors,
borderColor: tooltipOpts.borderColor,
borderWidth: tooltipOpts.borderWidth
};
}
/**
* Get the size of the tooltip
*/
function getTooltipSize(tooltip, model) {
var ctx = tooltip._chart.ctx;
var height = model.yPadding * 2; // Tooltip Padding
var width = 0;
// Count of all lines in the body
var body = model.body;
var combinedBodyLength = body.reduce(function(count, bodyItem) {
return count + bodyItem.before.length + bodyItem.lines.length + bodyItem.after.length;
}, 0);
combinedBodyLength += model.beforeBody.length + model.afterBody.length;
var titleLineCount = model.title.length;
var footerLineCount = model.footer.length;
var titleFontSize = model.titleFontSize;
var bodyFontSize = model.bodyFontSize;
var footerFontSize = model.footerFontSize;
height += titleLineCount * titleFontSize; // Title Lines
height += titleLineCount ? (titleLineCount - 1) * model.titleSpacing : 0; // Title Line Spacing
height += titleLineCount ? model.titleMarginBottom : 0; // Title's bottom Margin
height += combinedBodyLength * bodyFontSize; // Body Lines
height += combinedBodyLength ? (combinedBodyLength - 1) * model.bodySpacing : 0; // Body Line Spacing
height += footerLineCount ? model.footerMarginTop : 0; // Footer Margin
height += footerLineCount * (footerFontSize); // Footer Lines
height += footerLineCount ? (footerLineCount - 1) * model.footerSpacing : 0; // Footer Line Spacing
// Title width
var widthPadding = 0;
var maxLineWidth = function(line) {
width = Math.max(width, ctx.measureText(line).width + widthPadding);
};
ctx.font = helpers.fontString(titleFontSize, model._titleFontStyle, model._titleFontFamily);
helpers.each(model.title, maxLineWidth);
// Body width
ctx.font = helpers.fontString(bodyFontSize, model._bodyFontStyle, model._bodyFontFamily);
helpers.each(model.beforeBody.concat(model.afterBody), maxLineWidth);
// Body lines may include some extra width due to the color box
widthPadding = model.displayColors ? (bodyFontSize + 2) : 0;
helpers.each(body, function(bodyItem) {
helpers.each(bodyItem.before, maxLineWidth);
helpers.each(bodyItem.lines, maxLineWidth);
helpers.each(bodyItem.after, maxLineWidth);
});
// Reset back to 0
widthPadding = 0;
// Footer width
ctx.font = helpers.fontString(footerFontSize, model._footerFontStyle, model._footerFontFamily);
helpers.each(model.footer, maxLineWidth);
// Add padding
width += 2 * model.xPadding;
return {
width: width,
height: height
};
}
/**
* Helper to get the alignment of a tooltip given the size
*/
function determineAlignment(tooltip, size) {
var model = tooltip._model;
var chart = tooltip._chart;
var chartArea = tooltip._chart.chartArea;
var xAlign = 'center';
var yAlign = 'center';
if (model.y < size.height) {
yAlign = 'top';
} else if (model.y > (chart.height - size.height)) {
yAlign = 'bottom';
}
var lf, rf; // functions to determine left, right alignment
var olf, orf; // functions to determine if left/right alignment causes tooltip to go outside chart
var yf; // function to get the y alignment if the tooltip goes outside of the left or right edges
var midX = (chartArea.left + chartArea.right) / 2;
var midY = (chartArea.top + chartArea.bottom) / 2;
if (yAlign === 'center') {
lf = function(x) {
return x <= midX;
};
rf = function(x) {
return x > midX;
};
} else {
lf = function(x) {
return x <= (size.width / 2);
};
rf = function(x) {
return x >= (chart.width - (size.width / 2));
};
}
olf = function(x) {
return x + size.width + model.caretSize + model.caretPadding > chart.width;
};
orf = function(x) {
return x - size.width - model.caretSize - model.caretPadding < 0;
};
yf = function(y) {
return y <= midY ? 'top' : 'bottom';
};
if (lf(model.x)) {
xAlign = 'left';
// Is tooltip too wide and goes over the right side of the chart.?
if (olf(model.x)) {
xAlign = 'center';
yAlign = yf(model.y);
}
} else if (rf(model.x)) {
xAlign = 'right';
// Is tooltip too wide and goes outside left edge of canvas?
if (orf(model.x)) {
xAlign = 'center';
yAlign = yf(model.y);
}
}
var opts = tooltip._options;
return {
xAlign: opts.xAlign ? opts.xAlign : xAlign,
yAlign: opts.yAlign ? opts.yAlign : yAlign
};
}
/**
* @Helper to get the location a tooltip needs to be placed at given the initial position (via the vm) and the size and alignment
*/
function getBackgroundPoint(vm, size, alignment, chart) {
// Background Position
var x = vm.x;
var y = vm.y;
var caretSize = vm.caretSize;
var caretPadding = vm.caretPadding;
var cornerRadius = vm.cornerRadius;
var xAlign = alignment.xAlign;
var yAlign = alignment.yAlign;
var paddingAndSize = caretSize + caretPadding;
var radiusAndPadding = cornerRadius + caretPadding;
if (xAlign === 'right') {
x -= size.width;
} else if (xAlign === 'center') {
x -= (size.width / 2);
if (x + size.width > chart.width) {
x = chart.width - size.width;
}
if (x < 0) {
x = 0;
}
}
if (yAlign === 'top') {
y += paddingAndSize;
} else if (yAlign === 'bottom') {
y -= size.height + paddingAndSize;
} else {
y -= (size.height / 2);
}
if (yAlign === 'center') {
if (xAlign === 'left') {
x += paddingAndSize;
} else if (xAlign === 'right') {
x -= paddingAndSize;
}
} else if (xAlign === 'left') {
x -= radiusAndPadding;
} else if (xAlign === 'right') {
x += radiusAndPadding;
}
return {
x: x,
y: y
};
}
Chart.Tooltip = Element.extend({
initialize: function() {
this._model = getBaseModel(this._options);
this._lastActive = [];
},
// Get the title
// Args are: (tooltipItem, data)
getTitle: function() {
var me = this;
var opts = me._options;
var callbacks = opts.callbacks;
var beforeTitle = callbacks.beforeTitle.apply(me, arguments);
var title = callbacks.title.apply(me, arguments);
var afterTitle = callbacks.afterTitle.apply(me, arguments);
var lines = [];
lines = pushOrConcat(lines, beforeTitle);
lines = pushOrConcat(lines, title);
lines = pushOrConcat(lines, afterTitle);
return lines;
},
// Args are: (tooltipItem, data)
getBeforeBody: function() {
var lines = this._options.callbacks.beforeBody.apply(this, arguments);
return helpers.isArray(lines) ? lines : lines !== undefined ? [lines] : [];
},
// Args are: (tooltipItem, data)
getBody: function(tooltipItems, data) {
var me = this;
var callbacks = me._options.callbacks;
var bodyItems = [];
helpers.each(tooltipItems, function(tooltipItem) {
var bodyItem = {
before: [],
lines: [],
after: []
};
pushOrConcat(bodyItem.before, callbacks.beforeLabel.call(me, tooltipItem, data));
pushOrConcat(bodyItem.lines, callbacks.label.call(me, tooltipItem, data));
pushOrConcat(bodyItem.after, callbacks.afterLabel.call(me, tooltipItem, data));
bodyItems.push(bodyItem);
});
return bodyItems;
},
// Args are: (tooltipItem, data)
getAfterBody: function() {
var lines = this._options.callbacks.afterBody.apply(this, arguments);
return helpers.isArray(lines) ? lines : lines !== undefined ? [lines] : [];
},
// Get the footer and beforeFooter and afterFooter lines
// Args are: (tooltipItem, data)
getFooter: function() {
var me = this;
var callbacks = me._options.callbacks;
var beforeFooter = callbacks.beforeFooter.apply(me, arguments);
var footer = callbacks.footer.apply(me, arguments);
var afterFooter = callbacks.afterFooter.apply(me, arguments);
var lines = [];
lines = pushOrConcat(lines, beforeFooter);
lines = pushOrConcat(lines, footer);
lines = pushOrConcat(lines, afterFooter);
return lines;
},
update: function(changed) {
var me = this;
var opts = me._options;
// Need to regenerate the model because its faster than using extend and it is necessary due to the optimization in Chart.Element.transition
// that does _view = _model if ease === 1. This causes the 2nd tooltip update to set properties in both the view and model at the same time
// which breaks any animations.
var existingModel = me._model;
var model = me._model = getBaseModel(opts);
var active = me._active;
var data = me._data;
// In the case where active.length === 0 we need to keep these at existing values for good animations
var alignment = {
xAlign: existingModel.xAlign,
yAlign: existingModel.yAlign
};
var backgroundPoint = {
x: existingModel.x,
y: existingModel.y
};
var tooltipSize = {
width: existingModel.width,
height: existingModel.height
};
var tooltipPosition = {
x: existingModel.caretX,
y: existingModel.caretY
};
var i, len;
if (active.length) {
model.opacity = 1;
var labelColors = [];
var labelTextColors = [];
tooltipPosition = Chart.Tooltip.positioners[opts.position].call(me, active, me._eventPosition);
var tooltipItems = [];
for (i = 0, len = active.length; i < len; ++i) {
tooltipItems.push(createTooltipItem(active[i]));
}
// If the user provided a filter function, use it to modify the tooltip items
if (opts.filter) {
tooltipItems = tooltipItems.filter(function(a) {
return opts.filter(a, data);
});
}
// If the user provided a sorting function, use it to modify the tooltip items
if (opts.itemSort) {
tooltipItems = tooltipItems.sort(function(a, b) {
return opts.itemSort(a, b, data);
});
}
// Determine colors for boxes
helpers.each(tooltipItems, function(tooltipItem) {
labelColors.push(opts.callbacks.labelColor.call(me, tooltipItem, me._chart));
labelTextColors.push(opts.callbacks.labelTextColor.call(me, tooltipItem, me._chart));
});
// Build the Text Lines
model.title = me.getTitle(tooltipItems, data);
model.beforeBody = me.getBeforeBody(tooltipItems, data);
model.body = me.getBody(tooltipItems, data);
model.afterBody = me.getAfterBody(tooltipItems, data);
model.footer = me.getFooter(tooltipItems, data);
// Initial positioning and colors
model.x = Math.round(tooltipPosition.x);
model.y = Math.round(tooltipPosition.y);
model.caretPadding = opts.caretPadding;
model.labelColors = labelColors;
model.labelTextColors = labelTextColors;
// data points
model.dataPoints = tooltipItems;
// We need to determine alignment of the tooltip
tooltipSize = getTooltipSize(this, model);
alignment = determineAlignment(this, tooltipSize);
// Final Size and Position
backgroundPoint = getBackgroundPoint(model, tooltipSize, alignment, me._chart);
} else {
model.opacity = 0;
}
model.xAlign = alignment.xAlign;
model.yAlign = alignment.yAlign;
model.x = backgroundPoint.x;
model.y = backgroundPoint.y;
model.width = tooltipSize.width;
model.height = tooltipSize.height;
// Point where the caret on the tooltip points to
model.caretX = tooltipPosition.x;
model.caretY = tooltipPosition.y;
me._model = model;
if (changed && opts.custom) {
opts.custom.call(me, model);
}
return me;
},
drawCaret: function(tooltipPoint, size) {
var ctx = this._chart.ctx;
var vm = this._view;
var caretPosition = this.getCaretPosition(tooltipPoint, size, vm);
ctx.lineTo(caretPosition.x1, caretPosition.y1);
ctx.lineTo(caretPosition.x2, caretPosition.y2);
ctx.lineTo(caretPosition.x3, caretPosition.y3);
},
getCaretPosition: function(tooltipPoint, size, vm) {
var x1, x2, x3, y1, y2, y3;
var caretSize = vm.caretSize;
var cornerRadius = vm.cornerRadius;
var xAlign = vm.xAlign;
var yAlign = vm.yAlign;
var ptX = tooltipPoint.x;
var ptY = tooltipPoint.y;
var width = size.width;
var height = size.height;
if (yAlign === 'center') {
y2 = ptY + (height / 2);
if (xAlign === 'left') {
x1 = ptX;
x2 = x1 - caretSize;
x3 = x1;
y1 = y2 + caretSize;
y3 = y2 - caretSize;
} else {
x1 = ptX + width;
x2 = x1 + caretSize;
x3 = x1;
y1 = y2 - caretSize;
y3 = y2 + caretSize;
}
} else {
if (xAlign === 'left') {
x2 = ptX + cornerRadius + (caretSize);
x1 = x2 - caretSize;
x3 = x2 + caretSize;
} else if (xAlign === 'right') {
x2 = ptX + width - cornerRadius - caretSize;
x1 = x2 - caretSize;
x3 = x2 + caretSize;
} else {
x2 = vm.caretX;
x1 = x2 - caretSize;
x3 = x2 + caretSize;
}
if (yAlign === 'top') {
y1 = ptY;
y2 = y1 - caretSize;
y3 = y1;
} else {
y1 = ptY + height;
y2 = y1 + caretSize;
y3 = y1;
// invert drawing order
var tmp = x3;
x3 = x1;
x1 = tmp;
}
}
return {x1: x1, x2: x2, x3: x3, y1: y1, y2: y2, y3: y3};
},
drawTitle: function(pt, vm, ctx, opacity) {
var title = vm.title;
if (title.length) {
ctx.textAlign = vm._titleAlign;
ctx.textBaseline = 'top';
var titleFontSize = vm.titleFontSize;
var titleSpacing = vm.titleSpacing;
ctx.fillStyle = mergeOpacity(vm.titleFontColor, opacity);
ctx.font = helpers.fontString(titleFontSize, vm._titleFontStyle, vm._titleFontFamily);
var i, len;
for (i = 0, len = title.length; i < len; ++i) {
ctx.fillText(title[i], pt.x, pt.y);
pt.y += titleFontSize + titleSpacing; // Line Height and spacing
if (i + 1 === title.length) {
pt.y += vm.titleMarginBottom - titleSpacing; // If Last, add margin, remove spacing
}
}
}
},
drawBody: function(pt, vm, ctx, opacity) {
var bodyFontSize = vm.bodyFontSize;
var bodySpacing = vm.bodySpacing;
var body = vm.body;
ctx.textAlign = vm._bodyAlign;
ctx.textBaseline = 'top';
ctx.font = helpers.fontString(bodyFontSize, vm._bodyFontStyle, vm._bodyFontFamily);
// Before Body
var xLinePadding = 0;
var fillLineOfText = function(line) {
ctx.fillText(line, pt.x + xLinePadding, pt.y);
pt.y += bodyFontSize + bodySpacing;
};
// Before body lines
ctx.fillStyle = mergeOpacity(vm.bodyFontColor, opacity);
helpers.each(vm.beforeBody, fillLineOfText);
var drawColorBoxes = vm.displayColors;
xLinePadding = drawColorBoxes ? (bodyFontSize + 2) : 0;
// Draw body lines now
helpers.each(body, function(bodyItem, i) {
var textColor = mergeOpacity(vm.labelTextColors[i], opacity);
ctx.fillStyle = textColor;
helpers.each(bodyItem.before, fillLineOfText);
helpers.each(bodyItem.lines, function(line) {
// Draw Legend-like boxes if needed
if (drawColorBoxes) {
// Fill a white rect so that colours merge nicely if the opacity is < 1
ctx.fillStyle = mergeOpacity(vm.legendColorBackground, opacity);
ctx.fillRect(pt.x, pt.y, bodyFontSize, bodyFontSize);
// Border
ctx.lineWidth = 1;
ctx.strokeStyle = mergeOpacity(vm.labelColors[i].borderColor, opacity);
ctx.strokeRect(pt.x, pt.y, bodyFontSize, bodyFontSize);
// Inner square
ctx.fillStyle = mergeOpacity(vm.labelColors[i].backgroundColor, opacity);
ctx.fillRect(pt.x + 1, pt.y + 1, bodyFontSize - 2, bodyFontSize - 2);
ctx.fillStyle = textColor;
}
fillLineOfText(line);
});
helpers.each(bodyItem.after, fillLineOfText);
});
// Reset back to 0 for after body
xLinePadding = 0;
// After body lines
helpers.each(vm.afterBody, fillLineOfText);
pt.y -= bodySpacing; // Remove last body spacing
},
drawFooter: function(pt, vm, ctx, opacity) {
var footer = vm.footer;
if (footer.length) {
pt.y += vm.footerMarginTop;
ctx.textAlign = vm._footerAlign;
ctx.textBaseline = 'top';
ctx.fillStyle = mergeOpacity(vm.footerFontColor, opacity);
ctx.font = helpers.fontString(vm.footerFontSize, vm._footerFontStyle, vm._footerFontFamily);
helpers.each(footer, function(line) {
ctx.fillText(line, pt.x, pt.y);
pt.y += vm.footerFontSize + vm.footerSpacing;
});
}
},
drawBackground: function(pt, vm, ctx, tooltipSize, opacity) {
ctx.fillStyle = mergeOpacity(vm.backgroundColor, opacity);
ctx.strokeStyle = mergeOpacity(vm.borderColor, opacity);
ctx.lineWidth = vm.borderWidth;
var xAlign = vm.xAlign;
var yAlign = vm.yAlign;
var x = pt.x;
var y = pt.y;
var width = tooltipSize.width;
var height = tooltipSize.height;
var radius = vm.cornerRadius;
ctx.beginPath();
ctx.moveTo(x + radius, y);
if (yAlign === 'top') {
this.drawCaret(pt, tooltipSize);
}
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
if (yAlign === 'center' && xAlign === 'right') {
this.drawCaret(pt, tooltipSize);
}
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
if (yAlign === 'bottom') {
this.drawCaret(pt, tooltipSize);
}
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
if (yAlign === 'center' && xAlign === 'left') {
this.drawCaret(pt, tooltipSize);
}
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
ctx.fill();
if (vm.borderWidth > 0) {
ctx.stroke();
}
},
draw: function() {
var ctx = this._chart.ctx;
var vm = this._view;
if (vm.opacity === 0) {
return;
}
var tooltipSize = {
width: vm.width,
height: vm.height
};
var pt = {
x: vm.x,
y: vm.y
};
// IE11/Edge does not like very small opacities, so snap to 0
var opacity = Math.abs(vm.opacity < 1e-3) ? 0 : vm.opacity;
// Truthy/falsey value for empty tooltip
var hasTooltipContent = vm.title.length || vm.beforeBody.length || vm.body.length || vm.afterBody.length || vm.footer.length;
if (this._options.enabled && hasTooltipContent) {
// Draw Background
this.drawBackground(pt, vm, ctx, tooltipSize, opacity);
// Draw Title, Body, and Footer
pt.x += vm.xPadding;
pt.y += vm.yPadding;
// Titles
this.drawTitle(pt, vm, ctx, opacity);
// Body
this.drawBody(pt, vm, ctx, opacity);
// Footer
this.drawFooter(pt, vm, ctx, opacity);
}
},
/**
* Handle an event
* @private
* @param {IEvent} event - The event to handle
* @returns {Boolean} true if the tooltip changed
*/
handleEvent: function(e) {
var me = this;
var options = me._options;
var changed = false;
me._lastActive = me._lastActive || [];
// Find Active Elements for tooltips
if (e.type === 'mouseout') {
me._active = [];
} else {
me._active = me._chart.getElementsAtEventForMode(e, options.mode, options);
}
// Remember Last Actives
changed = !helpers.arrayEquals(me._active, me._lastActive);
// Only handle target event on tooltip change
if (changed) {
me._lastActive = me._active;
if (options.enabled || options.custom) {
me._eventPosition = {
x: e.x,
y: e.y
};
me.update(true);
me.pivot();
}
}
return changed;
}
});
/**
* @namespace Chart.Tooltip.positioners
*/
Chart.Tooltip.positioners = {
/**
* Average mode places the tooltip at the average position of the elements shown
* @function Chart.Tooltip.positioners.average
* @param elements {ChartElement[]} the elements being displayed in the tooltip
* @returns {Point} tooltip position
*/
average: function(elements) {
if (!elements.length) {
return false;
}
var i, len;
var x = 0;
var y = 0;
var count = 0;
for (i = 0, len = elements.length; i < len; ++i) {
var el = elements[i];
if (el && el.hasValue()) {
var pos = el.tooltipPosition();
x += pos.x;
y += pos.y;
++count;
}
}
return {
x: Math.round(x / count),
y: Math.round(y / count)
};
},
/**
* Gets the tooltip position nearest of the item nearest to the event position
* @function Chart.Tooltip.positioners.nearest
* @param elements {Chart.Element[]} the tooltip elements
* @param eventPosition {Point} the position of the event in canvas coordinates
* @returns {Point} the tooltip position
*/
nearest: function(elements, eventPosition) {
var x = eventPosition.x;
var y = eventPosition.y;
var minDistance = Number.POSITIVE_INFINITY;
var i, len, nearestElement;
for (i = 0, len = elements.length; i < len; ++i) {
var el = elements[i];
if (el && el.hasValue()) {
var center = el.getCenterPoint();
var d = helpers.distanceBetweenPoints(eventPosition, center);
if (d < minDistance) {
minDistance = d;
nearestElement = el;
}
}
}
if (nearestElement) {
var tp = nearestElement.tooltipPosition();
x = tp.x;
y = tp.y;
}
return {
x: x,
y: y
};
}
};
};
},{"25":25,"26":26,"45":45}],36:[function(require,module,exports){
'use strict';
var defaults = require(25);
var Element = require(26);
var helpers = require(45);
defaults._set('global', {
elements: {
arc: {
backgroundColor: defaults.global.defaultColor,
borderColor: '#fff',
borderWidth: 2
}
}
});
module.exports = Element.extend({
inLabelRange: function(mouseX) {
var vm = this._view;
if (vm) {
return (Math.pow(mouseX - vm.x, 2) < Math.pow(vm.radius + vm.hoverRadius, 2));
}
return false;
},
inRange: function(chartX, chartY) {
var vm = this._view;
if (vm) {
var pointRelativePosition = helpers.getAngleFromPoint(vm, {x: chartX, y: chartY});
var angle = pointRelativePosition.angle;
var distance = pointRelativePosition.distance;
// Sanitise angle range
var startAngle = vm.startAngle;
var endAngle = vm.endAngle;
while (endAngle < startAngle) {
endAngle += 2.0 * Math.PI;
}
while (angle > endAngle) {
angle -= 2.0 * Math.PI;
}
while (angle < startAngle) {
angle += 2.0 * Math.PI;
}
// Check if within the range of the open/close angle
var betweenAngles = (angle >= startAngle && angle <= endAngle);
var withinRadius = (distance >= vm.innerRadius && distance <= vm.outerRadius);
return (betweenAngles && withinRadius);
}
return false;
},
getCenterPoint: function() {
var vm = this._view;
var halfAngle = (vm.startAngle + vm.endAngle) / 2;
var halfRadius = (vm.innerRadius + vm.outerRadius) / 2;
return {
x: vm.x + Math.cos(halfAngle) * halfRadius,
y: vm.y + Math.sin(halfAngle) * halfRadius
};
},
getArea: function() {
var vm = this._view;
return Math.PI * ((vm.endAngle - vm.startAngle) / (2 * Math.PI)) * (Math.pow(vm.outerRadius, 2) - Math.pow(vm.innerRadius, 2));
},
tooltipPosition: function() {
var vm = this._view;
var centreAngle = vm.startAngle + ((vm.endAngle - vm.startAngle) / 2);
var rangeFromCentre = (vm.outerRadius - vm.innerRadius) / 2 + vm.innerRadius;
return {
x: vm.x + (Math.cos(centreAngle) * rangeFromCentre),
y: vm.y + (Math.sin(centreAngle) * rangeFromCentre)
};
},
draw: function() {
var ctx = this._chart.ctx;
var vm = this._view;
var sA = vm.startAngle;
var eA = vm.endAngle;
ctx.beginPath();
ctx.arc(vm.x, vm.y, vm.outerRadius, sA, eA);
ctx.arc(vm.x, vm.y, vm.innerRadius, eA, sA, true);
ctx.closePath();
ctx.strokeStyle = vm.borderColor;
ctx.lineWidth = vm.borderWidth;
ctx.fillStyle = vm.backgroundColor;
ctx.fill();
ctx.lineJoin = 'bevel';
if (vm.borderWidth) {
ctx.stroke();
}
}
});
},{"25":25,"26":26,"45":45}],37:[function(require,module,exports){
'use strict';
var defaults = require(25);
var Element = require(26);
var helpers = require(45);
var globalDefaults = defaults.global;
defaults._set('global', {
elements: {
line: {
tension: 0.4,
backgroundColor: globalDefaults.defaultColor,
borderWidth: 3,
borderColor: globalDefaults.defaultColor,
borderCapStyle: 'butt',
borderDash: [],
borderDashOffset: 0.0,
borderJoinStyle: 'miter',
capBezierPoints: true,
fill: true, // do we fill in the area between the line and its base axis
}
}
});
module.exports = Element.extend({
draw: function() {
var me = this;
var vm = me._view;
var ctx = me._chart.ctx;
var spanGaps = vm.spanGaps;
var points = me._children.slice(); // clone array
var globalOptionLineElements = globalDefaults.elements.line;
var lastDrawnIndex = -1;
var index, current, previous, currentVM;
// If we are looping, adding the first point again
if (me._loop && points.length) {
points.push(points[0]);
}
ctx.save();
// Stroke Line Options
ctx.lineCap = vm.borderCapStyle || globalOptionLineElements.borderCapStyle;
// IE 9 and 10 do not support line dash
if (ctx.setLineDash) {
ctx.setLineDash(vm.borderDash || globalOptionLineElements.borderDash);
}
ctx.lineDashOffset = vm.borderDashOffset || globalOptionLineElements.borderDashOffset;
ctx.lineJoin = vm.borderJoinStyle || globalOptionLineElements.borderJoinStyle;
ctx.lineWidth = vm.borderWidth || globalOptionLineElements.borderWidth;
ctx.strokeStyle = vm.borderColor || globalDefaults.defaultColor;
// Stroke Line
ctx.beginPath();
lastDrawnIndex = -1;
for (index = 0; index < points.length; ++index) {
current = points[index];
previous = helpers.previousItem(points, index);
currentVM = current._view;
// First point moves to it's starting position no matter what
if (index === 0) {
if (!currentVM.skip) {
ctx.moveTo(currentVM.x, currentVM.y);
lastDrawnIndex = index;
}
} else {
previous = lastDrawnIndex === -1 ? previous : points[lastDrawnIndex];
if (!currentVM.skip) {
if ((lastDrawnIndex !== (index - 1) && !spanGaps) || lastDrawnIndex === -1) {
// There was a gap and this is the first point after the gap
ctx.moveTo(currentVM.x, currentVM.y);
} else {
// Line to next point
helpers.canvas.lineTo(ctx, previous._view, current._view);
}
lastDrawnIndex = index;
}
}
}
ctx.stroke();
ctx.restore();
}
});
},{"25":25,"26":26,"45":45}],38:[function(require,module,exports){
'use strict';
var defaults = require(25);
var Element = require(26);
var helpers = require(45);
var defaultColor = defaults.global.defaultColor;
defaults._set('global', {
elements: {
point: {
radius: 3,
pointStyle: 'circle',
backgroundColor: defaultColor,
borderColor: defaultColor,
borderWidth: 1,
// Hover
hitRadius: 1,
hoverRadius: 4,
hoverBorderWidth: 1
}
}
});
function xRange(mouseX) {
var vm = this._view;
return vm ? (Math.abs(mouseX - vm.x) < vm.radius + vm.hitRadius) : false;
}
function yRange(mouseY) {
var vm = this._view;
return vm ? (Math.abs(mouseY - vm.y) < vm.radius + vm.hitRadius) : false;
}
module.exports = Element.extend({
inRange: function(mouseX, mouseY) {
var vm = this._view;
return vm ? ((Math.pow(mouseX - vm.x, 2) + Math.pow(mouseY - vm.y, 2)) < Math.pow(vm.hitRadius + vm.radius, 2)) : false;
},
inLabelRange: xRange,
inXRange: xRange,
inYRange: yRange,
getCenterPoint: function() {
var vm = this._view;
return {
x: vm.x,
y: vm.y
};
},
getArea: function() {
return Math.PI * Math.pow(this._view.radius, 2);
},
tooltipPosition: function() {
var vm = this._view;
return {
x: vm.x,
y: vm.y,
padding: vm.radius + vm.borderWidth
};
},
draw: function(chartArea) {
var vm = this._view;
var model = this._model;
var ctx = this._chart.ctx;
var pointStyle = vm.pointStyle;
var radius = vm.radius;
var x = vm.x;
var y = vm.y;
var color = helpers.color;
var errMargin = 1.01; // 1.01 is margin for Accumulated error. (Especially Edge, IE.)
var ratio = 0;
if (vm.skip) {
return;
}
ctx.strokeStyle = vm.borderColor || defaultColor;
ctx.lineWidth = helpers.valueOrDefault(vm.borderWidth, defaults.global.elements.point.borderWidth);
ctx.fillStyle = vm.backgroundColor || defaultColor;
// Cliping for Points.
// going out from inner charArea?
if ((chartArea !== undefined) && ((model.x < chartArea.left) || (chartArea.right * errMargin < model.x) || (model.y < chartArea.top) || (chartArea.bottom * errMargin < model.y))) {
// Point fade out
if (model.x < chartArea.left) {
ratio = (x - model.x) / (chartArea.left - model.x);
} else if (chartArea.right * errMargin < model.x) {
ratio = (model.x - x) / (model.x - chartArea.right);
} else if (model.y < chartArea.top) {
ratio = (y - model.y) / (chartArea.top - model.y);
} else if (chartArea.bottom * errMargin < model.y) {
ratio = (model.y - y) / (model.y - chartArea.bottom);
}
ratio = Math.round(ratio * 100) / 100;
ctx.strokeStyle = color(ctx.strokeStyle).alpha(ratio).rgbString();
ctx.fillStyle = color(ctx.fillStyle).alpha(ratio).rgbString();
}
helpers.canvas.drawPoint(ctx, pointStyle, radius, x, y);
}
});
},{"25":25,"26":26,"45":45}],39:[function(require,module,exports){
'use strict';
var defaults = require(25);
var Element = require(26);
defaults._set('global', {
elements: {
rectangle: {
backgroundColor: defaults.global.defaultColor,
borderColor: defaults.global.defaultColor,
borderSkipped: 'bottom',
borderWidth: 0
}
}
});
function isVertical(bar) {
return bar._view.width !== undefined;
}
/**
* Helper function to get the bounds of the bar regardless of the orientation
* @param bar {Chart.Element.Rectangle} the bar
* @return {Bounds} bounds of the bar
* @private
*/
function getBarBounds(bar) {
var vm = bar._view;
var x1, x2, y1, y2;
if (isVertical(bar)) {
// vertical
var halfWidth = vm.width / 2;
x1 = vm.x - halfWidth;
x2 = vm.x + halfWidth;
y1 = Math.min(vm.y, vm.base);
y2 = Math.max(vm.y, vm.base);
} else {
// horizontal bar
var halfHeight = vm.height / 2;
x1 = Math.min(vm.x, vm.base);
x2 = Math.max(vm.x, vm.base);
y1 = vm.y - halfHeight;
y2 = vm.y + halfHeight;
}
return {
left: x1,
top: y1,
right: x2,
bottom: y2
};
}
module.exports = Element.extend({
draw: function() {
var ctx = this._chart.ctx;
var vm = this._view;
var left, right, top, bottom, signX, signY, borderSkipped;
var borderWidth = vm.borderWidth;
if (!vm.horizontal) {
// bar
left = vm.x - vm.width / 2;
right = vm.x + vm.width / 2;
top = vm.y;
bottom = vm.base;
signX = 1;
signY = bottom > top ? 1 : -1;
borderSkipped = vm.borderSkipped || 'bottom';
} else {
// horizontal bar
left = vm.base;
right = vm.x;
top = vm.y - vm.height / 2;
bottom = vm.y + vm.height / 2;
signX = right > left ? 1 : -1;
signY = 1;
borderSkipped = vm.borderSkipped || 'left';
}
// Canvas doesn't allow us to stroke inside the width so we can
// adjust the sizes to fit if we're setting a stroke on the line
if (borderWidth) {
// borderWidth should be less than bar width and bar height.
var barSize = Math.min(Math.abs(left - right), Math.abs(top - bottom));
borderWidth = borderWidth > barSize ? barSize : borderWidth;
var halfStroke = borderWidth / 2;
// Adjust borderWidth when bar top position is near vm.base(zero).
var borderLeft = left + (borderSkipped !== 'left' ? halfStroke * signX : 0);
var borderRight = right + (borderSkipped !== 'right' ? -halfStroke * signX : 0);
var borderTop = top + (borderSkipped !== 'top' ? halfStroke * signY : 0);
var borderBottom = bottom + (borderSkipped !== 'bottom' ? -halfStroke * signY : 0);
// not become a vertical line?
if (borderLeft !== borderRight) {
top = borderTop;
bottom = borderBottom;
}
// not become a horizontal line?
if (borderTop !== borderBottom) {
left = borderLeft;
right = borderRight;
}
}
ctx.beginPath();
ctx.fillStyle = vm.backgroundColor;
ctx.strokeStyle = vm.borderColor;
ctx.lineWidth = borderWidth;
// Corner points, from bottom-left to bottom-right clockwise
// | 1 2 |
// | 0 3 |
var corners = [
[left, bottom],
[left, top],
[right, top],
[right, bottom]
];
// Find first (starting) corner with fallback to 'bottom'
var borders = ['bottom', 'left', 'top', 'right'];
var startCorner = borders.indexOf(borderSkipped, 0);
if (startCorner === -1) {
startCorner = 0;
}
function cornerAt(index) {
return corners[(startCorner + index) % 4];
}
// Draw rectangle from 'startCorner'
var corner = cornerAt(0);
ctx.moveTo(corner[0], corner[1]);
for (var i = 1; i < 4; i++) {
corner = cornerAt(i);
ctx.lineTo(corner[0], corner[1]);
}
ctx.fill();
if (borderWidth) {
ctx.stroke();
}
},
height: function() {
var vm = this._view;
return vm.base - vm.y;
},
inRange: function(mouseX, mouseY) {
var inRange = false;
if (this._view) {
var bounds = getBarBounds(this);
inRange = mouseX >= bounds.left && mouseX <= bounds.right && mouseY >= bounds.top && mouseY <= bounds.bottom;
}
return inRange;
},
inLabelRange: function(mouseX, mouseY) {
var me = this;
if (!me._view) {
return false;
}
var inRange = false;
var bounds = getBarBounds(me);
if (isVertical(me)) {
inRange = mouseX >= bounds.left && mouseX <= bounds.right;
} else {
inRange = mouseY >= bounds.top && mouseY <= bounds.bottom;
}
return inRange;
},
inXRange: function(mouseX) {
var bounds = getBarBounds(this);
return mouseX >= bounds.left && mouseX <= bounds.right;
},
inYRange: function(mouseY) {
var bounds = getBarBounds(this);
return mouseY >= bounds.top && mouseY <= bounds.bottom;
},
getCenterPoint: function() {
var vm = this._view;
var x, y;
if (isVertical(this)) {
x = vm.x;
y = (vm.y + vm.base) / 2;
} else {
x = (vm.x + vm.base) / 2;
y = vm.y;
}
return {x: x, y: y};
},
getArea: function() {
var vm = this._view;
return vm.width * Math.abs(vm.y - vm.base);
},
tooltipPosition: function() {
var vm = this._view;
return {
x: vm.x,
y: vm.y
};
}
});
},{"25":25,"26":26}],40:[function(require,module,exports){
'use strict';
module.exports = {};
module.exports.Arc = require(36);
module.exports.Line = require(37);
module.exports.Point = require(38);
module.exports.Rectangle = require(39);
},{"36":36,"37":37,"38":38,"39":39}],41:[function(require,module,exports){
'use strict';
var helpers = require(42);
/**
* @namespace Chart.helpers.canvas
*/
var exports = module.exports = {
/**
* Clears the entire canvas associated to the given `chart`.
* @param {Chart} chart - The chart for which to clear the canvas.
*/
clear: function(chart) {
chart.ctx.clearRect(0, 0, chart.width, chart.height);
},
/**
* Creates a "path" for a rectangle with rounded corners at position (x, y) with a
* given size (width, height) and the same `radius` for all corners.
* @param {CanvasRenderingContext2D} ctx - The canvas 2D Context.
* @param {Number} x - The x axis of the coordinate for the rectangle starting point.
* @param {Number} y - The y axis of the coordinate for the rectangle starting point.
* @param {Number} width - The rectangle's width.
* @param {Number} height - The rectangle's height.
* @param {Number} radius - The rounded amount (in pixels) for the four corners.
* @todo handle `radius` as top-left, top-right, bottom-right, bottom-left array/object?
*/
roundedRect: function(ctx, x, y, width, height, radius) {
if (radius) {
var rx = Math.min(radius, width / 2);
var ry = Math.min(radius, height / 2);
ctx.moveTo(x + rx, y);
ctx.lineTo(x + width - rx, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + ry);
ctx.lineTo(x + width, y + height - ry);
ctx.quadraticCurveTo(x + width, y + height, x + width - rx, y + height);
ctx.lineTo(x + rx, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - ry);
ctx.lineTo(x, y + ry);
ctx.quadraticCurveTo(x, y, x + rx, y);
} else {
ctx.rect(x, y, width, height);
}
},
drawPoint: function(ctx, style, radius, x, y) {
var type, edgeLength, xOffset, yOffset, height, size;
if (style && typeof style === 'object') {
type = style.toString();
if (type === '[object HTMLImageElement]' || type === '[object HTMLCanvasElement]') {
ctx.drawImage(style, x - style.width / 2, y - style.height / 2, style.width, style.height);
return;
}
}
if (isNaN(radius) || radius <= 0) {
return;
}
switch (style) {
// Default includes circle
default:
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.closePath();
ctx.fill();
break;
case 'triangle':
ctx.beginPath();
edgeLength = 3 * radius / Math.sqrt(3);
height = edgeLength * Math.sqrt(3) / 2;
ctx.moveTo(x - edgeLength / 2, y + height / 3);
ctx.lineTo(x + edgeLength / 2, y + height / 3);
ctx.lineTo(x, y - 2 * height / 3);
ctx.closePath();
ctx.fill();
break;
case 'rect':
size = 1 / Math.SQRT2 * radius;
ctx.beginPath();
ctx.fillRect(x - size, y - size, 2 * size, 2 * size);
ctx.strokeRect(x - size, y - size, 2 * size, 2 * size);
break;
case 'rectRounded':
var offset = radius / Math.SQRT2;
var leftX = x - offset;
var topY = y - offset;
var sideSize = Math.SQRT2 * radius;
ctx.beginPath();
this.roundedRect(ctx, leftX, topY, sideSize, sideSize, radius / 2);
ctx.closePath();
ctx.fill();
break;
case 'rectRot':
size = 1 / Math.SQRT2 * radius;
ctx.beginPath();
ctx.moveTo(x - size, y);
ctx.lineTo(x, y + size);
ctx.lineTo(x + size, y);
ctx.lineTo(x, y - size);
ctx.closePath();
ctx.fill();
break;
case 'cross':
ctx.beginPath();
ctx.moveTo(x, y + radius);
ctx.lineTo(x, y - radius);
ctx.moveTo(x - radius, y);
ctx.lineTo(x + radius, y);
ctx.closePath();
break;
case 'crossRot':
ctx.beginPath();
xOffset = Math.cos(Math.PI / 4) * radius;
yOffset = Math.sin(Math.PI / 4) * radius;
ctx.moveTo(x - xOffset, y - yOffset);
ctx.lineTo(x + xOffset, y + yOffset);
ctx.moveTo(x - xOffset, y + yOffset);
ctx.lineTo(x + xOffset, y - yOffset);
ctx.closePath();
break;
case 'star':
ctx.beginPath();
ctx.moveTo(x, y + radius);
ctx.lineTo(x, y - radius);
ctx.moveTo(x - radius, y);
ctx.lineTo(x + radius, y);
xOffset = Math.cos(Math.PI / 4) * radius;
yOffset = Math.sin(Math.PI / 4) * radius;
ctx.moveTo(x - xOffset, y - yOffset);
ctx.lineTo(x + xOffset, y + yOffset);
ctx.moveTo(x - xOffset, y + yOffset);
ctx.lineTo(x + xOffset, y - yOffset);
ctx.closePath();
break;
case 'line':
ctx.beginPath();
ctx.moveTo(x - radius, y);
ctx.lineTo(x + radius, y);
ctx.closePath();
break;
case 'dash':
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + radius, y);
ctx.closePath();
break;
}
ctx.stroke();
},
clipArea: function(ctx, area) {
ctx.save();
ctx.beginPath();
ctx.rect(area.left, area.top, area.right - area.left, area.bottom - area.top);
ctx.clip();
},
unclipArea: function(ctx) {
ctx.restore();
},
lineTo: function(ctx, previous, target, flip) {
if (target.steppedLine) {
if ((target.steppedLine === 'after' && !flip) || (target.steppedLine !== 'after' && flip)) {
ctx.lineTo(previous.x, target.y);
} else {
ctx.lineTo(target.x, previous.y);
}
ctx.lineTo(target.x, target.y);
return;
}
if (!target.tension) {
ctx.lineTo(target.x, target.y);
return;
}
ctx.bezierCurveTo(
flip ? previous.controlPointPreviousX : previous.controlPointNextX,
flip ? previous.controlPointPreviousY : previous.controlPointNextY,
flip ? target.controlPointNextX : target.controlPointPreviousX,
flip ? target.controlPointNextY : target.controlPointPreviousY,
target.x,
target.y);
}
};
// DEPRECATIONS
/**
* Provided for backward compatibility, use Chart.helpers.canvas.clear instead.
* @namespace Chart.helpers.clear
* @deprecated since version 2.7.0
* @todo remove at version 3
* @private
*/
helpers.clear = exports.clear;
/**
* Provided for backward compatibility, use Chart.helpers.canvas.roundedRect instead.
* @namespace Chart.helpers.drawRoundedRectangle
* @deprecated since version 2.7.0
* @todo remove at version 3
* @private
*/
helpers.drawRoundedRectangle = function(ctx) {
ctx.beginPath();
exports.roundedRect.apply(exports, arguments);
ctx.closePath();
};
},{"42":42}],42:[function(require,module,exports){
'use strict';
/**
* @namespace Chart.helpers
*/
var helpers = {
/**
* An empty function that can be used, for example, for optional callback.
*/
noop: function() {},
/**
* Returns a unique id, sequentially generated from a global variable.
* @returns {Number}
* @function
*/
uid: (function() {
var id = 0;
return function() {
return id++;
};
}()),
/**
* Returns true if `value` is neither null nor undefined, else returns false.
* @param {*} value - The value to test.
* @returns {Boolean}
* @since 2.7.0
*/
isNullOrUndef: function(value) {
return value === null || typeof value === 'undefined';
},
/**
* Returns true if `value` is an array, else returns false.
* @param {*} value - The value to test.
* @returns {Boolean}
* @function
*/
isArray: Array.isArray ? Array.isArray : function(value) {
return Object.prototype.toString.call(value) === '[object Array]';
},
/**
* Returns true if `value` is an object (excluding null), else returns false.
* @param {*} value - The value to test.
* @returns {Boolean}
* @since 2.7.0
*/
isObject: function(value) {
return value !== null && Object.prototype.toString.call(value) === '[object Object]';
},
/**
* Returns `value` if defined, else returns `defaultValue`.
* @param {*} value - The value to return if defined.
* @param {*} defaultValue - The value to return if `value` is undefined.
* @returns {*}
*/
valueOrDefault: function(value, defaultValue) {
return typeof value === 'undefined' ? defaultValue : value;
},
/**
* Returns value at the given `index` in array if defined, else returns `defaultValue`.
* @param {Array} value - The array to lookup for value at `index`.
* @param {Number} index - The index in `value` to lookup for value.
* @param {*} defaultValue - The value to return if `value[index]` is undefined.
* @returns {*}
*/
valueAtIndexOrDefault: function(value, index, defaultValue) {
return helpers.valueOrDefault(helpers.isArray(value) ? value[index] : value, defaultValue);
},
/**
* Calls `fn` with the given `args` in the scope defined by `thisArg` and returns the
* value returned by `fn`. If `fn` is not a function, this method returns undefined.
* @param {Function} fn - The function to call.
* @param {Array|undefined|null} args - The arguments with which `fn` should be called.
* @param {Object} [thisArg] - The value of `this` provided for the call to `fn`.
* @returns {*}
*/
callback: function(fn, args, thisArg) {
if (fn && typeof fn.call === 'function') {
return fn.apply(thisArg, args);
}
},
/**
* Note(SB) for performance sake, this method should only be used when loopable type
* is unknown or in none intensive code (not called often and small loopable). Else
* it's preferable to use a regular for() loop and save extra function calls.
* @param {Object|Array} loopable - The object or array to be iterated.
* @param {Function} fn - The function to call for each item.
* @param {Object} [thisArg] - The value of `this` provided for the call to `fn`.
* @param {Boolean} [reverse] - If true, iterates backward on the loopable.
*/
each: function(loopable, fn, thisArg, reverse) {
var i, len, keys;
if (helpers.isArray(loopable)) {
len = loopable.length;
if (reverse) {
for (i = len - 1; i >= 0; i--) {
fn.call(thisArg, loopable[i], i);
}
} else {
for (i = 0; i < len; i++) {
fn.call(thisArg, loopable[i], i);
}
}
} else if (helpers.isObject(loopable)) {
keys = Object.keys(loopable);
len = keys.length;
for (i = 0; i < len; i++) {
fn.call(thisArg, loopable[keys[i]], keys[i]);
}
}
},
/**
* Returns true if the `a0` and `a1` arrays have the same content, else returns false.
* @see http://stackoverflow.com/a/14853974
* @param {Array} a0 - The array to compare
* @param {Array} a1 - The array to compare
* @returns {Boolean}
*/
arrayEquals: function(a0, a1) {
var i, ilen, v0, v1;
if (!a0 || !a1 || a0.length !== a1.length) {
return false;
}
for (i = 0, ilen = a0.length; i < ilen; ++i) {
v0 = a0[i];
v1 = a1[i];
if (v0 instanceof Array && v1 instanceof Array) {
if (!helpers.arrayEquals(v0, v1)) {
return false;
}
} else if (v0 !== v1) {
// NOTE: two different object instances will never be equal: {x:20} != {x:20}
return false;
}
}
return true;
},
/**
* Returns a deep copy of `source` without keeping references on objects and arrays.
* @param {*} source - The value to clone.
* @returns {*}
*/
clone: function(source) {
if (helpers.isArray(source)) {
return source.map(helpers.clone);
}
if (helpers.isObject(source)) {
var target = {};
var keys = Object.keys(source);
var klen = keys.length;
var k = 0;
for (; k < klen; ++k) {
target[keys[k]] = helpers.clone(source[keys[k]]);
}
return target;
}
return source;
},
/**
* The default merger when Chart.helpers.merge is called without merger option.
* Note(SB): this method is also used by configMerge and scaleMerge as fallback.
* @private
*/
_merger: function(key, target, source, options) {
var tval = target[key];
var sval = source[key];
if (helpers.isObject(tval) && helpers.isObject(sval)) {
helpers.merge(tval, sval, options);
} else {
target[key] = helpers.clone(sval);
}
},
/**
* Merges source[key] in target[key] only if target[key] is undefined.
* @private
*/
_mergerIf: function(key, target, source) {
var tval = target[key];
var sval = source[key];
if (helpers.isObject(tval) && helpers.isObject(sval)) {
helpers.mergeIf(tval, sval);
} else if (!target.hasOwnProperty(key)) {
target[key] = helpers.clone(sval);
}
},
/**
* Recursively deep copies `source` properties into `target` with the given `options`.
* IMPORTANT: `target` is not cloned and will be updated with `source` properties.
* @param {Object} target - The target object in which all sources are merged into.
* @param {Object|Array(Object)} source - Object(s) to merge into `target`.
* @param {Object} [options] - Merging options:
* @param {Function} [options.merger] - The merge method (key, target, source, options)
* @returns {Object} The `target` object.
*/
merge: function(target, source, options) {
var sources = helpers.isArray(source) ? source : [source];
var ilen = sources.length;
var merge, i, keys, klen, k;
if (!helpers.isObject(target)) {
return target;
}
options = options || {};
merge = options.merger || helpers._merger;
for (i = 0; i < ilen; ++i) {
source = sources[i];
if (!helpers.isObject(source)) {
continue;
}
keys = Object.keys(source);
for (k = 0, klen = keys.length; k < klen; ++k) {
merge(keys[k], target, source, options);
}
}
return target;
},
/**
* Recursively deep copies `source` properties into `target` *only* if not defined in target.
* IMPORTANT: `target` is not cloned and will be updated with `source` properties.
* @param {Object} target - The target object in which all sources are merged into.
* @param {Object|Array(Object)} source - Object(s) to merge into `target`.
* @returns {Object} The `target` object.
*/
mergeIf: function(target, source) {
return helpers.merge(target, source, {merger: helpers._mergerIf});
},
/**
* Applies the contents of two or more objects together into the first object.
* @param {Object} target - The target object in which all objects are merged into.
* @param {Object} arg1 - Object containing additional properties to merge in target.
* @param {Object} argN - Additional objects containing properties to merge in target.
* @returns {Object} The `target` object.
*/
extend: function(target) {
var setFn = function(value, key) {
target[key] = value;
};
for (var i = 1, ilen = arguments.length; i < ilen; ++i) {
helpers.each(arguments[i], setFn);
}
return target;
},
/**
* Basic javascript inheritance based on the model created in Backbone.js
*/
inherits: function(extensions) {
var me = this;
var ChartElement = (extensions && extensions.hasOwnProperty('constructor')) ? extensions.constructor : function() {
return me.apply(this, arguments);
};
var Surrogate = function() {
this.constructor = ChartElement;
};
Surrogate.prototype = me.prototype;
ChartElement.prototype = new Surrogate();
ChartElement.extend = helpers.inherits;
if (extensions) {
helpers.extend(ChartElement.prototype, extensions);
}
ChartElement.__super__ = me.prototype;
return ChartElement;
}
};
module.exports = helpers;
// DEPRECATIONS
/**
* Provided for backward compatibility, use Chart.helpers.callback instead.
* @function Chart.helpers.callCallback
* @deprecated since version 2.6.0
* @todo remove at version 3
* @private
*/
helpers.callCallback = helpers.callback;
/**
* Provided for backward compatibility, use Array.prototype.indexOf instead.
* Array.prototype.indexOf compatibility: Chrome, Opera, Safari, FF1.5+, IE9+
* @function Chart.helpers.indexOf
* @deprecated since version 2.7.0
* @todo remove at version 3
* @private
*/
helpers.indexOf = function(array, item, fromIndex) {
return Array.prototype.indexOf.call(array, item, fromIndex);
};
/**
* Provided for backward compatibility, use Chart.helpers.valueOrDefault instead.
* @function Chart.helpers.getValueOrDefault
* @deprecated since version 2.7.0
* @todo remove at version 3
* @private
*/
helpers.getValueOrDefault = helpers.valueOrDefault;
/**
* Provided for backward compatibility, use Chart.helpers.valueAtIndexOrDefault instead.
* @function Chart.helpers.getValueAtIndexOrDefault
* @deprecated since version 2.7.0
* @todo remove at version 3
* @private
*/
helpers.getValueAtIndexOrDefault = helpers.valueAtIndexOrDefault;
},{}],43:[function(require,module,exports){
'use strict';
var helpers = require(42);
/**
* Easing functions adapted from Robert Penner's easing equations.
* @namespace Chart.helpers.easingEffects
* @see http://www.robertpenner.com/easing/
*/
var effects = {
linear: function(t) {
return t;
},
easeInQuad: function(t) {
return t * t;
},
easeOutQuad: function(t) {
return -t * (t - 2);
},
easeInOutQuad: function(t) {
if ((t /= 0.5) < 1) {
return 0.5 * t * t;
}
return -0.5 * ((--t) * (t - 2) - 1);
},
easeInCubic: function(t) {
return t * t * t;
},
easeOutCubic: function(t) {
return (t = t - 1) * t * t + 1;
},
easeInOutCubic: function(t) {
if ((t /= 0.5) < 1) {
return 0.5 * t * t * t;
}
return 0.5 * ((t -= 2) * t * t + 2);
},
easeInQuart: function(t) {
return t * t * t * t;
},
easeOutQuart: function(t) {
return -((t = t - 1) * t * t * t - 1);
},
easeInOutQuart: function(t) {
if ((t /= 0.5) < 1) {
return 0.5 * t * t * t * t;
}
return -0.5 * ((t -= 2) * t * t * t - 2);
},
easeInQuint: function(t) {
return t * t * t * t * t;
},
easeOutQuint: function(t) {
return (t = t - 1) * t * t * t * t + 1;
},
easeInOutQuint: function(t) {
if ((t /= 0.5) < 1) {
return 0.5 * t * t * t * t * t;
}
return 0.5 * ((t -= 2) * t * t * t * t + 2);
},
easeInSine: function(t) {
return -Math.cos(t * (Math.PI / 2)) + 1;
},
easeOutSine: function(t) {
return Math.sin(t * (Math.PI / 2));
},
easeInOutSine: function(t) {
return -0.5 * (Math.cos(Math.PI * t) - 1);
},
easeInExpo: function(t) {
return (t === 0) ? 0 : Math.pow(2, 10 * (t - 1));
},
easeOutExpo: function(t) {
return (t === 1) ? 1 : -Math.pow(2, -10 * t) + 1;
},
easeInOutExpo: function(t) {
if (t === 0) {
return 0;
}
if (t === 1) {
return 1;
}
if ((t /= 0.5) < 1) {
return 0.5 * Math.pow(2, 10 * (t - 1));
}
return 0.5 * (-Math.pow(2, -10 * --t) + 2);
},
easeInCirc: function(t) {
if (t >= 1) {
return t;
}
return -(Math.sqrt(1 - t * t) - 1);
},
easeOutCirc: function(t) {
return Math.sqrt(1 - (t = t - 1) * t);
},
easeInOutCirc: function(t) {
if ((t /= 0.5) < 1) {
return -0.5 * (Math.sqrt(1 - t * t) - 1);
}
return 0.5 * (Math.sqrt(1 - (t -= 2) * t) + 1);
},
easeInElastic: function(t) {
var s = 1.70158;
var p = 0;
var a = 1;
if (t === 0) {
return 0;
}
if (t === 1) {
return 1;
}
if (!p) {
p = 0.3;
}
if (a < 1) {
a = 1;
s = p / 4;
} else {
s = p / (2 * Math.PI) * Math.asin(1 / a);
}
return -(a * Math.pow(2, 10 * (t -= 1)) * Math.sin((t - s) * (2 * Math.PI) / p));
},
easeOutElastic: function(t) {
var s = 1.70158;
var p = 0;
var a = 1;
if (t === 0) {
return 0;
}
if (t === 1) {
return 1;
}
if (!p) {
p = 0.3;
}
if (a < 1) {
a = 1;
s = p / 4;
} else {
s = p / (2 * Math.PI) * Math.asin(1 / a);
}
return a * Math.pow(2, -10 * t) * Math.sin((t - s) * (2 * Math.PI) / p) + 1;
},
easeInOutElastic: function(t) {
var s = 1.70158;
var p = 0;
var a = 1;
if (t === 0) {
return 0;
}
if ((t /= 0.5) === 2) {
return 1;
}
if (!p) {
p = 0.45;
}
if (a < 1) {
a = 1;
s = p / 4;
} else {
s = p / (2 * Math.PI) * Math.asin(1 / a);
}
if (t < 1) {
return -0.5 * (a * Math.pow(2, 10 * (t -= 1)) * Math.sin((t - s) * (2 * Math.PI) / p));
}
return a * Math.pow(2, -10 * (t -= 1)) * Math.sin((t - s) * (2 * Math.PI) / p) * 0.5 + 1;
},
easeInBack: function(t) {
var s = 1.70158;
return t * t * ((s + 1) * t - s);
},
easeOutBack: function(t) {
var s = 1.70158;
return (t = t - 1) * t * ((s + 1) * t + s) + 1;
},
easeInOutBack: function(t) {
var s = 1.70158;
if ((t /= 0.5) < 1) {
return 0.5 * (t * t * (((s *= (1.525)) + 1) * t - s));
}
return 0.5 * ((t -= 2) * t * (((s *= (1.525)) + 1) * t + s) + 2);
},
easeInBounce: function(t) {
return 1 - effects.easeOutBounce(1 - t);
},
easeOutBounce: function(t) {
if (t < (1 / 2.75)) {
return 7.5625 * t * t;
}
if (t < (2 / 2.75)) {
return 7.5625 * (t -= (1.5 / 2.75)) * t + 0.75;
}
if (t < (2.5 / 2.75)) {
return 7.5625 * (t -= (2.25 / 2.75)) * t + 0.9375;
}
return 7.5625 * (t -= (2.625 / 2.75)) * t + 0.984375;
},
easeInOutBounce: function(t) {
if (t < 0.5) {
return effects.easeInBounce(t * 2) * 0.5;
}
return effects.easeOutBounce(t * 2 - 1) * 0.5 + 0.5;
}
};
module.exports = {
effects: effects
};
// DEPRECATIONS
/**
* Provided for backward compatibility, use Chart.helpers.easing.effects instead.
* @function Chart.helpers.easingEffects
* @deprecated since version 2.7.0
* @todo remove at version 3
* @private
*/
helpers.easingEffects = effects;
},{"42":42}],44:[function(require,module,exports){
'use strict';
var helpers = require(42);
/**
* @alias Chart.helpers.options
* @namespace
*/
module.exports = {
/**
* Converts the given line height `value` in pixels for a specific font `size`.
* @param {Number|String} value - The lineHeight to parse (eg. 1.6, '14px', '75%', '1.6em').
* @param {Number} size - The font size (in pixels) used to resolve relative `value`.
* @returns {Number} The effective line height in pixels (size * 1.2 if value is invalid).
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/line-height
* @since 2.7.0
*/
toLineHeight: function(value, size) {
var matches = ('' + value).match(/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/);
if (!matches || matches[1] === 'normal') {
return size * 1.2;
}
value = +matches[2];
switch (matches[3]) {
case 'px':
return value;
case '%':
value /= 100;
break;
default:
break;
}
return size * value;
},
/**
* Converts the given value into a padding object with pre-computed width/height.
* @param {Number|Object} value - If a number, set the value to all TRBL component,
* else, if and object, use defined properties and sets undefined ones to 0.
* @returns {Object} The padding values (top, right, bottom, left, width, height)
* @since 2.7.0
*/
toPadding: function(value) {
var t, r, b, l;
if (helpers.isObject(value)) {
t = +value.top || 0;
r = +value.right || 0;
b = +value.bottom || 0;
l = +value.left || 0;
} else {
t = r = b = l = +value || 0;
}
return {
top: t,
right: r,
bottom: b,
left: l,
height: t + b,
width: l + r
};
},
/**
* Evaluates the given `inputs` sequentially and returns the first defined value.
* @param {Array[]} inputs - An array of values, falling back to the last value.
* @param {Object} [context] - If defined and the current value is a function, the value
* is called with `context` as first argument and the result becomes the new input.
* @param {Number} [index] - If defined and the current value is an array, the value
* at `index` become the new input.
* @since 2.7.0
*/
resolve: function(inputs, context, index) {
var i, ilen, value;
for (i = 0, ilen = inputs.length; i < ilen; ++i) {
value = inputs[i];
if (value === undefined) {
continue;
}
if (context !== undefined && typeof value === 'function') {
value = value(context);
}
if (index !== undefined && helpers.isArray(value)) {
value = value[index];
}
if (value !== undefined) {
return value;
}
}
}
};
},{"42":42}],45:[function(require,module,exports){
'use strict';
module.exports = require(42);
module.exports.easing = require(43);
module.exports.canvas = require(41);
module.exports.options = require(44);
},{"41":41,"42":42,"43":43,"44":44}],46:[function(require,module,exports){
/**
* Platform fallback implementation (minimal).
* @see https://github.com/chartjs/Chart.js/pull/4591#issuecomment-319575939
*/
module.exports = {
acquireContext: function(item) {
if (item && item.canvas) {
// Support for any object associated to a canvas (including a context2d)
item = item.canvas;
}
return item && item.getContext('2d') || null;
}
};
},{}],47:[function(require,module,exports){
/**
* Chart.Platform implementation for targeting a web browser
*/
'use strict';
var helpers = require(45);
var EXPANDO_KEY = '$chartjs';
var CSS_PREFIX = 'chartjs-';
var CSS_RENDER_MONITOR = CSS_PREFIX + 'render-monitor';
var CSS_RENDER_ANIMATION = CSS_PREFIX + 'render-animation';
var ANIMATION_START_EVENTS = ['animationstart', 'webkitAnimationStart'];
/**
* DOM event types -> Chart.js event types.
* Note: only events with different types are mapped.
* @see https://developer.mozilla.org/en-US/docs/Web/Events
*/
var EVENT_TYPES = {
touchstart: 'mousedown',
touchmove: 'mousemove',
touchend: 'mouseup',
pointerenter: 'mouseenter',
pointerdown: 'mousedown',
pointermove: 'mousemove',
pointerup: 'mouseup',
pointerleave: 'mouseout',
pointerout: 'mouseout'
};
/**
* The "used" size is the final value of a dimension property after all calculations have
* been performed. This method uses the computed style of `element` but returns undefined
* if the computed style is not expressed in pixels. That can happen in some cases where
* `element` has a size relative to its parent and this last one is not yet displayed,
* for example because of `display: none` on a parent node.
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/used_value
* @returns {Number} Size in pixels or undefined if unknown.
*/
function readUsedSize(element, property) {
var value = helpers.getStyle(element, property);
var matches = value && value.match(/^(\d+)(\.\d+)?px$/);
return matches ? Number(matches[1]) : undefined;
}
/**
* Initializes the canvas style and render size without modifying the canvas display size,
* since responsiveness is handled by the controller.resize() method. The config is used
* to determine the aspect ratio to apply in case no explicit height has been specified.
*/
function initCanvas(canvas, config) {
var style = canvas.style;
// NOTE(SB) canvas.getAttribute('width') !== canvas.width: in the first case it
// returns null or '' if no explicit value has been set to the canvas attribute.
var renderHeight = canvas.getAttribute('height');
var renderWidth = canvas.getAttribute('width');
// Chart.js modifies some canvas values that we want to restore on destroy
canvas[EXPANDO_KEY] = {
initial: {
height: renderHeight,
width: renderWidth,
style: {
display: style.display,
height: style.height,
width: style.width
}
}
};
// Force canvas to display as block to avoid extra space caused by inline
// elements, which would interfere with the responsive resize process.
// https://github.com/chartjs/Chart.js/issues/2538
style.display = style.display || 'block';
if (renderWidth === null || renderWidth === '') {
var displayWidth = readUsedSize(canvas, 'width');
if (displayWidth !== undefined) {
canvas.width = displayWidth;
}
}
if (renderHeight === null || renderHeight === '') {
if (canvas.style.height === '') {
// If no explicit render height and style height, let's apply the aspect ratio,
// which one can be specified by the user but also by charts as default option
// (i.e. options.aspectRatio). If not specified, use canvas aspect ratio of 2.
canvas.height = canvas.width / (config.options.aspectRatio || 2);
} else {
var displayHeight = readUsedSize(canvas, 'height');
if (displayWidth !== undefined) {
canvas.height = displayHeight;
}
}
}
return canvas;
}
/**
* Detects support for options object argument in addEventListener.
* https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Safely_detecting_option_support
* @private
*/
var supportsEventListenerOptions = (function() {
var supports = false;
try {
var options = Object.defineProperty({}, 'passive', {
get: function() {
supports = true;
}
});
window.addEventListener('e', null, options);
} catch (e) {
// continue regardless of error
}
return supports;
}());
// Default passive to true as expected by Chrome for 'touchstart' and 'touchend' events.
// https://github.com/chartjs/Chart.js/issues/4287
var eventListenerOptions = supportsEventListenerOptions ? {passive: true} : false;
function addEventListener(node, type, listener) {
node.addEventListener(type, listener, eventListenerOptions);
}
function removeEventListener(node, type, listener) {
node.removeEventListener(type, listener, eventListenerOptions);
}
function createEvent(type, chart, x, y, nativeEvent) {
return {
type: type,
chart: chart,
native: nativeEvent || null,
x: x !== undefined ? x : null,
y: y !== undefined ? y : null,
};
}
function fromNativeEvent(event, chart) {
var type = EVENT_TYPES[event.type] || event.type;
var pos = helpers.getRelativePosition(event, chart);
return createEvent(type, chart, pos.x, pos.y, event);
}
function throttled(fn, thisArg) {
var ticking = false;
var args = [];
return function() {
args = Array.prototype.slice.call(arguments);
thisArg = thisArg || this;
if (!ticking) {
ticking = true;
helpers.requestAnimFrame.call(window, function() {
ticking = false;
fn.apply(thisArg, args);
});
}
};
}
// Implementation based on https://github.com/marcj/css-element-queries
function createResizer(handler) {
var resizer = document.createElement('div');
var cls = CSS_PREFIX + 'size-monitor';
var maxSize = 1000000;
var style =
'position:absolute;' +
'left:0;' +
'top:0;' +
'right:0;' +
'bottom:0;' +
'overflow:hidden;' +
'pointer-events:none;' +
'visibility:hidden;' +
'z-index:-1;';
resizer.style.cssText = style;
resizer.className = cls;
resizer.innerHTML =
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
';
var expand = resizer.childNodes[0];
var shrink = resizer.childNodes[1];
resizer._reset = function() {
expand.scrollLeft = maxSize;
expand.scrollTop = maxSize;
shrink.scrollLeft = maxSize;
shrink.scrollTop = maxSize;
};
var onScroll = function() {
resizer._reset();
handler();
};
addEventListener(expand, 'scroll', onScroll.bind(expand, 'expand'));
addEventListener(shrink, 'scroll', onScroll.bind(shrink, 'shrink'));
return resizer;
}
// https://davidwalsh.name/detect-node-insertion
function watchForRender(node, handler) {
var expando = node[EXPANDO_KEY] || (node[EXPANDO_KEY] = {});
var proxy = expando.renderProxy = function(e) {
if (e.animationName === CSS_RENDER_ANIMATION) {
handler();
}
};
helpers.each(ANIMATION_START_EVENTS, function(type) {
addEventListener(node, type, proxy);
});
// #4737: Chrome might skip the CSS animation when the CSS_RENDER_MONITOR class
// is removed then added back immediately (same animation frame?). Accessing the
// `offsetParent` property will force a reflow and re-evaluate the CSS animation.
// https://gist.github.com/paulirish/5d52fb081b3570c81e3a#box-metrics
// https://github.com/chartjs/Chart.js/issues/4737
expando.reflow = !!node.offsetParent;
node.classList.add(CSS_RENDER_MONITOR);
}
function unwatchForRender(node) {
var expando = node[EXPANDO_KEY] || {};
var proxy = expando.renderProxy;
if (proxy) {
helpers.each(ANIMATION_START_EVENTS, function(type) {
removeEventListener(node, type, proxy);
});
delete expando.renderProxy;
}
node.classList.remove(CSS_RENDER_MONITOR);
}
function addResizeListener(node, listener, chart) {
var expando = node[EXPANDO_KEY] || (node[EXPANDO_KEY] = {});
// Let's keep track of this added resizer and thus avoid DOM query when removing it.
var resizer = expando.resizer = createResizer(throttled(function() {
if (expando.resizer) {
return listener(createEvent('resize', chart));
}
}));
// The resizer needs to be attached to the node parent, so we first need to be
// sure that `node` is attached to the DOM before injecting the resizer element.
watchForRender(node, function() {
if (expando.resizer) {
var container = node.parentNode;
if (container && container !== resizer.parentNode) {
container.insertBefore(resizer, container.firstChild);
}
// The container size might have changed, let's reset the resizer state.
resizer._reset();
}
});
}
function removeResizeListener(node) {
var expando = node[EXPANDO_KEY] || {};
var resizer = expando.resizer;
delete expando.resizer;
unwatchForRender(node);
if (resizer && resizer.parentNode) {
resizer.parentNode.removeChild(resizer);
}
}
function injectCSS(platform, css) {
// http://stackoverflow.com/q/3922139
var style = platform._style || document.createElement('style');
if (!platform._style) {
platform._style = style;
css = '/* Chart.js */\n' + css;
style.setAttribute('type', 'text/css');
document.getElementsByTagName('head')[0].appendChild(style);
}
style.appendChild(document.createTextNode(css));
}
module.exports = {
/**
* This property holds whether this platform is enabled for the current environment.
* Currently used by platform.js to select the proper implementation.
* @private
*/
_enabled: typeof window !== 'undefined' && typeof document !== 'undefined',
initialize: function() {
var keyframes = 'from{opacity:0.99}to{opacity:1}';
injectCSS(this,
// DOM rendering detection
// https://davidwalsh.name/detect-node-insertion
'@-webkit-keyframes ' + CSS_RENDER_ANIMATION + '{' + keyframes + '}' +
'@keyframes ' + CSS_RENDER_ANIMATION + '{' + keyframes + '}' +
'.' + CSS_RENDER_MONITOR + '{' +
'-webkit-animation:' + CSS_RENDER_ANIMATION + ' 0.001s;' +
'animation:' + CSS_RENDER_ANIMATION + ' 0.001s;' +
'}'
);
},
acquireContext: function(item, config) {
if (typeof item === 'string') {
item = document.getElementById(item);
} else if (item.length) {
// Support for array based queries (such as jQuery)
item = item[0];
}
if (item && item.canvas) {
// Support for any object associated to a canvas (including a context2d)
item = item.canvas;
}
// To prevent canvas fingerprinting, some add-ons undefine the getContext
// method, for example: https://github.com/kkapsner/CanvasBlocker
// https://github.com/chartjs/Chart.js/issues/2807
var context = item && item.getContext && item.getContext('2d');
// `instanceof HTMLCanvasElement/CanvasRenderingContext2D` fails when the item is
// inside an iframe or when running in a protected environment. We could guess the
// types from their toString() value but let's keep things flexible and assume it's
// a sufficient condition if the item has a context2D which has item as `canvas`.
// https://github.com/chartjs/Chart.js/issues/3887
// https://github.com/chartjs/Chart.js/issues/4102
// https://github.com/chartjs/Chart.js/issues/4152
if (context && context.canvas === item) {
initCanvas(item, config);
return context;
}
return null;
},
releaseContext: function(context) {
var canvas = context.canvas;
if (!canvas[EXPANDO_KEY]) {
return;
}
var initial = canvas[EXPANDO_KEY].initial;
['height', 'width'].forEach(function(prop) {
var value = initial[prop];
if (helpers.isNullOrUndef(value)) {
canvas.removeAttribute(prop);
} else {
canvas.setAttribute(prop, value);
}
});
helpers.each(initial.style || {}, function(value, key) {
canvas.style[key] = value;
});
// The canvas render size might have been changed (and thus the state stack discarded),
// we can't use save() and restore() to restore the initial state. So make sure that at
// least the canvas context is reset to the default state by setting the canvas width.
// https://www.w3.org/TR/2011/WD-html5-20110525/the-canvas-element.html
canvas.width = canvas.width;
delete canvas[EXPANDO_KEY];
},
addEventListener: function(chart, type, listener) {
var canvas = chart.canvas;
if (type === 'resize') {
// Note: the resize event is not supported on all browsers.
addResizeListener(canvas, listener, chart);
return;
}
var expando = listener[EXPANDO_KEY] || (listener[EXPANDO_KEY] = {});
var proxies = expando.proxies || (expando.proxies = {});
var proxy = proxies[chart.id + '_' + type] = function(event) {
listener(fromNativeEvent(event, chart));
};
addEventListener(canvas, type, proxy);
},
removeEventListener: function(chart, type, listener) {
var canvas = chart.canvas;
if (type === 'resize') {
// Note: the resize event is not supported on all browsers.
removeResizeListener(canvas, listener);
return;
}
var expando = listener[EXPANDO_KEY] || {};
var proxies = expando.proxies || {};
var proxy = proxies[chart.id + '_' + type];
if (!proxy) {
return;
}
removeEventListener(canvas, type, proxy);
}
};
// DEPRECATIONS
/**
* Provided for backward compatibility, use EventTarget.addEventListener instead.
* EventTarget.addEventListener compatibility: Chrome, Opera 7, Safari, FF1.5+, IE9+
* @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
* @function Chart.helpers.addEvent
* @deprecated since version 2.7.0
* @todo remove at version 3
* @private
*/
helpers.addEvent = addEventListener;
/**
* Provided for backward compatibility, use EventTarget.removeEventListener instead.
* EventTarget.removeEventListener compatibility: Chrome, Opera 7, Safari, FF1.5+, IE9+
* @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener
* @function Chart.helpers.removeEvent
* @deprecated since version 2.7.0
* @todo remove at version 3
* @private
*/
helpers.removeEvent = removeEventListener;
},{"45":45}],48:[function(require,module,exports){
'use strict';
var helpers = require(45);
var basic = require(46);
var dom = require(47);
// @TODO Make possible to select another platform at build time.
var implementation = dom._enabled ? dom : basic;
/**
* @namespace Chart.platform
* @see https://chartjs.gitbooks.io/proposals/content/Platform.html
* @since 2.4.0
*/
module.exports = helpers.extend({
/**
* @since 2.7.0
*/
initialize: function() {},
/**
* Called at chart construction time, returns a context2d instance implementing
* the [W3C Canvas 2D Context API standard]{@link https://www.w3.org/TR/2dcontext/}.
* @param {*} item - The native item from which to acquire context (platform specific)
* @param {Object} options - The chart options
* @returns {CanvasRenderingContext2D} context2d instance
*/
acquireContext: function() {},
/**
* Called at chart destruction time, releases any resources associated to the context
* previously returned by the acquireContext() method.
* @param {CanvasRenderingContext2D} context - The context2d instance
* @returns {Boolean} true if the method succeeded, else false
*/
releaseContext: function() {},
/**
* Registers the specified listener on the given chart.
* @param {Chart} chart - Chart from which to listen for event
* @param {String} type - The ({@link IEvent}) type to listen for
* @param {Function} listener - Receives a notification (an object that implements
* the {@link IEvent} interface) when an event of the specified type occurs.
*/
addEventListener: function() {},
/**
* Removes the specified listener previously registered with addEventListener.
* @param {Chart} chart -Chart from which to remove the listener
* @param {String} type - The ({@link IEvent}) type to remove
* @param {Function} listener - The listener function to remove from the event target.
*/
removeEventListener: function() {}
}, implementation);
/**
* @interface IPlatform
* Allows abstracting platform dependencies away from the chart
* @borrows Chart.platform.acquireContext as acquireContext
* @borrows Chart.platform.releaseContext as releaseContext
* @borrows Chart.platform.addEventListener as addEventListener
* @borrows Chart.platform.removeEventListener as removeEventListener
*/
/**
* @interface IEvent
* @prop {String} type - The event type name, possible values are:
* 'contextmenu', 'mouseenter', 'mousedown', 'mousemove', 'mouseup', 'mouseout',
* 'click', 'dblclick', 'keydown', 'keypress', 'keyup' and 'resize'
* @prop {*} native - The original native event (null for emulated events, e.g. 'resize')
* @prop {Number} x - The mouse x position, relative to the canvas (null for incompatible events)
* @prop {Number} y - The mouse y position, relative to the canvas (null for incompatible events)
*/
},{"45":45,"46":46,"47":47}],49:[function(require,module,exports){
'use strict';
module.exports = {};
module.exports.filler = require(50);
module.exports.legend = require(51);
module.exports.title = require(52);
},{"50":50,"51":51,"52":52}],50:[function(require,module,exports){
/**
* Plugin based on discussion from the following Chart.js issues:
* @see https://github.com/chartjs/Chart.js/issues/2380#issuecomment-279961569
* @see https://github.com/chartjs/Chart.js/issues/2440#issuecomment-256461897
*/
'use strict';
var defaults = require(25);
var elements = require(40);
var helpers = require(45);
defaults._set('global', {
plugins: {
filler: {
propagate: true
}
}
});
var mappers = {
dataset: function(source) {
var index = source.fill;
var chart = source.chart;
var meta = chart.getDatasetMeta(index);
var visible = meta && chart.isDatasetVisible(index);
var points = (visible && meta.dataset._children) || [];
var length = points.length || 0;
return !length ? null : function(point, i) {
return (i < length && points[i]._view) || null;
};
},
boundary: function(source) {
var boundary = source.boundary;
var x = boundary ? boundary.x : null;
var y = boundary ? boundary.y : null;
return function(point) {
return {
x: x === null ? point.x : x,
y: y === null ? point.y : y,
};
};
}
};
// @todo if (fill[0] === '#')
function decodeFill(el, index, count) {
var model = el._model || {};
var fill = model.fill;
var target;
if (fill === undefined) {
fill = !!model.backgroundColor;
}
if (fill === false || fill === null) {
return false;
}
if (fill === true) {
return 'origin';
}
target = parseFloat(fill, 10);
if (isFinite(target) && Math.floor(target) === target) {
if (fill[0] === '-' || fill[0] === '+') {
target = index + target;
}
if (target === index || target < 0 || target >= count) {
return false;
}
return target;
}
switch (fill) {
// compatibility
case 'bottom':
return 'start';
case 'top':
return 'end';
case 'zero':
return 'origin';
// supported boundaries
case 'origin':
case 'start':
case 'end':
return fill;
// invalid fill values
default:
return false;
}
}
function computeBoundary(source) {
var model = source.el._model || {};
var scale = source.el._scale || {};
var fill = source.fill;
var target = null;
var horizontal;
if (isFinite(fill)) {
return null;
}
// Backward compatibility: until v3, we still need to support boundary values set on
// the model (scaleTop, scaleBottom and scaleZero) because some external plugins and
// controllers might still use it (e.g. the Smith chart).
if (fill === 'start') {
target = model.scaleBottom === undefined ? scale.bottom : model.scaleBottom;
} else if (fill === 'end') {
target = model.scaleTop === undefined ? scale.top : model.scaleTop;
} else if (model.scaleZero !== undefined) {
target = model.scaleZero;
} else if (scale.getBasePosition) {
target = scale.getBasePosition();
} else if (scale.getBasePixel) {
target = scale.getBasePixel();
}
if (target !== undefined && target !== null) {
if (target.x !== undefined && target.y !== undefined) {
return target;
}
if (typeof target === 'number' && isFinite(target)) {
horizontal = scale.isHorizontal();
return {
x: horizontal ? target : null,
y: horizontal ? null : target
};
}
}
return null;
}
function resolveTarget(sources, index, propagate) {
var source = sources[index];
var fill = source.fill;
var visited = [index];
var target;
if (!propagate) {
return fill;
}
while (fill !== false && visited.indexOf(fill) === -1) {
if (!isFinite(fill)) {
return fill;
}
target = sources[fill];
if (!target) {
return false;
}
if (target.visible) {
return fill;
}
visited.push(fill);
fill = target.fill;
}
return false;
}
function createMapper(source) {
var fill = source.fill;
var type = 'dataset';
if (fill === false) {
return null;
}
if (!isFinite(fill)) {
type = 'boundary';
}
return mappers[type](source);
}
function isDrawable(point) {
return point && !point.skip;
}
function drawArea(ctx, curve0, curve1, len0, len1) {
var i;
if (!len0 || !len1) {
return;
}
// building first area curve (normal)
ctx.moveTo(curve0[0].x, curve0[0].y);
for (i = 1; i < len0; ++i) {
helpers.canvas.lineTo(ctx, curve0[i - 1], curve0[i]);
}
// joining the two area curves
ctx.lineTo(curve1[len1 - 1].x, curve1[len1 - 1].y);
// building opposite area curve (reverse)
for (i = len1 - 1; i > 0; --i) {
helpers.canvas.lineTo(ctx, curve1[i], curve1[i - 1], true);
}
}
function doFill(ctx, points, mapper, view, color, loop) {
var count = points.length;
var span = view.spanGaps;
var curve0 = [];
var curve1 = [];
var len0 = 0;
var len1 = 0;
var i, ilen, index, p0, p1, d0, d1;
ctx.beginPath();
for (i = 0, ilen = (count + !!loop); i < ilen; ++i) {
index = i % count;
p0 = points[index]._view;
p1 = mapper(p0, index, view);
d0 = isDrawable(p0);
d1 = isDrawable(p1);
if (d0 && d1) {
len0 = curve0.push(p0);
len1 = curve1.push(p1);
} else if (len0 && len1) {
if (!span) {
drawArea(ctx, curve0, curve1, len0, len1);
len0 = len1 = 0;
curve0 = [];
curve1 = [];
} else {
if (d0) {
curve0.push(p0);
}
if (d1) {
curve1.push(p1);
}
}
}
}
drawArea(ctx, curve0, curve1, len0, len1);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
}
module.exports = {
id: 'filler',
afterDatasetsUpdate: function(chart, options) {
var count = (chart.data.datasets || []).length;
var propagate = options.propagate;
var sources = [];
var meta, i, el, source;
for (i = 0; i < count; ++i) {
meta = chart.getDatasetMeta(i);
el = meta.dataset;
source = null;
if (el && el._model && el instanceof elements.Line) {
source = {
visible: chart.isDatasetVisible(i),
fill: decodeFill(el, i, count),
chart: chart,
el: el
};
}
meta.$filler = source;
sources.push(source);
}
for (i = 0; i < count; ++i) {
source = sources[i];
if (!source) {
continue;
}
source.fill = resolveTarget(sources, i, propagate);
source.boundary = computeBoundary(source);
source.mapper = createMapper(source);
}
},
beforeDatasetDraw: function(chart, args) {
var meta = args.meta.$filler;
if (!meta) {
return;
}
var ctx = chart.ctx;
var el = meta.el;
var view = el._view;
var points = el._children || [];
var mapper = meta.mapper;
var color = view.backgroundColor || defaults.global.defaultColor;
if (mapper && color && points.length) {
helpers.canvas.clipArea(ctx, chart.chartArea);
doFill(ctx, points, mapper, view, color, el._loop);
helpers.canvas.unclipArea(ctx);
}
}
};
},{"25":25,"40":40,"45":45}],51:[function(require,module,exports){
'use strict';
var defaults = require(25);
var Element = require(26);
var helpers = require(45);
var layouts = require(30);
var noop = helpers.noop;
defaults._set('global', {
legend: {
display: true,
position: 'top',
fullWidth: true,
reverse: false,
weight: 1000,
// a callback that will handle
onClick: function(e, legendItem) {
var index = legendItem.datasetIndex;
var ci = this.chart;
var meta = ci.getDatasetMeta(index);
// See controller.isDatasetVisible comment
meta.hidden = meta.hidden === null ? !ci.data.datasets[index].hidden : null;
// We hid a dataset ... rerender the chart
ci.update();
},
onHover: null,
labels: {
boxWidth: 40,
padding: 10,
// Generates labels shown in the legend
// Valid properties to return:
// text : text to display
// fillStyle : fill of coloured box
// strokeStyle: stroke of coloured box
// hidden : if this legend item refers to a hidden item
// lineCap : cap style for line
// lineDash
// lineDashOffset :
// lineJoin :
// lineWidth :
generateLabels: function(chart) {
var data = chart.data;
return helpers.isArray(data.datasets) ? data.datasets.map(function(dataset, i) {
return {
text: dataset.label,
fillStyle: (!helpers.isArray(dataset.backgroundColor) ? dataset.backgroundColor : dataset.backgroundColor[0]),
hidden: !chart.isDatasetVisible(i),
lineCap: dataset.borderCapStyle,
lineDash: dataset.borderDash,
lineDashOffset: dataset.borderDashOffset,
lineJoin: dataset.borderJoinStyle,
lineWidth: dataset.borderWidth,
strokeStyle: dataset.borderColor,
pointStyle: dataset.pointStyle,
// Below is extra data used for toggling the datasets
datasetIndex: i
};
}, this) : [];
}
}
},
legendCallback: function(chart) {
var text = [];
text.push('
');
for (var i = 0; i < chart.data.datasets.length; i++) {
text.push('
');
if (chart.data.datasets[i].label) {
text.push(chart.data.datasets[i].label);
}
text.push('
');
}
text.push('
');
return text.join('');
}
});
/**
* Helper function to get the box width based on the usePointStyle option
* @param labelopts {Object} the label options on the legend
* @param fontSize {Number} the label font size
* @return {Number} width of the color box area
*/
function getBoxWidth(labelOpts, fontSize) {
return labelOpts.usePointStyle ?
fontSize * Math.SQRT2 :
labelOpts.boxWidth;
}
/**
* IMPORTANT: this class is exposed publicly as Chart.Legend, backward compatibility required!
*/
var Legend = Element.extend({
initialize: function(config) {
helpers.extend(this, config);
// Contains hit boxes for each dataset (in dataset order)
this.legendHitBoxes = [];
// Are we in doughnut mode which has a different data type
this.doughnutMode = false;
},
// These methods are ordered by lifecycle. Utilities then follow.
// Any function defined here is inherited by all legend types.
// Any function can be extended by the legend type
beforeUpdate: noop,
update: function(maxWidth, maxHeight, margins) {
var me = this;
// Update Lifecycle - Probably don't want to ever extend or overwrite this function ;)
me.beforeUpdate();
// Absorb the master measurements
me.maxWidth = maxWidth;
me.maxHeight = maxHeight;
me.margins = margins;
// Dimensions
me.beforeSetDimensions();
me.setDimensions();
me.afterSetDimensions();
// Labels
me.beforeBuildLabels();
me.buildLabels();
me.afterBuildLabels();
// Fit
me.beforeFit();
me.fit();
me.afterFit();
//
me.afterUpdate();
return me.minSize;
},
afterUpdate: noop,
//
beforeSetDimensions: noop,
setDimensions: function() {
var me = this;
// Set the unconstrained dimension before label rotation
if (me.isHorizontal()) {
// Reset position before calculating rotation
me.width = me.maxWidth;
me.left = 0;
me.right = me.width;
} else {
me.height = me.maxHeight;
// Reset position before calculating rotation
me.top = 0;
me.bottom = me.height;
}
// Reset padding
me.paddingLeft = 0;
me.paddingTop = 0;
me.paddingRight = 0;
me.paddingBottom = 0;
// Reset minSize
me.minSize = {
width: 0,
height: 0
};
},
afterSetDimensions: noop,
//
beforeBuildLabels: noop,
buildLabels: function() {
var me = this;
var labelOpts = me.options.labels || {};
var legendItems = helpers.callback(labelOpts.generateLabels, [me.chart], me) || [];
if (labelOpts.filter) {
legendItems = legendItems.filter(function(item) {
return labelOpts.filter(item, me.chart.data);
});
}
if (me.options.reverse) {
legendItems.reverse();
}
me.legendItems = legendItems;
},
afterBuildLabels: noop,
//
beforeFit: noop,
fit: function() {
var me = this;
var opts = me.options;
var labelOpts = opts.labels;
var display = opts.display;
var ctx = me.ctx;
var globalDefault = defaults.global;
var valueOrDefault = helpers.valueOrDefault;
var fontSize = valueOrDefault(labelOpts.fontSize, globalDefault.defaultFontSize);
var fontStyle = valueOrDefault(labelOpts.fontStyle, globalDefault.defaultFontStyle);
var fontFamily = valueOrDefault(labelOpts.fontFamily, globalDefault.defaultFontFamily);
var labelFont = helpers.fontString(fontSize, fontStyle, fontFamily);
// Reset hit boxes
var hitboxes = me.legendHitBoxes = [];
var minSize = me.minSize;
var isHorizontal = me.isHorizontal();
if (isHorizontal) {
minSize.width = me.maxWidth; // fill all the width
minSize.height = display ? 10 : 0;
} else {
minSize.width = display ? 10 : 0;
minSize.height = me.maxHeight; // fill all the height
}
// Increase sizes here
if (display) {
ctx.font = labelFont;
if (isHorizontal) {
// Labels
// Width of each line of legend boxes. Labels wrap onto multiple lines when there are too many to fit on one
var lineWidths = me.lineWidths = [0];
var totalHeight = me.legendItems.length ? fontSize + (labelOpts.padding) : 0;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
helpers.each(me.legendItems, function(legendItem, i) {
var boxWidth = getBoxWidth(labelOpts, fontSize);
var width = boxWidth + (fontSize / 2) + ctx.measureText(legendItem.text).width;
if (lineWidths[lineWidths.length - 1] + width + labelOpts.padding >= me.width) {
totalHeight += fontSize + (labelOpts.padding);
lineWidths[lineWidths.length] = me.left;
}
// Store the hitbox width and height here. Final position will be updated in `draw`
hitboxes[i] = {
left: 0,
top: 0,
width: width,
height: fontSize
};
lineWidths[lineWidths.length - 1] += width + labelOpts.padding;
});
minSize.height += totalHeight;
} else {
var vPadding = labelOpts.padding;
var columnWidths = me.columnWidths = [];
var totalWidth = labelOpts.padding;
var currentColWidth = 0;
var currentColHeight = 0;
var itemHeight = fontSize + vPadding;
helpers.each(me.legendItems, function(legendItem, i) {
var boxWidth = getBoxWidth(labelOpts, fontSize);
var itemWidth = boxWidth + (fontSize / 2) + ctx.measureText(legendItem.text).width;
// If too tall, go to new column
if (currentColHeight + itemHeight > minSize.height) {
totalWidth += currentColWidth + labelOpts.padding;
columnWidths.push(currentColWidth); // previous column width
currentColWidth = 0;
currentColHeight = 0;
}
// Get max width
currentColWidth = Math.max(currentColWidth, itemWidth);
currentColHeight += itemHeight;
// Store the hitbox width and height here. Final position will be updated in `draw`
hitboxes[i] = {
left: 0,
top: 0,
width: itemWidth,
height: fontSize
};
});
totalWidth += currentColWidth;
columnWidths.push(currentColWidth);
minSize.width += totalWidth;
}
}
me.width = minSize.width;
me.height = minSize.height;
},
afterFit: noop,
// Shared Methods
isHorizontal: function() {
return this.options.position === 'top' || this.options.position === 'bottom';
},
// Actually draw the legend on the canvas
draw: function() {
var me = this;
var opts = me.options;
var labelOpts = opts.labels;
var globalDefault = defaults.global;
var lineDefault = globalDefault.elements.line;
var legendWidth = me.width;
var lineWidths = me.lineWidths;
if (opts.display) {
var ctx = me.ctx;
var valueOrDefault = helpers.valueOrDefault;
var fontColor = valueOrDefault(labelOpts.fontColor, globalDefault.defaultFontColor);
var fontSize = valueOrDefault(labelOpts.fontSize, globalDefault.defaultFontSize);
var fontStyle = valueOrDefault(labelOpts.fontStyle, globalDefault.defaultFontStyle);
var fontFamily = valueOrDefault(labelOpts.fontFamily, globalDefault.defaultFontFamily);
var labelFont = helpers.fontString(fontSize, fontStyle, fontFamily);
var cursor;
// Canvas setup
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.lineWidth = 0.5;
ctx.strokeStyle = fontColor; // for strikethrough effect
ctx.fillStyle = fontColor; // render in correct colour
ctx.font = labelFont;
var boxWidth = getBoxWidth(labelOpts, fontSize);
var hitboxes = me.legendHitBoxes;
// current position
var drawLegendBox = function(x, y, legendItem) {
if (isNaN(boxWidth) || boxWidth <= 0) {
return;
}
// Set the ctx for the box
ctx.save();
ctx.fillStyle = valueOrDefault(legendItem.fillStyle, globalDefault.defaultColor);
ctx.lineCap = valueOrDefault(legendItem.lineCap, lineDefault.borderCapStyle);
ctx.lineDashOffset = valueOrDefault(legendItem.lineDashOffset, lineDefault.borderDashOffset);
ctx.lineJoin = valueOrDefault(legendItem.lineJoin, lineDefault.borderJoinStyle);
ctx.lineWidth = valueOrDefault(legendItem.lineWidth, lineDefault.borderWidth);
ctx.strokeStyle = valueOrDefault(legendItem.strokeStyle, globalDefault.defaultColor);
var isLineWidthZero = (valueOrDefault(legendItem.lineWidth, lineDefault.borderWidth) === 0);
if (ctx.setLineDash) {
// IE 9 and 10 do not support line dash
ctx.setLineDash(valueOrDefault(legendItem.lineDash, lineDefault.borderDash));
}
if (opts.labels && opts.labels.usePointStyle) {
// Recalculate x and y for drawPoint() because its expecting
// x and y to be center of figure (instead of top left)
var radius = fontSize * Math.SQRT2 / 2;
var offSet = radius / Math.SQRT2;
var centerX = x + offSet;
var centerY = y + offSet;
// Draw pointStyle as legend symbol
helpers.canvas.drawPoint(ctx, legendItem.pointStyle, radius, centerX, centerY);
} else {
// Draw box as legend symbol
if (!isLineWidthZero) {
ctx.strokeRect(x, y, boxWidth, fontSize);
}
ctx.fillRect(x, y, boxWidth, fontSize);
}
ctx.restore();
};
var fillText = function(x, y, legendItem, textWidth) {
var halfFontSize = fontSize / 2;
var xLeft = boxWidth + halfFontSize + x;
var yMiddle = y + halfFontSize;
ctx.fillText(legendItem.text, xLeft, yMiddle);
if (legendItem.hidden) {
// Strikethrough the text if hidden
ctx.beginPath();
ctx.lineWidth = 2;
ctx.moveTo(xLeft, yMiddle);
ctx.lineTo(xLeft + textWidth, yMiddle);
ctx.stroke();
}
};
// Horizontal
var isHorizontal = me.isHorizontal();
if (isHorizontal) {
cursor = {
x: me.left + ((legendWidth - lineWidths[0]) / 2),
y: me.top + labelOpts.padding,
line: 0
};
} else {
cursor = {
x: me.left + labelOpts.padding,
y: me.top + labelOpts.padding,
line: 0
};
}
var itemHeight = fontSize + labelOpts.padding;
helpers.each(me.legendItems, function(legendItem, i) {
var textWidth = ctx.measureText(legendItem.text).width;
var width = boxWidth + (fontSize / 2) + textWidth;
var x = cursor.x;
var y = cursor.y;
if (isHorizontal) {
if (x + width >= legendWidth) {
y = cursor.y += itemHeight;
cursor.line++;
x = cursor.x = me.left + ((legendWidth - lineWidths[cursor.line]) / 2);
}
} else if (y + itemHeight > me.bottom) {
x = cursor.x = x + me.columnWidths[cursor.line] + labelOpts.padding;
y = cursor.y = me.top + labelOpts.padding;
cursor.line++;
}
drawLegendBox(x, y, legendItem);
hitboxes[i].left = x;
hitboxes[i].top = y;
// Fill the actual label
fillText(x, y, legendItem, textWidth);
if (isHorizontal) {
cursor.x += width + (labelOpts.padding);
} else {
cursor.y += itemHeight;
}
});
}
},
/**
* Handle an event
* @private
* @param {IEvent} event - The event to handle
* @return {Boolean} true if a change occured
*/
handleEvent: function(e) {
var me = this;
var opts = me.options;
var type = e.type === 'mouseup' ? 'click' : e.type;
var changed = false;
if (type === 'mousemove') {
if (!opts.onHover) {
return;
}
} else if (type === 'click') {
if (!opts.onClick) {
return;
}
} else {
return;
}
// Chart event already has relative position in it
var x = e.x;
var y = e.y;
if (x >= me.left && x <= me.right && y >= me.top && y <= me.bottom) {
// See if we are touching one of the dataset boxes
var lh = me.legendHitBoxes;
for (var i = 0; i < lh.length; ++i) {
var hitBox = lh[i];
if (x >= hitBox.left && x <= hitBox.left + hitBox.width && y >= hitBox.top && y <= hitBox.top + hitBox.height) {
// Touching an element
if (type === 'click') {
// use e.native for backwards compatibility
opts.onClick.call(me, e.native, me.legendItems[i]);
changed = true;
break;
} else if (type === 'mousemove') {
// use e.native for backwards compatibility
opts.onHover.call(me, e.native, me.legendItems[i]);
changed = true;
break;
}
}
}
}
return changed;
}
});
function createNewLegendAndAttach(chart, legendOpts) {
var legend = new Legend({
ctx: chart.ctx,
options: legendOpts,
chart: chart
});
layouts.configure(chart, legend, legendOpts);
layouts.addBox(chart, legend);
chart.legend = legend;
}
module.exports = {
id: 'legend',
/**
* Backward compatibility: since 2.1.5, the legend is registered as a plugin, making
* Chart.Legend obsolete. To avoid a breaking change, we export the Legend as part of
* the plugin, which one will be re-exposed in the chart.js file.
* https://github.com/chartjs/Chart.js/pull/2640
* @private
*/
_element: Legend,
beforeInit: function(chart) {
var legendOpts = chart.options.legend;
if (legendOpts) {
createNewLegendAndAttach(chart, legendOpts);
}
},
beforeUpdate: function(chart) {
var legendOpts = chart.options.legend;
var legend = chart.legend;
if (legendOpts) {
helpers.mergeIf(legendOpts, defaults.global.legend);
if (legend) {
layouts.configure(chart, legend, legendOpts);
legend.options = legendOpts;
} else {
createNewLegendAndAttach(chart, legendOpts);
}
} else if (legend) {
layouts.removeBox(chart, legend);
delete chart.legend;
}
},
afterEvent: function(chart, e) {
var legend = chart.legend;
if (legend) {
legend.handleEvent(e);
}
}
};
},{"25":25,"26":26,"30":30,"45":45}],52:[function(require,module,exports){
'use strict';
var defaults = require(25);
var Element = require(26);
var helpers = require(45);
var layouts = require(30);
var noop = helpers.noop;
defaults._set('global', {
title: {
display: false,
fontStyle: 'bold',
fullWidth: true,
lineHeight: 1.2,
padding: 10,
position: 'top',
text: '',
weight: 2000 // by default greater than legend (1000) to be above
}
});
/**
* IMPORTANT: this class is exposed publicly as Chart.Legend, backward compatibility required!
*/
var Title = Element.extend({
initialize: function(config) {
var me = this;
helpers.extend(me, config);
// Contains hit boxes for each dataset (in dataset order)
me.legendHitBoxes = [];
},
// These methods are ordered by lifecycle. Utilities then follow.
beforeUpdate: noop,
update: function(maxWidth, maxHeight, margins) {
var me = this;
// Update Lifecycle - Probably don't want to ever extend or overwrite this function ;)
me.beforeUpdate();
// Absorb the master measurements
me.maxWidth = maxWidth;
me.maxHeight = maxHeight;
me.margins = margins;
// Dimensions
me.beforeSetDimensions();
me.setDimensions();
me.afterSetDimensions();
// Labels
me.beforeBuildLabels();
me.buildLabels();
me.afterBuildLabels();
// Fit
me.beforeFit();
me.fit();
me.afterFit();
//
me.afterUpdate();
return me.minSize;
},
afterUpdate: noop,
//
beforeSetDimensions: noop,
setDimensions: function() {
var me = this;
// Set the unconstrained dimension before label rotation
if (me.isHorizontal()) {
// Reset position before calculating rotation
me.width = me.maxWidth;
me.left = 0;
me.right = me.width;
} else {
me.height = me.maxHeight;
// Reset position before calculating rotation
me.top = 0;
me.bottom = me.height;
}
// Reset padding
me.paddingLeft = 0;
me.paddingTop = 0;
me.paddingRight = 0;
me.paddingBottom = 0;
// Reset minSize
me.minSize = {
width: 0,
height: 0
};
},
afterSetDimensions: noop,
//
beforeBuildLabels: noop,
buildLabels: noop,
afterBuildLabels: noop,
//
beforeFit: noop,
fit: function() {
var me = this;
var valueOrDefault = helpers.valueOrDefault;
var opts = me.options;
var display = opts.display;
var fontSize = valueOrDefault(opts.fontSize, defaults.global.defaultFontSize);
var minSize = me.minSize;
var lineCount = helpers.isArray(opts.text) ? opts.text.length : 1;
var lineHeight = helpers.options.toLineHeight(opts.lineHeight, fontSize);
var textSize = display ? (lineCount * lineHeight) + (opts.padding * 2) : 0;
if (me.isHorizontal()) {
minSize.width = me.maxWidth; // fill all the width
minSize.height = textSize;
} else {
minSize.width = textSize;
minSize.height = me.maxHeight; // fill all the height
}
me.width = minSize.width;
me.height = minSize.height;
},
afterFit: noop,
// Shared Methods
isHorizontal: function() {
var pos = this.options.position;
return pos === 'top' || pos === 'bottom';
},
// Actually draw the title block on the canvas
draw: function() {
var me = this;
var ctx = me.ctx;
var valueOrDefault = helpers.valueOrDefault;
var opts = me.options;
var globalDefaults = defaults.global;
if (opts.display) {
var fontSize = valueOrDefault(opts.fontSize, globalDefaults.defaultFontSize);
var fontStyle = valueOrDefault(opts.fontStyle, globalDefaults.defaultFontStyle);
var fontFamily = valueOrDefault(opts.fontFamily, globalDefaults.defaultFontFamily);
var titleFont = helpers.fontString(fontSize, fontStyle, fontFamily);
var lineHeight = helpers.options.toLineHeight(opts.lineHeight, fontSize);
var offset = lineHeight / 2 + opts.padding;
var rotation = 0;
var top = me.top;
var left = me.left;
var bottom = me.bottom;
var right = me.right;
var maxWidth, titleX, titleY;
ctx.fillStyle = valueOrDefault(opts.fontColor, globalDefaults.defaultFontColor); // render in correct colour
ctx.font = titleFont;
// Horizontal
if (me.isHorizontal()) {
titleX = left + ((right - left) / 2); // midpoint of the width
titleY = top + offset;
maxWidth = right - left;
} else {
titleX = opts.position === 'left' ? left + offset : right - offset;
titleY = top + ((bottom - top) / 2);
maxWidth = bottom - top;
rotation = Math.PI * (opts.position === 'left' ? -0.5 : 0.5);
}
ctx.save();
ctx.translate(titleX, titleY);
ctx.rotate(rotation);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
var text = opts.text;
if (helpers.isArray(text)) {
var y = 0;
for (var i = 0; i < text.length; ++i) {
ctx.fillText(text[i], 0, y, maxWidth);
y += lineHeight;
}
} else {
ctx.fillText(text, 0, 0, maxWidth);
}
ctx.restore();
}
}
});
function createNewTitleBlockAndAttach(chart, titleOpts) {
var title = new Title({
ctx: chart.ctx,
options: titleOpts,
chart: chart
});
layouts.configure(chart, title, titleOpts);
layouts.addBox(chart, title);
chart.titleBlock = title;
}
module.exports = {
id: 'title',
/**
* Backward compatibility: since 2.1.5, the title is registered as a plugin, making
* Chart.Title obsolete. To avoid a breaking change, we export the Title as part of
* the plugin, which one will be re-exposed in the chart.js file.
* https://github.com/chartjs/Chart.js/pull/2640
* @private
*/
_element: Title,
beforeInit: function(chart) {
var titleOpts = chart.options.title;
if (titleOpts) {
createNewTitleBlockAndAttach(chart, titleOpts);
}
},
beforeUpdate: function(chart) {
var titleOpts = chart.options.title;
var titleBlock = chart.titleBlock;
if (titleOpts) {
helpers.mergeIf(titleOpts, defaults.global.title);
if (titleBlock) {
layouts.configure(chart, titleBlock, titleOpts);
titleBlock.options = titleOpts;
} else {
createNewTitleBlockAndAttach(chart, titleOpts);
}
} else if (titleBlock) {
layouts.removeBox(chart, titleBlock);
delete chart.titleBlock;
}
}
};
},{"25":25,"26":26,"30":30,"45":45}],53:[function(require,module,exports){
'use strict';
module.exports = function(Chart) {
// Default config for a category scale
var defaultConfig = {
position: 'bottom'
};
var DatasetScale = Chart.Scale.extend({
/**
* Internal function to get the correct labels. If data.xLabels or data.yLabels are defined, use those
* else fall back to data.labels
* @private
*/
getLabels: function() {
var data = this.chart.data;
return this.options.labels || (this.isHorizontal() ? data.xLabels : data.yLabels) || data.labels;
},
determineDataLimits: function() {
var me = this;
var labels = me.getLabels();
me.minIndex = 0;
me.maxIndex = labels.length - 1;
var findIndex;
if (me.options.ticks.min !== undefined) {
// user specified min value
findIndex = labels.indexOf(me.options.ticks.min);
me.minIndex = findIndex !== -1 ? findIndex : me.minIndex;
}
if (me.options.ticks.max !== undefined) {
// user specified max value
findIndex = labels.indexOf(me.options.ticks.max);
me.maxIndex = findIndex !== -1 ? findIndex : me.maxIndex;
}
me.min = labels[me.minIndex];
me.max = labels[me.maxIndex];
},
buildTicks: function() {
var me = this;
var labels = me.getLabels();
// If we are viewing some subset of labels, slice the original array
me.ticks = (me.minIndex === 0 && me.maxIndex === labels.length - 1) ? labels : labels.slice(me.minIndex, me.maxIndex + 1);
},
getLabelForIndex: function(index, datasetIndex) {
var me = this;
var data = me.chart.data;
var isHorizontal = me.isHorizontal();
if (data.yLabels && !isHorizontal) {
return me.getRightValue(data.datasets[datasetIndex].data[index]);
}
return me.ticks[index - me.minIndex];
},
// Used to get data value locations. Value can either be an index or a numerical value
getPixelForValue: function(value, index) {
var me = this;
var offset = me.options.offset;
// 1 is added because we need the length but we have the indexes
var offsetAmt = Math.max((me.maxIndex + 1 - me.minIndex - (offset ? 0 : 1)), 1);
// If value is a data object, then index is the index in the data array,
// not the index of the scale. We need to change that.
var valueCategory;
if (value !== undefined && value !== null) {
valueCategory = me.isHorizontal() ? value.x : value.y;
}
if (valueCategory !== undefined || (value !== undefined && isNaN(index))) {
var labels = me.getLabels();
value = valueCategory || value;
var idx = labels.indexOf(value);
index = idx !== -1 ? idx : index;
}
if (me.isHorizontal()) {
var valueWidth = me.width / offsetAmt;
var widthOffset = (valueWidth * (index - me.minIndex));
if (offset) {
widthOffset += (valueWidth / 2);
}
return me.left + Math.round(widthOffset);
}
var valueHeight = me.height / offsetAmt;
var heightOffset = (valueHeight * (index - me.minIndex));
if (offset) {
heightOffset += (valueHeight / 2);
}
return me.top + Math.round(heightOffset);
},
getPixelForTick: function(index) {
return this.getPixelForValue(this.ticks[index], index + this.minIndex, null);
},
getValueForPixel: function(pixel) {
var me = this;
var offset = me.options.offset;
var value;
var offsetAmt = Math.max((me._ticks.length - (offset ? 0 : 1)), 1);
var horz = me.isHorizontal();
var valueDimension = (horz ? me.width : me.height) / offsetAmt;
pixel -= horz ? me.left : me.top;
if (offset) {
pixel -= (valueDimension / 2);
}
if (pixel <= 0) {
value = 0;
} else {
value = Math.round(pixel / valueDimension);
}
return value + me.minIndex;
},
getBasePixel: function() {
return this.bottom;
}
});
Chart.scaleService.registerScaleType('category', DatasetScale, defaultConfig);
};
},{}],54:[function(require,module,exports){
'use strict';
var defaults = require(25);
var helpers = require(45);
var Ticks = require(34);
module.exports = function(Chart) {
var defaultConfig = {
position: 'left',
ticks: {
callback: Ticks.formatters.linear
}
};
var LinearScale = Chart.LinearScaleBase.extend({
determineDataLimits: function() {
var me = this;
var opts = me.options;
var chart = me.chart;
var data = chart.data;
var datasets = data.datasets;
var isHorizontal = me.isHorizontal();
var DEFAULT_MIN = 0;
var DEFAULT_MAX = 1;
function IDMatches(meta) {
return isHorizontal ? meta.xAxisID === me.id : meta.yAxisID === me.id;
}
// First Calculate the range
me.min = null;
me.max = null;
var hasStacks = opts.stacked;
if (hasStacks === undefined) {
helpers.each(datasets, function(dataset, datasetIndex) {
if (hasStacks) {
return;
}
var meta = chart.getDatasetMeta(datasetIndex);
if (chart.isDatasetVisible(datasetIndex) && IDMatches(meta) &&
meta.stack !== undefined) {
hasStacks = true;
}
});
}
if (opts.stacked || hasStacks) {
var valuesPerStack = {};
helpers.each(datasets, function(dataset, datasetIndex) {
var meta = chart.getDatasetMeta(datasetIndex);
var key = [
meta.type,
// we have a separate stack for stack=undefined datasets when the opts.stacked is undefined
((opts.stacked === undefined && meta.stack === undefined) ? datasetIndex : ''),
meta.stack
].join('.');
if (valuesPerStack[key] === undefined) {
valuesPerStack[key] = {
positiveValues: [],
negativeValues: []
};
}
// Store these per type
var positiveValues = valuesPerStack[key].positiveValues;
var negativeValues = valuesPerStack[key].negativeValues;
if (chart.isDatasetVisible(datasetIndex) && IDMatches(meta)) {
helpers.each(dataset.data, function(rawValue, index) {
var value = +me.getRightValue(rawValue);
if (isNaN(value) || meta.data[index].hidden) {
return;
}
positiveValues[index] = positiveValues[index] || 0;
negativeValues[index] = negativeValues[index] || 0;
if (opts.relativePoints) {
positiveValues[index] = 100;
} else if (value < 0) {
negativeValues[index] += value;
} else {
positiveValues[index] += value;
}
});
}
});
helpers.each(valuesPerStack, function(valuesForType) {
var values = valuesForType.positiveValues.concat(valuesForType.negativeValues);
var minVal = helpers.min(values);
var maxVal = helpers.max(values);
me.min = me.min === null ? minVal : Math.min(me.min, minVal);
me.max = me.max === null ? maxVal : Math.max(me.max, maxVal);
});
} else {
helpers.each(datasets, function(dataset, datasetIndex) {
var meta = chart.getDatasetMeta(datasetIndex);
if (chart.isDatasetVisible(datasetIndex) && IDMatches(meta)) {
helpers.each(dataset.data, function(rawValue, index) {
var value = +me.getRightValue(rawValue);
if (isNaN(value) || meta.data[index].hidden) {
return;
}
if (me.min === null) {
me.min = value;
} else if (value < me.min) {
me.min = value;
}
if (me.max === null) {
me.max = value;
} else if (value > me.max) {
me.max = value;
}
});
}
});
}
me.min = isFinite(me.min) && !isNaN(me.min) ? me.min : DEFAULT_MIN;
me.max = isFinite(me.max) && !isNaN(me.max) ? me.max : DEFAULT_MAX;
// Common base implementation to handle ticks.min, ticks.max, ticks.beginAtZero
this.handleTickRangeOptions();
},
getTickLimit: function() {
var maxTicks;
var me = this;
var tickOpts = me.options.ticks;
if (me.isHorizontal()) {
maxTicks = Math.min(tickOpts.maxTicksLimit ? tickOpts.maxTicksLimit : 11, Math.ceil(me.width / 50));
} else {
// The factor of 2 used to scale the font size has been experimentally determined.
var tickFontSize = helpers.valueOrDefault(tickOpts.fontSize, defaults.global.defaultFontSize);
maxTicks = Math.min(tickOpts.maxTicksLimit ? tickOpts.maxTicksLimit : 11, Math.ceil(me.height / (2 * tickFontSize)));
}
return maxTicks;
},
// Called after the ticks are built. We need
handleDirectionalChanges: function() {
if (!this.isHorizontal()) {
// We are in a vertical orientation. The top value is the highest. So reverse the array
this.ticks.reverse();
}
},
getLabelForIndex: function(index, datasetIndex) {
return +this.getRightValue(this.chart.data.datasets[datasetIndex].data[index]);
},
// Utils
getPixelForValue: function(value) {
// This must be called after fit has been run so that
// this.left, this.top, this.right, and this.bottom have been defined
var me = this;
var start = me.start;
var rightValue = +me.getRightValue(value);
var pixel;
var range = me.end - start;
if (me.isHorizontal()) {
pixel = me.left + (me.width / range * (rightValue - start));
} else {
pixel = me.bottom - (me.height / range * (rightValue - start));
}
return pixel;
},
getValueForPixel: function(pixel) {
var me = this;
var isHorizontal = me.isHorizontal();
var innerDimension = isHorizontal ? me.width : me.height;
var offset = (isHorizontal ? pixel - me.left : me.bottom - pixel) / innerDimension;
return me.start + ((me.end - me.start) * offset);
},
getPixelForTick: function(index) {
return this.getPixelForValue(this.ticksAsNumbers[index]);
}
});
Chart.scaleService.registerScaleType('linear', LinearScale, defaultConfig);
};
},{"25":25,"34":34,"45":45}],55:[function(require,module,exports){
'use strict';
var helpers = require(45);
/**
* Generate a set of linear ticks
* @param generationOptions the options used to generate the ticks
* @param dataRange the range of the data
* @returns {Array} array of tick values
*/
function generateTicks(generationOptions, dataRange) {
var ticks = [];
// To get a "nice" value for the tick spacing, we will use the appropriately named
// "nice number" algorithm. See http://stackoverflow.com/questions/8506881/nice-label-algorithm-for-charts-with-minimum-ticks
// for details.
var spacing;
if (generationOptions.stepSize && generationOptions.stepSize > 0) {
spacing = generationOptions.stepSize;
} else {
var niceRange = helpers.niceNum(dataRange.max - dataRange.min, false);
spacing = helpers.niceNum(niceRange / (generationOptions.maxTicks - 1), true);
}
var niceMin = Math.floor(dataRange.min / spacing) * spacing;
var niceMax = Math.ceil(dataRange.max / spacing) * spacing;
// If min, max and stepSize is set and they make an evenly spaced scale use it.
if (generationOptions.min && generationOptions.max && generationOptions.stepSize) {
// If very close to our whole number, use it.
if (helpers.almostWhole((generationOptions.max - generationOptions.min) / generationOptions.stepSize, spacing / 1000)) {
niceMin = generationOptions.min;
niceMax = generationOptions.max;
}
}
var numSpaces = (niceMax - niceMin) / spacing;
// If very close to our rounded value, use it.
if (helpers.almostEquals(numSpaces, Math.round(numSpaces), spacing / 1000)) {
numSpaces = Math.round(numSpaces);
} else {
numSpaces = Math.ceil(numSpaces);
}
var precision = 1;
if (spacing < 1) {
precision = Math.pow(10, spacing.toString().length - 2);
niceMin = Math.round(niceMin * precision) / precision;
niceMax = Math.round(niceMax * precision) / precision;
}
ticks.push(generationOptions.min !== undefined ? generationOptions.min : niceMin);
for (var j = 1; j < numSpaces; ++j) {
ticks.push(Math.round((niceMin + j * spacing) * precision) / precision);
}
ticks.push(generationOptions.max !== undefined ? generationOptions.max : niceMax);
return ticks;
}
module.exports = function(Chart) {
var noop = helpers.noop;
Chart.LinearScaleBase = Chart.Scale.extend({
getRightValue: function(value) {
if (typeof value === 'string') {
return +value;
}
return Chart.Scale.prototype.getRightValue.call(this, value);
},
handleTickRangeOptions: function() {
var me = this;
var opts = me.options;
var tickOpts = opts.ticks;
// If we are forcing it to begin at 0, but 0 will already be rendered on the chart,
// do nothing since that would make the chart weird. If the user really wants a weird chart
// axis, they can manually override it
if (tickOpts.beginAtZero) {
var minSign = helpers.sign(me.min);
var maxSign = helpers.sign(me.max);
if (minSign < 0 && maxSign < 0) {
// move the top up to 0
me.max = 0;
} else if (minSign > 0 && maxSign > 0) {
// move the bottom down to 0
me.min = 0;
}
}
var setMin = tickOpts.min !== undefined || tickOpts.suggestedMin !== undefined;
var setMax = tickOpts.max !== undefined || tickOpts.suggestedMax !== undefined;
if (tickOpts.min !== undefined) {
me.min = tickOpts.min;
} else if (tickOpts.suggestedMin !== undefined) {
if (me.min === null) {
me.min = tickOpts.suggestedMin;
} else {
me.min = Math.min(me.min, tickOpts.suggestedMin);
}
}
if (tickOpts.max !== undefined) {
me.max = tickOpts.max;
} else if (tickOpts.suggestedMax !== undefined) {
if (me.max === null) {
me.max = tickOpts.suggestedMax;
} else {
me.max = Math.max(me.max, tickOpts.suggestedMax);
}
}
if (setMin !== setMax) {
// We set the min or the max but not both.
// So ensure that our range is good
// Inverted or 0 length range can happen when
// ticks.min is set, and no datasets are visible
if (me.min >= me.max) {
if (setMin) {
me.max = me.min + 1;
} else {
me.min = me.max - 1;
}
}
}
if (me.min === me.max) {
me.max++;
if (!tickOpts.beginAtZero) {
me.min--;
}
}
},
getTickLimit: noop,
handleDirectionalChanges: noop,
buildTicks: function() {
var me = this;
var opts = me.options;
var tickOpts = opts.ticks;
// Figure out what the max number of ticks we can support it is based on the size of
// the axis area. For now, we say that the minimum tick spacing in pixels must be 50
// We also limit the maximum number of ticks to 11 which gives a nice 10 squares on
// the graph. Make sure we always have at least 2 ticks
var maxTicks = me.getTickLimit();
maxTicks = Math.max(2, maxTicks);
var numericGeneratorOptions = {
maxTicks: maxTicks,
min: tickOpts.min,
max: tickOpts.max,
stepSize: helpers.valueOrDefault(tickOpts.fixedStepSize, tickOpts.stepSize)
};
var ticks = me.ticks = generateTicks(numericGeneratorOptions, me);
me.handleDirectionalChanges();
// At this point, we need to update our max and min given the tick values since we have expanded the
// range of the scale
me.max = helpers.max(ticks);
me.min = helpers.min(ticks);
if (tickOpts.reverse) {
ticks.reverse();
me.start = me.max;
me.end = me.min;
} else {
me.start = me.min;
me.end = me.max;
}
},
convertTicksToLabels: function() {
var me = this;
me.ticksAsNumbers = me.ticks.slice();
me.zeroLineIndex = me.ticks.indexOf(0);
Chart.Scale.prototype.convertTicksToLabels.call(me);
}
});
};
},{"45":45}],56:[function(require,module,exports){
'use strict';
var helpers = require(45);
var Ticks = require(34);
/**
* Generate a set of logarithmic ticks
* @param generationOptions the options used to generate the ticks
* @param dataRange the range of the data
* @returns {Array} array of tick values
*/
function generateTicks(generationOptions, dataRange) {
var ticks = [];
var valueOrDefault = helpers.valueOrDefault;
// Figure out what the max number of ticks we can support it is based on the size of
// the axis area. For now, we say that the minimum tick spacing in pixels must be 50
// We also limit the maximum number of ticks to 11 which gives a nice 10 squares on
// the graph
var tickVal = valueOrDefault(generationOptions.min, Math.pow(10, Math.floor(helpers.log10(dataRange.min))));
var endExp = Math.floor(helpers.log10(dataRange.max));
var endSignificand = Math.ceil(dataRange.max / Math.pow(10, endExp));
var exp, significand;
if (tickVal === 0) {
exp = Math.floor(helpers.log10(dataRange.minNotZero));
significand = Math.floor(dataRange.minNotZero / Math.pow(10, exp));
ticks.push(tickVal);
tickVal = significand * Math.pow(10, exp);
} else {
exp = Math.floor(helpers.log10(tickVal));
significand = Math.floor(tickVal / Math.pow(10, exp));
}
var precision = exp < 0 ? Math.pow(10, Math.abs(exp)) : 1;
do {
ticks.push(tickVal);
++significand;
if (significand === 10) {
significand = 1;
++exp;
precision = exp >= 0 ? 1 : precision;
}
tickVal = Math.round(significand * Math.pow(10, exp) * precision) / precision;
} while (exp < endExp || (exp === endExp && significand < endSignificand));
var lastTick = valueOrDefault(generationOptions.max, tickVal);
ticks.push(lastTick);
return ticks;
}
module.exports = function(Chart) {
var defaultConfig = {
position: 'left',
// label settings
ticks: {
callback: Ticks.formatters.logarithmic
}
};
var LogarithmicScale = Chart.Scale.extend({
determineDataLimits: function() {
var me = this;
var opts = me.options;
var chart = me.chart;
var data = chart.data;
var datasets = data.datasets;
var isHorizontal = me.isHorizontal();
function IDMatches(meta) {
return isHorizontal ? meta.xAxisID === me.id : meta.yAxisID === me.id;
}
// Calculate Range
me.min = null;
me.max = null;
me.minNotZero = null;
var hasStacks = opts.stacked;
if (hasStacks === undefined) {
helpers.each(datasets, function(dataset, datasetIndex) {
if (hasStacks) {
return;
}
var meta = chart.getDatasetMeta(datasetIndex);
if (chart.isDatasetVisible(datasetIndex) && IDMatches(meta) &&
meta.stack !== undefined) {
hasStacks = true;
}
});
}
if (opts.stacked || hasStacks) {
var valuesPerStack = {};
helpers.each(datasets, function(dataset, datasetIndex) {
var meta = chart.getDatasetMeta(datasetIndex);
var key = [
meta.type,
// we have a separate stack for stack=undefined datasets when the opts.stacked is undefined
((opts.stacked === undefined && meta.stack === undefined) ? datasetIndex : ''),
meta.stack
].join('.');
if (chart.isDatasetVisible(datasetIndex) && IDMatches(meta)) {
if (valuesPerStack[key] === undefined) {
valuesPerStack[key] = [];
}
helpers.each(dataset.data, function(rawValue, index) {
var values = valuesPerStack[key];
var value = +me.getRightValue(rawValue);
// invalid, hidden and negative values are ignored
if (isNaN(value) || meta.data[index].hidden || value < 0) {
return;
}
values[index] = values[index] || 0;
values[index] += value;
});
}
});
helpers.each(valuesPerStack, function(valuesForType) {
if (valuesForType.length > 0) {
var minVal = helpers.min(valuesForType);
var maxVal = helpers.max(valuesForType);
me.min = me.min === null ? minVal : Math.min(me.min, minVal);
me.max = me.max === null ? maxVal : Math.max(me.max, maxVal);
}
});
} else {
helpers.each(datasets, function(dataset, datasetIndex) {
var meta = chart.getDatasetMeta(datasetIndex);
if (chart.isDatasetVisible(datasetIndex) && IDMatches(meta)) {
helpers.each(dataset.data, function(rawValue, index) {
var value = +me.getRightValue(rawValue);
// invalid, hidden and negative values are ignored
if (isNaN(value) || meta.data[index].hidden || value < 0) {
return;
}
if (me.min === null) {
me.min = value;
} else if (value < me.min) {
me.min = value;
}
if (me.max === null) {
me.max = value;
} else if (value > me.max) {
me.max = value;
}
if (value !== 0 && (me.minNotZero === null || value < me.minNotZero)) {
me.minNotZero = value;
}
});
}
});
}
// Common base implementation to handle ticks.min, ticks.max
this.handleTickRangeOptions();
},
handleTickRangeOptions: function() {
var me = this;
var opts = me.options;
var tickOpts = opts.ticks;
var valueOrDefault = helpers.valueOrDefault;
var DEFAULT_MIN = 1;
var DEFAULT_MAX = 10;
me.min = valueOrDefault(tickOpts.min, me.min);
me.max = valueOrDefault(tickOpts.max, me.max);
if (me.min === me.max) {
if (me.min !== 0 && me.min !== null) {
me.min = Math.pow(10, Math.floor(helpers.log10(me.min)) - 1);
me.max = Math.pow(10, Math.floor(helpers.log10(me.max)) + 1);
} else {
me.min = DEFAULT_MIN;
me.max = DEFAULT_MAX;
}
}
if (me.min === null) {
me.min = Math.pow(10, Math.floor(helpers.log10(me.max)) - 1);
}
if (me.max === null) {
me.max = me.min !== 0
? Math.pow(10, Math.floor(helpers.log10(me.min)) + 1)
: DEFAULT_MAX;
}
if (me.minNotZero === null) {
if (me.min > 0) {
me.minNotZero = me.min;
} else if (me.max < 1) {
me.minNotZero = Math.pow(10, Math.floor(helpers.log10(me.max)));
} else {
me.minNotZero = DEFAULT_MIN;
}
}
},
buildTicks: function() {
var me = this;
var opts = me.options;
var tickOpts = opts.ticks;
var reverse = !me.isHorizontal();
var generationOptions = {
min: tickOpts.min,
max: tickOpts.max
};
var ticks = me.ticks = generateTicks(generationOptions, me);
// At this point, we need to update our max and min given the tick values since we have expanded the
// range of the scale
me.max = helpers.max(ticks);
me.min = helpers.min(ticks);
if (tickOpts.reverse) {
reverse = !reverse;
me.start = me.max;
me.end = me.min;
} else {
me.start = me.min;
me.end = me.max;
}
if (reverse) {
ticks.reverse();
}
},
convertTicksToLabels: function() {
this.tickValues = this.ticks.slice();
Chart.Scale.prototype.convertTicksToLabels.call(this);
},
// Get the correct tooltip label
getLabelForIndex: function(index, datasetIndex) {
return +this.getRightValue(this.chart.data.datasets[datasetIndex].data[index]);
},
getPixelForTick: function(index) {
return this.getPixelForValue(this.tickValues[index]);
},
/**
* Returns the value of the first tick.
* @param {Number} value - The minimum not zero value.
* @return {Number} The first tick value.
* @private
*/
_getFirstTickValue: function(value) {
var exp = Math.floor(helpers.log10(value));
var significand = Math.floor(value / Math.pow(10, exp));
return significand * Math.pow(10, exp);
},
getPixelForValue: function(value) {
var me = this;
var reverse = me.options.ticks.reverse;
var log10 = helpers.log10;
var firstTickValue = me._getFirstTickValue(me.minNotZero);
var offset = 0;
var innerDimension, pixel, start, end, sign;
value = +me.getRightValue(value);
if (reverse) {
start = me.end;
end = me.start;
sign = -1;
} else {
start = me.start;
end = me.end;
sign = 1;
}
if (me.isHorizontal()) {
innerDimension = me.width;
pixel = reverse ? me.right : me.left;
} else {
innerDimension = me.height;
sign *= -1; // invert, since the upper-left corner of the canvas is at pixel (0, 0)
pixel = reverse ? me.top : me.bottom;
}
if (value !== start) {
if (start === 0) { // include zero tick
offset = helpers.getValueOrDefault(
me.options.ticks.fontSize,
Chart.defaults.global.defaultFontSize
);
innerDimension -= offset;
start = firstTickValue;
}
if (value !== 0) {
offset += innerDimension / (log10(end) - log10(start)) * (log10(value) - log10(start));
}
pixel += sign * offset;
}
return pixel;
},
getValueForPixel: function(pixel) {
var me = this;
var reverse = me.options.ticks.reverse;
var log10 = helpers.log10;
var firstTickValue = me._getFirstTickValue(me.minNotZero);
var innerDimension, start, end, value;
if (reverse) {
start = me.end;
end = me.start;
} else {
start = me.start;
end = me.end;
}
if (me.isHorizontal()) {
innerDimension = me.width;
value = reverse ? me.right - pixel : pixel - me.left;
} else {
innerDimension = me.height;
value = reverse ? pixel - me.top : me.bottom - pixel;
}
if (value !== start) {
if (start === 0) { // include zero tick
var offset = helpers.getValueOrDefault(
me.options.ticks.fontSize,
Chart.defaults.global.defaultFontSize
);
value -= offset;
innerDimension -= offset;
start = firstTickValue;
}
value *= log10(end) - log10(start);
value /= innerDimension;
value = Math.pow(10, log10(start) + value);
}
return value;
}
});
Chart.scaleService.registerScaleType('logarithmic', LogarithmicScale, defaultConfig);
};
},{"34":34,"45":45}],57:[function(require,module,exports){
'use strict';
var defaults = require(25);
var helpers = require(45);
var Ticks = require(34);
module.exports = function(Chart) {
var globalDefaults = defaults.global;
var defaultConfig = {
display: true,
// Boolean - Whether to animate scaling the chart from the centre
animate: true,
position: 'chartArea',
angleLines: {
display: true,
color: 'rgba(0, 0, 0, 0.1)',
lineWidth: 1
},
gridLines: {
circular: false
},
// label settings
ticks: {
// Boolean - Show a backdrop to the scale label
showLabelBackdrop: true,
// String - The colour of the label backdrop
backdropColor: 'rgba(255,255,255,0.75)',
// Number - The backdrop padding above & below the label in pixels
backdropPaddingY: 2,
// Number - The backdrop padding to the side of the label in pixels
backdropPaddingX: 2,
callback: Ticks.formatters.linear
},
pointLabels: {
// Boolean - if true, show point labels
display: true,
// Number - Point label font size in pixels
fontSize: 10,
// Function - Used to convert point labels
callback: function(label) {
return label;
}
}
};
function getValueCount(scale) {
var opts = scale.options;
return opts.angleLines.display || opts.pointLabels.display ? scale.chart.data.labels.length : 0;
}
function getPointLabelFontOptions(scale) {
var pointLabelOptions = scale.options.pointLabels;
var fontSize = helpers.valueOrDefault(pointLabelOptions.fontSize, globalDefaults.defaultFontSize);
var fontStyle = helpers.valueOrDefault(pointLabelOptions.fontStyle, globalDefaults.defaultFontStyle);
var fontFamily = helpers.valueOrDefault(pointLabelOptions.fontFamily, globalDefaults.defaultFontFamily);
var font = helpers.fontString(fontSize, fontStyle, fontFamily);
return {
size: fontSize,
style: fontStyle,
family: fontFamily,
font: font
};
}
function measureLabelSize(ctx, fontSize, label) {
if (helpers.isArray(label)) {
return {
w: helpers.longestText(ctx, ctx.font, label),
h: (label.length * fontSize) + ((label.length - 1) * 1.5 * fontSize)
};
}
return {
w: ctx.measureText(label).width,
h: fontSize
};
}
function determineLimits(angle, pos, size, min, max) {
if (angle === min || angle === max) {
return {
start: pos - (size / 2),
end: pos + (size / 2)
};
} else if (angle < min || angle > max) {
return {
start: pos - size - 5,
end: pos
};
}
return {
start: pos,
end: pos + size + 5
};
}
/**
* Helper function to fit a radial linear scale with point labels
*/
function fitWithPointLabels(scale) {
/*
* Right, this is really confusing and there is a lot of maths going on here
* The gist of the problem is here: https://gist.github.com/nnnick/696cc9c55f4b0beb8fe9
*
* Reaction: https://dl.dropboxusercontent.com/u/34601363/toomuchscience.gif
*
* Solution:
*
* We assume the radius of the polygon is half the size of the canvas at first
* at each index we check if the text overlaps.
*
* Where it does, we store that angle and that index.
*
* After finding the largest index and angle we calculate how much we need to remove
* from the shape radius to move the point inwards by that x.
*
* We average the left and right distances to get the maximum shape radius that can fit in the box
* along with labels.
*
* Once we have that, we can find the centre point for the chart, by taking the x text protrusion
* on each side, removing that from the size, halving it and adding the left x protrusion width.
*
* This will mean we have a shape fitted to the canvas, as large as it can be with the labels
* and position it in the most space efficient manner
*
* https://dl.dropboxusercontent.com/u/34601363/yeahscience.gif
*/
var plFont = getPointLabelFontOptions(scale);
// Get maximum radius of the polygon. Either half the height (minus the text width) or half the width.
// Use this to calculate the offset + change. - Make sure L/R protrusion is at least 0 to stop issues with centre points
var largestPossibleRadius = Math.min(scale.height / 2, scale.width / 2);
var furthestLimits = {
r: scale.width,
l: 0,
t: scale.height,
b: 0
};
var furthestAngles = {};
var i, textSize, pointPosition;
scale.ctx.font = plFont.font;
scale._pointLabelSizes = [];
var valueCount = getValueCount(scale);
for (i = 0; i < valueCount; i++) {
pointPosition = scale.getPointPosition(i, largestPossibleRadius);
textSize = measureLabelSize(scale.ctx, plFont.size, scale.pointLabels[i] || '');
scale._pointLabelSizes[i] = textSize;
// Add quarter circle to make degree 0 mean top of circle
var angleRadians = scale.getIndexAngle(i);
var angle = helpers.toDegrees(angleRadians) % 360;
var hLimits = determineLimits(angle, pointPosition.x, textSize.w, 0, 180);
var vLimits = determineLimits(angle, pointPosition.y, textSize.h, 90, 270);
if (hLimits.start < furthestLimits.l) {
furthestLimits.l = hLimits.start;
furthestAngles.l = angleRadians;
}
if (hLimits.end > furthestLimits.r) {
furthestLimits.r = hLimits.end;
furthestAngles.r = angleRadians;
}
if (vLimits.start < furthestLimits.t) {
furthestLimits.t = vLimits.start;
furthestAngles.t = angleRadians;
}
if (vLimits.end > furthestLimits.b) {
furthestLimits.b = vLimits.end;
furthestAngles.b = angleRadians;
}
}
scale.setReductions(largestPossibleRadius, furthestLimits, furthestAngles);
}
/**
* Helper function to fit a radial linear scale with no point labels
*/
function fit(scale) {
var largestPossibleRadius = Math.min(scale.height / 2, scale.width / 2);
scale.drawingArea = Math.round(largestPossibleRadius);
scale.setCenterPoint(0, 0, 0, 0);
}
function getTextAlignForAngle(angle) {
if (angle === 0 || angle === 180) {
return 'center';
} else if (angle < 180) {
return 'left';
}
return 'right';
}
function fillText(ctx, text, position, fontSize) {
if (helpers.isArray(text)) {
var y = position.y;
var spacing = 1.5 * fontSize;
for (var i = 0; i < text.length; ++i) {
ctx.fillText(text[i], position.x, y);
y += spacing;
}
} else {
ctx.fillText(text, position.x, position.y);
}
}
function adjustPointPositionForLabelHeight(angle, textSize, position) {
if (angle === 90 || angle === 270) {
position.y -= (textSize.h / 2);
} else if (angle > 270 || angle < 90) {
position.y -= textSize.h;
}
}
function drawPointLabels(scale) {
var ctx = scale.ctx;
var opts = scale.options;
var angleLineOpts = opts.angleLines;
var pointLabelOpts = opts.pointLabels;
ctx.lineWidth = angleLineOpts.lineWidth;
ctx.strokeStyle = angleLineOpts.color;
var outerDistance = scale.getDistanceFromCenterForValue(opts.ticks.reverse ? scale.min : scale.max);
// Point Label Font
var plFont = getPointLabelFontOptions(scale);
ctx.textBaseline = 'top';
for (var i = getValueCount(scale) - 1; i >= 0; i--) {
if (angleLineOpts.display) {
var outerPosition = scale.getPointPosition(i, outerDistance);
ctx.beginPath();
ctx.moveTo(scale.xCenter, scale.yCenter);
ctx.lineTo(outerPosition.x, outerPosition.y);
ctx.stroke();
ctx.closePath();
}
if (pointLabelOpts.display) {
// Extra 3px out for some label spacing
var pointLabelPosition = scale.getPointPosition(i, outerDistance + 5);
// Keep this in loop since we may support array properties here
var pointLabelFontColor = helpers.valueAtIndexOrDefault(pointLabelOpts.fontColor, i, globalDefaults.defaultFontColor);
ctx.font = plFont.font;
ctx.fillStyle = pointLabelFontColor;
var angleRadians = scale.getIndexAngle(i);
var angle = helpers.toDegrees(angleRadians);
ctx.textAlign = getTextAlignForAngle(angle);
adjustPointPositionForLabelHeight(angle, scale._pointLabelSizes[i], pointLabelPosition);
fillText(ctx, scale.pointLabels[i] || '', pointLabelPosition, plFont.size);
}
}
}
function drawRadiusLine(scale, gridLineOpts, radius, index) {
var ctx = scale.ctx;
ctx.strokeStyle = helpers.valueAtIndexOrDefault(gridLineOpts.color, index - 1);
ctx.lineWidth = helpers.valueAtIndexOrDefault(gridLineOpts.lineWidth, index - 1);
if (scale.options.gridLines.circular) {
// Draw circular arcs between the points
ctx.beginPath();
ctx.arc(scale.xCenter, scale.yCenter, radius, 0, Math.PI * 2);
ctx.closePath();
ctx.stroke();
} else {
// Draw straight lines connecting each index
var valueCount = getValueCount(scale);
if (valueCount === 0) {
return;
}
ctx.beginPath();
var pointPosition = scale.getPointPosition(0, radius);
ctx.moveTo(pointPosition.x, pointPosition.y);
for (var i = 1; i < valueCount; i++) {
pointPosition = scale.getPointPosition(i, radius);
ctx.lineTo(pointPosition.x, pointPosition.y);
}
ctx.closePath();
ctx.stroke();
}
}
function numberOrZero(param) {
return helpers.isNumber(param) ? param : 0;
}
var LinearRadialScale = Chart.LinearScaleBase.extend({
setDimensions: function() {
var me = this;
var opts = me.options;
var tickOpts = opts.ticks;
// Set the unconstrained dimension before label rotation
me.width = me.maxWidth;
me.height = me.maxHeight;
me.xCenter = Math.round(me.width / 2);
me.yCenter = Math.round(me.height / 2);
var minSize = helpers.min([me.height, me.width]);
var tickFontSize = helpers.valueOrDefault(tickOpts.fontSize, globalDefaults.defaultFontSize);
me.drawingArea = opts.display ? (minSize / 2) - (tickFontSize / 2 + tickOpts.backdropPaddingY) : (minSize / 2);
},
determineDataLimits: function() {
var me = this;
var chart = me.chart;
var min = Number.POSITIVE_INFINITY;
var max = Number.NEGATIVE_INFINITY;
helpers.each(chart.data.datasets, function(dataset, datasetIndex) {
if (chart.isDatasetVisible(datasetIndex)) {
var meta = chart.getDatasetMeta(datasetIndex);
helpers.each(dataset.data, function(rawValue, index) {
var value = +me.getRightValue(rawValue);
if (isNaN(value) || meta.data[index].hidden) {
return;
}
min = Math.min(value, min);
max = Math.max(value, max);
});
}
});
me.min = (min === Number.POSITIVE_INFINITY ? 0 : min);
me.max = (max === Number.NEGATIVE_INFINITY ? 0 : max);
// Common base implementation to handle ticks.min, ticks.max, ticks.beginAtZero
me.handleTickRangeOptions();
},
getTickLimit: function() {
var tickOpts = this.options.ticks;
var tickFontSize = helpers.valueOrDefault(tickOpts.fontSize, globalDefaults.defaultFontSize);
return Math.min(tickOpts.maxTicksLimit ? tickOpts.maxTicksLimit : 11, Math.ceil(this.drawingArea / (1.5 * tickFontSize)));
},
convertTicksToLabels: function() {
var me = this;
Chart.LinearScaleBase.prototype.convertTicksToLabels.call(me);
// Point labels
me.pointLabels = me.chart.data.labels.map(me.options.pointLabels.callback, me);
},
getLabelForIndex: function(index, datasetIndex) {
return +this.getRightValue(this.chart.data.datasets[datasetIndex].data[index]);
},
fit: function() {
if (this.options.pointLabels.display) {
fitWithPointLabels(this);
} else {
fit(this);
}
},
/**
* Set radius reductions and determine new radius and center point
* @private
*/
setReductions: function(largestPossibleRadius, furthestLimits, furthestAngles) {
var me = this;
var radiusReductionLeft = furthestLimits.l / Math.sin(furthestAngles.l);
var radiusReductionRight = Math.max(furthestLimits.r - me.width, 0) / Math.sin(furthestAngles.r);
var radiusReductionTop = -furthestLimits.t / Math.cos(furthestAngles.t);
var radiusReductionBottom = -Math.max(furthestLimits.b - me.height, 0) / Math.cos(furthestAngles.b);
radiusReductionLeft = numberOrZero(radiusReductionLeft);
radiusReductionRight = numberOrZero(radiusReductionRight);
radiusReductionTop = numberOrZero(radiusReductionTop);
radiusReductionBottom = numberOrZero(radiusReductionBottom);
me.drawingArea = Math.min(
Math.round(largestPossibleRadius - (radiusReductionLeft + radiusReductionRight) / 2),
Math.round(largestPossibleRadius - (radiusReductionTop + radiusReductionBottom) / 2));
me.setCenterPoint(radiusReductionLeft, radiusReductionRight, radiusReductionTop, radiusReductionBottom);
},
setCenterPoint: function(leftMovement, rightMovement, topMovement, bottomMovement) {
var me = this;
var maxRight = me.width - rightMovement - me.drawingArea;
var maxLeft = leftMovement + me.drawingArea;
var maxTop = topMovement + me.drawingArea;
var maxBottom = me.height - bottomMovement - me.drawingArea;
me.xCenter = Math.round(((maxLeft + maxRight) / 2) + me.left);
me.yCenter = Math.round(((maxTop + maxBottom) / 2) + me.top);
},
getIndexAngle: function(index) {
var angleMultiplier = (Math.PI * 2) / getValueCount(this);
var startAngle = this.chart.options && this.chart.options.startAngle ?
this.chart.options.startAngle :
0;
var startAngleRadians = startAngle * Math.PI * 2 / 360;
// Start from the top instead of right, so remove a quarter of the circle
return index * angleMultiplier + startAngleRadians;
},
getDistanceFromCenterForValue: function(value) {
var me = this;
if (value === null) {
return 0; // null always in center
}
// Take into account half font size + the yPadding of the top value
var scalingFactor = me.drawingArea / (me.max - me.min);
if (me.options.ticks.reverse) {
return (me.max - value) * scalingFactor;
}
return (value - me.min) * scalingFactor;
},
getPointPosition: function(index, distanceFromCenter) {
var me = this;
var thisAngle = me.getIndexAngle(index) - (Math.PI / 2);
return {
x: Math.round(Math.cos(thisAngle) * distanceFromCenter) + me.xCenter,
y: Math.round(Math.sin(thisAngle) * distanceFromCenter) + me.yCenter
};
},
getPointPositionForValue: function(index, value) {
return this.getPointPosition(index, this.getDistanceFromCenterForValue(value));
},
getBasePosition: function() {
var me = this;
var min = me.min;
var max = me.max;
return me.getPointPositionForValue(0,
me.beginAtZero ? 0 :
min < 0 && max < 0 ? max :
min > 0 && max > 0 ? min :
0);
},
draw: function() {
var me = this;
var opts = me.options;
var gridLineOpts = opts.gridLines;
var tickOpts = opts.ticks;
var valueOrDefault = helpers.valueOrDefault;
if (opts.display) {
var ctx = me.ctx;
var startAngle = this.getIndexAngle(0);
// Tick Font
var tickFontSize = valueOrDefault(tickOpts.fontSize, globalDefaults.defaultFontSize);
var tickFontStyle = valueOrDefault(tickOpts.fontStyle, globalDefaults.defaultFontStyle);
var tickFontFamily = valueOrDefault(tickOpts.fontFamily, globalDefaults.defaultFontFamily);
var tickLabelFont = helpers.fontString(tickFontSize, tickFontStyle, tickFontFamily);
helpers.each(me.ticks, function(label, index) {
// Don't draw a centre value (if it is minimum)
if (index > 0 || tickOpts.reverse) {
var yCenterOffset = me.getDistanceFromCenterForValue(me.ticksAsNumbers[index]);
// Draw circular lines around the scale
if (gridLineOpts.display && index !== 0) {
drawRadiusLine(me, gridLineOpts, yCenterOffset, index);
}
if (tickOpts.display) {
var tickFontColor = valueOrDefault(tickOpts.fontColor, globalDefaults.defaultFontColor);
ctx.font = tickLabelFont;
ctx.save();
ctx.translate(me.xCenter, me.yCenter);
ctx.rotate(startAngle);
if (tickOpts.showLabelBackdrop) {
var labelWidth = ctx.measureText(label).width;
ctx.fillStyle = tickOpts.backdropColor;
ctx.fillRect(
-labelWidth / 2 - tickOpts.backdropPaddingX,
-yCenterOffset - tickFontSize / 2 - tickOpts.backdropPaddingY,
labelWidth + tickOpts.backdropPaddingX * 2,
tickFontSize + tickOpts.backdropPaddingY * 2
);
}
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = tickFontColor;
ctx.fillText(label, 0, -yCenterOffset);
ctx.restore();
}
}
});
if (opts.angleLines.display || opts.pointLabels.display) {
drawPointLabels(me);
}
}
}
});
Chart.scaleService.registerScaleType('radialLinear', LinearRadialScale, defaultConfig);
};
},{"25":25,"34":34,"45":45}],58:[function(require,module,exports){
/* global window: false */
'use strict';
var moment = require(1);
moment = typeof moment === 'function' ? moment : window.moment;
var defaults = require(25);
var helpers = require(45);
// Integer constants are from the ES6 spec.
var MIN_INTEGER = Number.MIN_SAFE_INTEGER || -9007199254740991;
var MAX_INTEGER = Number.MAX_SAFE_INTEGER || 9007199254740991;
var INTERVALS = {
millisecond: {
common: true,
size: 1,
steps: [1, 2, 5, 10, 20, 50, 100, 250, 500]
},
second: {
common: true,
size: 1000,
steps: [1, 2, 5, 10, 30]
},
minute: {
common: true,
size: 60000,
steps: [1, 2, 5, 10, 30]
},
hour: {
common: true,
size: 3600000,
steps: [1, 2, 3, 6, 12]
},
day: {
common: true,
size: 86400000,
steps: [1, 2, 5]
},
week: {
common: false,
size: 604800000,
steps: [1, 2, 3, 4]
},
month: {
common: true,
size: 2.628e9,
steps: [1, 2, 3]
},
quarter: {
common: false,
size: 7.884e9,
steps: [1, 2, 3, 4]
},
year: {
common: true,
size: 3.154e10
}
};
var UNITS = Object.keys(INTERVALS);
function sorter(a, b) {
return a - b;
}
function arrayUnique(items) {
var hash = {};
var out = [];
var i, ilen, item;
for (i = 0, ilen = items.length; i < ilen; ++i) {
item = items[i];
if (!hash[item]) {
hash[item] = true;
out.push(item);
}
}
return out;
}
/**
* Returns an array of {time, pos} objects used to interpolate a specific `time` or position
* (`pos`) on the scale, by searching entries before and after the requested value. `pos` is
* a decimal between 0 and 1: 0 being the start of the scale (left or top) and 1 the other
* extremity (left + width or top + height). Note that it would be more optimized to directly
* store pre-computed pixels, but the scale dimensions are not guaranteed at the time we need
* to create the lookup table. The table ALWAYS contains at least two items: min and max.
*
* @param {Number[]} timestamps - timestamps sorted from lowest to highest.
* @param {String} distribution - If 'linear', timestamps will be spread linearly along the min
* and max range, so basically, the table will contains only two items: {min, 0} and {max, 1}.
* If 'series', timestamps will be positioned at the same distance from each other. In this
* case, only timestamps that break the time linearity are registered, meaning that in the
* best case, all timestamps are linear, the table contains only min and max.
*/
function buildLookupTable(timestamps, min, max, distribution) {
if (distribution === 'linear' || !timestamps.length) {
return [
{time: min, pos: 0},
{time: max, pos: 1}
];
}
var table = [];
var items = [min];
var i, ilen, prev, curr, next;
for (i = 0, ilen = timestamps.length; i < ilen; ++i) {
curr = timestamps[i];
if (curr > min && curr < max) {
items.push(curr);
}
}
items.push(max);
for (i = 0, ilen = items.length; i < ilen; ++i) {
next = items[i + 1];
prev = items[i - 1];
curr = items[i];
// only add points that breaks the scale linearity
if (prev === undefined || next === undefined || Math.round((next + prev) / 2) !== curr) {
table.push({time: curr, pos: i / (ilen - 1)});
}
}
return table;
}
// @see adapted from http://www.anujgakhar.com/2014/03/01/binary-search-in-javascript/
function lookup(table, key, value) {
var lo = 0;
var hi = table.length - 1;
var mid, i0, i1;
while (lo >= 0 && lo <= hi) {
mid = (lo + hi) >> 1;
i0 = table[mid - 1] || null;
i1 = table[mid];
if (!i0) {
// given value is outside table (before first item)
return {lo: null, hi: i1};
} else if (i1[key] < value) {
lo = mid + 1;
} else if (i0[key] > value) {
hi = mid - 1;
} else {
return {lo: i0, hi: i1};
}
}
// given value is outside table (after last item)
return {lo: i1, hi: null};
}
/**
* Linearly interpolates the given source `value` using the table items `skey` values and
* returns the associated `tkey` value. For example, interpolate(table, 'time', 42, 'pos')
* returns the position for a timestamp equal to 42. If value is out of bounds, values at
* index [0, 1] or [n - 1, n] are used for the interpolation.
*/
function interpolate(table, skey, sval, tkey) {
var range = lookup(table, skey, sval);
// Note: the lookup table ALWAYS contains at least 2 items (min and max)
var prev = !range.lo ? table[0] : !range.hi ? table[table.length - 2] : range.lo;
var next = !range.lo ? table[1] : !range.hi ? table[table.length - 1] : range.hi;
var span = next[skey] - prev[skey];
var ratio = span ? (sval - prev[skey]) / span : 0;
var offset = (next[tkey] - prev[tkey]) * ratio;
return prev[tkey] + offset;
}
/**
* Convert the given value to a moment object using the given time options.
* @see http://momentjs.com/docs/#/parsing/
*/
function momentify(value, options) {
var parser = options.parser;
var format = options.parser || options.format;
if (typeof parser === 'function') {
return parser(value);
}
if (typeof value === 'string' && typeof format === 'string') {
return moment(value, format);
}
if (!(value instanceof moment)) {
value = moment(value);
}
if (value.isValid()) {
return value;
}
// Labels are in an incompatible moment format and no `parser` has been provided.
// The user might still use the deprecated `format` option to convert his inputs.
if (typeof format === 'function') {
return format(value);
}
return value;
}
function parse(input, scale) {
if (helpers.isNullOrUndef(input)) {
return null;
}
var options = scale.options.time;
var value = momentify(scale.getRightValue(input), options);
if (!value.isValid()) {
return null;
}
if (options.round) {
value.startOf(options.round);
}
return value.valueOf();
}
/**
* Returns the number of unit to skip to be able to display up to `capacity` number of ticks
* in `unit` for the given `min` / `max` range and respecting the interval steps constraints.
*/
function determineStepSize(min, max, unit, capacity) {
var range = max - min;
var interval = INTERVALS[unit];
var milliseconds = interval.size;
var steps = interval.steps;
var i, ilen, factor;
if (!steps) {
return Math.ceil(range / (capacity * milliseconds));
}
for (i = 0, ilen = steps.length; i < ilen; ++i) {
factor = steps[i];
if (Math.ceil(range / (milliseconds * factor)) <= capacity) {
break;
}
}
return factor;
}
/**
* Figures out what unit results in an appropriate number of auto-generated ticks
*/
function determineUnitForAutoTicks(minUnit, min, max, capacity) {
var ilen = UNITS.length;
var i, interval, factor;
for (i = UNITS.indexOf(minUnit); i < ilen - 1; ++i) {
interval = INTERVALS[UNITS[i]];
factor = interval.steps ? interval.steps[interval.steps.length - 1] : MAX_INTEGER;
if (interval.common && Math.ceil((max - min) / (factor * interval.size)) <= capacity) {
return UNITS[i];
}
}
return UNITS[ilen - 1];
}
/**
* Figures out what unit to format a set of ticks with
*/
function determineUnitForFormatting(ticks, minUnit, min, max) {
var duration = moment.duration(moment(max).diff(moment(min)));
var ilen = UNITS.length;
var i, unit;
for (i = ilen - 1; i >= UNITS.indexOf(minUnit); i--) {
unit = UNITS[i];
if (INTERVALS[unit].common && duration.as(unit) >= ticks.length) {
return unit;
}
}
return UNITS[minUnit ? UNITS.indexOf(minUnit) : 0];
}
function determineMajorUnit(unit) {
for (var i = UNITS.indexOf(unit) + 1, ilen = UNITS.length; i < ilen; ++i) {
if (INTERVALS[UNITS[i]].common) {
return UNITS[i];
}
}
}
/**
* Generates a maximum of `capacity` timestamps between min and max, rounded to the
* `minor` unit, aligned on the `major` unit and using the given scale time `options`.
* Important: this method can return ticks outside the min and max range, it's the
* responsibility of the calling code to clamp values if needed.
*/
function generate(min, max, capacity, options) {
var timeOpts = options.time;
var minor = timeOpts.unit || determineUnitForAutoTicks(timeOpts.minUnit, min, max, capacity);
var major = determineMajorUnit(minor);
var stepSize = helpers.valueOrDefault(timeOpts.stepSize, timeOpts.unitStepSize);
var weekday = minor === 'week' ? timeOpts.isoWeekday : false;
var majorTicksEnabled = options.ticks.major.enabled;
var interval = INTERVALS[minor];
var first = moment(min);
var last = moment(max);
var ticks = [];
var time;
if (!stepSize) {
stepSize = determineStepSize(min, max, minor, capacity);
}
// For 'week' unit, handle the first day of week option
if (weekday) {
first = first.isoWeekday(weekday);
last = last.isoWeekday(weekday);
}
// Align first/last ticks on unit
first = first.startOf(weekday ? 'day' : minor);
last = last.startOf(weekday ? 'day' : minor);
// Make sure that the last tick include max
if (last < max) {
last.add(1, minor);
}
time = moment(first);
if (majorTicksEnabled && major && !weekday && !timeOpts.round) {
// Align the first tick on the previous `minor` unit aligned on the `major` unit:
// we first aligned time on the previous `major` unit then add the number of full
// stepSize there is between first and the previous major time.
time.startOf(major);
time.add(~~((first - time) / (interval.size * stepSize)) * stepSize, minor);
}
for (; time < last; time.add(stepSize, minor)) {
ticks.push(+time);
}
ticks.push(+time);
return ticks;
}
/**
* Returns the right and left offsets from edges in the form of {left, right}.
* Offsets are added when the `offset` option is true.
*/
function computeOffsets(table, ticks, min, max, options) {
var left = 0;
var right = 0;
var upper, lower;
if (options.offset && ticks.length) {
if (!options.time.min) {
upper = ticks.length > 1 ? ticks[1] : max;
lower = ticks[0];
left = (
interpolate(table, 'time', upper, 'pos') -
interpolate(table, 'time', lower, 'pos')
) / 2;
}
if (!options.time.max) {
upper = ticks[ticks.length - 1];
lower = ticks.length > 1 ? ticks[ticks.length - 2] : min;
right = (
interpolate(table, 'time', upper, 'pos') -
interpolate(table, 'time', lower, 'pos')
) / 2;
}
}
return {left: left, right: right};
}
function ticksFromTimestamps(values, majorUnit) {
var ticks = [];
var i, ilen, value, major;
for (i = 0, ilen = values.length; i < ilen; ++i) {
value = values[i];
major = majorUnit ? value === +moment(value).startOf(majorUnit) : false;
ticks.push({
value: value,
major: major
});
}
return ticks;
}
function determineLabelFormat(data, timeOpts) {
var i, momentDate, hasTime;
var ilen = data.length;
// find the label with the most parts (milliseconds, minutes, etc.)
// format all labels with the same level of detail as the most specific label
for (i = 0; i < ilen; i++) {
momentDate = momentify(data[i], timeOpts);
if (momentDate.millisecond() !== 0) {
return 'MMM D, YYYY h:mm:ss.SSS a';
}
if (momentDate.second() !== 0 || momentDate.minute() !== 0 || momentDate.hour() !== 0) {
hasTime = true;
}
}
if (hasTime) {
return 'MMM D, YYYY h:mm:ss a';
}
return 'MMM D, YYYY';
}
module.exports = function(Chart) {
var defaultConfig = {
position: 'bottom',
/**
* Data distribution along the scale:
* - 'linear': data are spread according to their time (distances can vary),
* - 'series': data are spread at the same distance from each other.
* @see https://github.com/chartjs/Chart.js/pull/4507
* @since 2.7.0
*/
distribution: 'linear',
/**
* Scale boundary strategy (bypassed by min/max time options)
* - `data`: make sure data are fully visible, ticks outside are removed
* - `ticks`: make sure ticks are fully visible, data outside are truncated
* @see https://github.com/chartjs/Chart.js/pull/4556
* @since 2.7.0
*/
bounds: 'data',
time: {
parser: false, // false == a pattern string from http://momentjs.com/docs/#/parsing/string-format/ or a custom callback that converts its argument to a moment
format: false, // DEPRECATED false == date objects, moment object, callback or a pattern string from http://momentjs.com/docs/#/parsing/string-format/
unit: false, // false == automatic or override with week, month, year, etc.
round: false, // none, or override with week, month, year, etc.
displayFormat: false, // DEPRECATED
isoWeekday: false, // override week start day - see http://momentjs.com/docs/#/get-set/iso-weekday/
minUnit: 'millisecond',
// defaults to unit's corresponding unitFormat below or override using pattern string from http://momentjs.com/docs/#/displaying/format/
displayFormats: {
millisecond: 'h:mm:ss.SSS a', // 11:20:01.123 AM,
second: 'h:mm:ss a', // 11:20:01 AM
minute: 'h:mm a', // 11:20 AM
hour: 'hA', // 5PM
day: 'MMM D', // Sep 4
week: 'll', // Week 46, or maybe "[W]WW - YYYY" ?
month: 'MMM YYYY', // Sept 2015
quarter: '[Q]Q - YYYY', // Q3
year: 'YYYY' // 2015
},
},
ticks: {
autoSkip: false,
/**
* Ticks generation input values:
* - 'auto': generates "optimal" ticks based on scale size and time options.
* - 'data': generates ticks from data (including labels from data {t|x|y} objects).
* - 'labels': generates ticks from user given `data.labels` values ONLY.
* @see https://github.com/chartjs/Chart.js/pull/4507
* @since 2.7.0
*/
source: 'auto',
major: {
enabled: false
}
}
};
var TimeScale = Chart.Scale.extend({
initialize: function() {
if (!moment) {
throw new Error('Chart.js - Moment.js could not be found! You must include it before Chart.js to use the time scale. Download at https://momentjs.com');
}
this.mergeTicksOptions();
Chart.Scale.prototype.initialize.call(this);
},
update: function() {
var me = this;
var options = me.options;
// DEPRECATIONS: output a message only one time per update
if (options.time && options.time.format) {
console.warn('options.time.format is deprecated and replaced by options.time.parser.');
}
return Chart.Scale.prototype.update.apply(me, arguments);
},
/**
* Allows data to be referenced via 't' attribute
*/
getRightValue: function(rawValue) {
if (rawValue && rawValue.t !== undefined) {
rawValue = rawValue.t;
}
return Chart.Scale.prototype.getRightValue.call(this, rawValue);
},
determineDataLimits: function() {
var me = this;
var chart = me.chart;
var timeOpts = me.options.time;
var unit = timeOpts.unit || 'day';
var min = MAX_INTEGER;
var max = MIN_INTEGER;
var timestamps = [];
var datasets = [];
var labels = [];
var i, j, ilen, jlen, data, timestamp;
// Convert labels to timestamps
for (i = 0, ilen = chart.data.labels.length; i < ilen; ++i) {
labels.push(parse(chart.data.labels[i], me));
}
// Convert data to timestamps
for (i = 0, ilen = (chart.data.datasets || []).length; i < ilen; ++i) {
if (chart.isDatasetVisible(i)) {
data = chart.data.datasets[i].data;
// Let's consider that all data have the same format.
if (helpers.isObject(data[0])) {
datasets[i] = [];
for (j = 0, jlen = data.length; j < jlen; ++j) {
timestamp = parse(data[j], me);
timestamps.push(timestamp);
datasets[i][j] = timestamp;
}
} else {
timestamps.push.apply(timestamps, labels);
datasets[i] = labels.slice(0);
}
} else {
datasets[i] = [];
}
}
if (labels.length) {
// Sort labels **after** data have been converted
labels = arrayUnique(labels).sort(sorter);
min = Math.min(min, labels[0]);
max = Math.max(max, labels[labels.length - 1]);
}
if (timestamps.length) {
timestamps = arrayUnique(timestamps).sort(sorter);
min = Math.min(min, timestamps[0]);
max = Math.max(max, timestamps[timestamps.length - 1]);
}
min = parse(timeOpts.min, me) || min;
max = parse(timeOpts.max, me) || max;
// In case there is no valid min/max, set limits based on unit time option
min = min === MAX_INTEGER ? +moment().startOf(unit) : min;
max = max === MIN_INTEGER ? +moment().endOf(unit) + 1 : max;
// Make sure that max is strictly higher than min (required by the lookup table)
me.min = Math.min(min, max);
me.max = Math.max(min + 1, max);
// PRIVATE
me._horizontal = me.isHorizontal();
me._table = [];
me._timestamps = {
data: timestamps,
datasets: datasets,
labels: labels
};
},
buildTicks: function() {
var me = this;
var min = me.min;
var max = me.max;
var options = me.options;
var timeOpts = options.time;
var timestamps = [];
var ticks = [];
var i, ilen, timestamp;
switch (options.ticks.source) {
case 'data':
timestamps = me._timestamps.data;
break;
case 'labels':
timestamps = me._timestamps.labels;
break;
case 'auto':
default:
timestamps = generate(min, max, me.getLabelCapacity(min), options);
}
if (options.bounds === 'ticks' && timestamps.length) {
min = timestamps[0];
max = timestamps[timestamps.length - 1];
}
// Enforce limits with user min/max options
min = parse(timeOpts.min, me) || min;
max = parse(timeOpts.max, me) || max;
// Remove ticks outside the min/max range
for (i = 0, ilen = timestamps.length; i < ilen; ++i) {
timestamp = timestamps[i];
if (timestamp >= min && timestamp <= max) {
ticks.push(timestamp);
}
}
me.min = min;
me.max = max;
// PRIVATE
me._unit = timeOpts.unit || determineUnitForFormatting(ticks, timeOpts.minUnit, me.min, me.max);
me._majorUnit = determineMajorUnit(me._unit);
me._table = buildLookupTable(me._timestamps.data, min, max, options.distribution);
me._offsets = computeOffsets(me._table, ticks, min, max, options);
me._labelFormat = determineLabelFormat(me._timestamps.data, timeOpts);
return ticksFromTimestamps(ticks, me._majorUnit);
},
getLabelForIndex: function(index, datasetIndex) {
var me = this;
var data = me.chart.data;
var timeOpts = me.options.time;
var label = data.labels && index < data.labels.length ? data.labels[index] : '';
var value = data.datasets[datasetIndex].data[index];
if (helpers.isObject(value)) {
label = me.getRightValue(value);
}
if (timeOpts.tooltipFormat) {
return momentify(label, timeOpts).format(timeOpts.tooltipFormat);
}
if (typeof label === 'string') {
return label;
}
return momentify(label, timeOpts).format(me._labelFormat);
},
/**
* Function to format an individual tick mark
* @private
*/
tickFormatFunction: function(tick, index, ticks, formatOverride) {
var me = this;
var options = me.options;
var time = tick.valueOf();
var formats = options.time.displayFormats;
var minorFormat = formats[me._unit];
var majorUnit = me._majorUnit;
var majorFormat = formats[majorUnit];
var majorTime = tick.clone().startOf(majorUnit).valueOf();
var majorTickOpts = options.ticks.major;
var major = majorTickOpts.enabled && majorUnit && majorFormat && time === majorTime;
var label = tick.format(formatOverride ? formatOverride : major ? majorFormat : minorFormat);
var tickOpts = major ? majorTickOpts : options.ticks.minor;
var formatter = helpers.valueOrDefault(tickOpts.callback, tickOpts.userCallback);
return formatter ? formatter(label, index, ticks) : label;
},
convertTicksToLabels: function(ticks) {
var labels = [];
var i, ilen;
for (i = 0, ilen = ticks.length; i < ilen; ++i) {
labels.push(this.tickFormatFunction(moment(ticks[i].value), i, ticks));
}
return labels;
},
/**
* @private
*/
getPixelForOffset: function(time) {
var me = this;
var size = me._horizontal ? me.width : me.height;
var start = me._horizontal ? me.left : me.top;
var pos = interpolate(me._table, 'time', time, 'pos');
return start + size * (me._offsets.left + pos) / (me._offsets.left + 1 + me._offsets.right);
},
getPixelForValue: function(value, index, datasetIndex) {
var me = this;
var time = null;
if (index !== undefined && datasetIndex !== undefined) {
time = me._timestamps.datasets[datasetIndex][index];
}
if (time === null) {
time = parse(value, me);
}
if (time !== null) {
return me.getPixelForOffset(time);
}
},
getPixelForTick: function(index) {
var ticks = this.getTicks();
return index >= 0 && index < ticks.length ?
this.getPixelForOffset(ticks[index].value) :
null;
},
getValueForPixel: function(pixel) {
var me = this;
var size = me._horizontal ? me.width : me.height;
var start = me._horizontal ? me.left : me.top;
var pos = (size ? (pixel - start) / size : 0) * (me._offsets.left + 1 + me._offsets.left) - me._offsets.right;
var time = interpolate(me._table, 'pos', pos, 'time');
return moment(time);
},
/**
* Crude approximation of what the label width might be
* @private
*/
getLabelWidth: function(label) {
var me = this;
var ticksOpts = me.options.ticks;
var tickLabelWidth = me.ctx.measureText(label).width;
var angle = helpers.toRadians(ticksOpts.maxRotation);
var cosRotation = Math.cos(angle);
var sinRotation = Math.sin(angle);
var tickFontSize = helpers.valueOrDefault(ticksOpts.fontSize, defaults.global.defaultFontSize);
return (tickLabelWidth * cosRotation) + (tickFontSize * sinRotation);
},
/**
* @private
*/
getLabelCapacity: function(exampleTime) {
var me = this;
var formatOverride = me.options.time.displayFormats.millisecond; // Pick the longest format for guestimation
var exampleLabel = me.tickFormatFunction(moment(exampleTime), 0, [], formatOverride);
var tickLabelWidth = me.getLabelWidth(exampleLabel);
var innerWidth = me.isHorizontal() ? me.width : me.height;
var capacity = Math.floor(innerWidth / tickLabelWidth);
return capacity > 0 ? capacity : 1;
}
});
Chart.scaleService.registerScaleType('time', TimeScale, defaultConfig);
};
},{"1":1,"25":25,"45":45}]},{},[7])(7)
});
================================================
FILE: bukuserver/static/bukuserver/js/bookmark.js
================================================
$(document).ready(function() {
window._tags = (Date.now() - (window._tagsQueried||0) < 1000 ? _tags :
new Promise(resolve => {
window._tagsQueried = Date.now();
$.getJSON('/api/tags', ({tags}) => resolve(tags));
}));
_tags.then(tags => $('input#tags').select2({tags, tokenSeparators: [',']}));
});
================================================
FILE: bukuserver/static/bukuserver/js/buku_filter.js
================================================
$(document).ready(function () { // synchronizing buku filters
let bukuFilters = () => $(`option[value^="buku_"]`).parent(`.filter-op`);
let filterInput = filter => $(`.filter-val`, $(filter).parents('tr').first());
let adder = $(`.field-filters .filter`).filter(function () {return this.innerText === 'buku'}).get(0);
let sync = (key, $filter=bukuFilters().last(), value=filterInput($filter).val()) => {
($filter.val() != key) && $filter.val(key).triggerHandler('change');
filterInput($filter).val(value).trigger('focus').on('change', evt => {value = evt.target.value});
$filter.on('change', (evt, param) => (param == '$norecur$') || bukuFilters().each(function () {
if (this == evt.target) {
filterInput(this).val(value); // retaining the last filter value
} else {
let _value = filterInput(this).val();
$(this).val(evt.val).triggerHandler('change', '$norecur$');
filterInput(this).val(_value); // retaining the last value for other filters
}
}));
};
bukuFilters().each(function () {sync(this.value, $(this))});
adder.onclick = () => {
try {
let key = bukuFilters().first().val() || 'buku_search_markers_match_all';
setTimeout(() => sync(key));
} catch (e) { // ensuring the handler always returns false
console.error(e);
}
return false;
};
});
================================================
FILE: bukuserver/static/bukuserver/js/filters_fix.js
================================================
$(document).ready(function () {
const IDX = [[36, 'a'], [10, 'A'], [0, '0']];
let idxChar = (i, [x, c]=IDX.find(([x]) => i >= x)) => String.fromCharCode(c.charCodeAt(0) + (i-x));
filter_form.onsubmit = function () {
$(`.filter-val[name]`, this).each((i, e) => {e.name = e.name.replace(/(?<=^flt)[^_]*(?=_)/, idxChar(i))});
};
$(`.pagination a:not([href^=javascript])`).each((_, e) => {
let url = new URL(e.href), params = Array.from(new URLSearchParams(url.search)), idx = 0;
params.forEach(kv => {
let m = kv[0].match(/^flt[^_]*(_.*)$/);
if (m) kv[0] = `flt${idxChar(idx++)}${m[1]}`;
});
e.href = Object.assign(url, {search: new URLSearchParams(params)});
});
});
================================================
FILE: bukuserver/static/bukuserver/js/last_page.js
================================================
$(document).ready(function() {
$(`.pagination :contains("»") a`).not(`[href^="javascript:"]`).attr('href', (idx, href) =>
href.replace(/\/?(\?|$)/, '/last-page$1').replace(/([?&])page=[0-9]+(&|$)/, '$1'));
});
================================================
FILE: bukuserver/static/bukuserver/js/order_filter.js
================================================
$(document).ready(function () { // retaining state of same-kind filters on switch
let config = JSON.parse(document.getElementById('filter-groups-data').innerText);
let isOrder = k => config[k]?.[0].arg.startsWith('order_');
let orderFilters = () => $(`option[value^="order_"]`).parent(`.filter-op`);
let filterInput = filter => $(`:is(input, select).filter-val`, $(filter).parents('tr').first());
let adder = $(`.field-filters .filter`).filter(function () {return isOrder(this.innerText)}).get(0);
let stickyValue = (filter, input=filterInput(filter)) => $(filter).on('change', (evt, param) => {
let _input = filterInput(filter);
if (evt.removed?.id?.startsWith('order_by_') == evt.val?.startsWith('order_by_'))
_input.val( input.val() ); // retaining the last value
input = _input;
if (input.prop('tagName') == 'SELECT') // redraw select widget
$(`.select2-chosen`, input.prev()).text( $(':selected', input).text() );
});
orderFilters().each(function () {stickyValue(this)});
adder.onclick = () => setTimeout(() => stickyValue( orderFilters().last() ));
});
================================================
FILE: bukuserver/templates/bukuserver/bookmark_create.html
================================================
{% extends 'admin/model/create.html' %}
{% import 'bukuserver/lib.html' as buku with context %}
{% block tail %}
{{ super() }}
{{ buku.set_lang() }}
{{ buku.limit_navigation_if_popup() }}
{{ buku.brand_dbname() }}
{{ buku.script('bookmark.js') }}
{{ buku.fetch_checkbox(form.fetch.data) }}
{{ buku.horizontal_form(excluding_popups=True) }}
{{ buku.focus() }}
{{ buku.link_saved() }}
{% endblock %}
================================================
FILE: bukuserver/templates/bukuserver/bookmark_create_modal.html
================================================
{% extends 'admin/model/modals/create.html' %}
{% import 'bukuserver/lib.html' as buku with context %}
{% block create_form %}
{{ lib.render_form(form, return_url, lib.extra(), form_opts=form_opts,
action=url_for('.create_view', url=return_url),
is_modal=True) }}
{% endblock %}
{% block tail %}
{{ super() }}
{{ buku.script('bookmark.js') }}
{{ buku.fetch_checkbox(form.fetch.data, modal=True) }}
{{ buku.horizontal_form() }}
{{ buku.focus('.modal-body') }}
{% endblock %}
================================================
FILE: bukuserver/templates/bukuserver/bookmark_details.html
================================================
{% extends 'admin/model/details.html' %}
{% import 'bukuserver/lib.html' as buku with context %}
{% block tail %}
{{ super() }}
{{ buku.set_lang() }}
{{ buku.limit_navigation_if_popup() }}
{{ buku.brand_dbname() }}
{{ buku.details_formatting('.table.searchable') }}
{{ buku.link_saved() }}
{% endblock %}
================================================
FILE: bukuserver/templates/bukuserver/bookmark_details_modal.html
================================================
{% extends 'admin/model/modals/details.html' %}
{% import 'bukuserver/lib.html' as buku with context %}
{% block header_text %}
{{_('Bookmarklet')}}:
✚ {{ _('Add to Buku') }} {{ _("Note: if you select text on the page before activating the bookmarklet, it'll be used as description instead of page metadata.") }}
{{ _('Location Bar (keyboard-only) shortcut') }}
{{ _('in Firefox:') }}
{{ _('Open the bookmarks editor and set %(buku)s in the Keyword field of the bookmarklet.', buku='@buku'|safe) }}
{{ _('in Chrome:') }}
{{ _('In %(path)s, add a new row by placing %(add_to_buku)s, %(buku)s, and the copied bookmarklet URL in respective fields).',
path=''|safe + _('Settings > Search engine > Manage… > Site Search')|escape + ''|safe,
add_to_buku='✚ '|safe + _('Add to Buku') + ''|safe, buku='@buku'|safe) }}
{{ _('usage:') }}
{{ _("By hitting %(hotkey)s (thus switching to Location Bar), then typing %(buku)s and hitting %(enter)s, you'll be able to open the bookmarklet dialog via keyboard only.",
hotkey='Ctrl+L'|safe, buku='@buku'|safe, enter='Enter'|safe) }}
{{
_('Note: in Firefox this changes displayed URL, but you can reset it by switching back to Location Bar and hitting %(escape)s twice.', escape='Esc'|safe)
}}
{% endblock %}
{% block tail %}
{{ buku.fix_translations('tags') }}
{{ super() }}
{{ buku.page_size_custom() }}
{{ buku.focus(None) }}
{% endblock %}
================================================
FILE: bukuserver/translations/.gitignore
================================================
/messages.pot
================================================
FILE: bukuserver/translations/README.md
================================================
## Bukuserver translations
This directory contains translations activated by installing Flask-Babel(Ex) and providing `BUKUSERVER_LOCALE` environment variable.
(If a locale is missing or incomplete, the original "keys" are used as translations; also `_gettext` converts strings using Flask-Admin translation files.)
The `__init__.py` file includes wrapper scripts for Babel CLI interface; a simple CLI is provided in `__main__.py`.
Translation files can be found as `/LC_MESSAGES/messages.po` (their corresponding _compiled_ versions have a `.mo` extension);
their layout is generated by collecting translation keys from Python/Jinja sources, and appending the `messages_custom.pot` contents
(for keys that can't be detected automatically).
### Script
The `__main__.py` file contains a script that does the following:
1. collect translation keys from Python and Jinja invocations of `gettext()`/`lazy_gettext()`/`pgettext()` +
shorthands `_()`/`_l()`/`_p()`/`_lp()`, and generate a "template" translation file `messages.pot`
2. patch said "template" with initial values (most importantly a hardcoded creation date, which prevents "changes" from appearing in unchanged translations)
and append contents of `messages_custom.pot` to it
3. update existing translation files (`*/LC_MESSAGES/messages.po`) to match the new "template" (note that if you've removed/renamed some translation keys,
their current values will be commented out and moved to the bottom; so you'll need to edit _all_ translation files after that)
4. if any locale names are supplied, corresponding locale files are created (or updated if they exist already)
5. compile all translation files into matching `.mo` binaries
Steps 1-2 are implemented as `translations_generate()`, 3-4 as `translations_update()`, and 5 as `translations_compile()` (in the `__init__.py` file).
### Usage
```sh
python .
```
Will run the `__main__.py` script (if running from a different folder, pass relative path to it instead of `.`)
```sh
python -m bukuserver.translations
```
Has the same effect _when run **in a virtualenv**_ in which `buku` was _installed as an **`--editable`** package_.
Run the script in following events:
* after you've added/removed/renamed some translation keys in the source (note that in the latter two cases you'll need to _edit all translation files_,
to either remove or restore commented out translations of keys no longer found in `messages.pot`; _alternatively_ you can edit these keys before running the script)
* after you've edited some translation file(s), to compile them
* when you want to add one or more new locales (provide their codes as additional parameters to the script)
Additionally, providing `-h` or `--help` will cause brief usage info to be printed out instead.
================================================
FILE: bukuserver/translations/__init__.py
================================================
import os
import re
from babel.messages.frontend import CommandLineInterface as pybabel
try:
from buku import __version__
except ImportError:
__version__ = None
DOMAIN = 'messages'
DIR = os.path.dirname(__file__)
BUKUSERVER = os.path.dirname(DIR)
MAPPING = os.path.join(DIR, 'babel.cfg')
TEMPLATE = os.path.join(DIR, 'messages.pot')
CUSTOM = os.path.join(DIR, 'messages_custom.pot')
_EOL, _STR_EOL, _G_STR_EOL = r'$\r?\n?', r'"[^\r\n]*"$\r?\n?', r'"([^\r\n]*)"$\r?\n?'
STRINGS = {
r'\bPROJECT VERSION\b': __version__ or '???',
r'(?<=for )PROJECT\b': 'bukuserver',
r'(?<=as the )PROJECT(?= project)': 'buku',
r'\bORGANIZATION\b': 'buku',
f'^# FIRST AUTHOR , [0-9]+.{_EOL}': '',
f'^#, fuzzy{_EOL}': '',
r'(?<=^"POT-Creation-Date: ).*(?=\\n"$)': '2024-09-12 00:00+0000', # avoid git updates of unchanged translations
}
OLD_BLANK = re.compile(f'^(?:#~ msgctxt {_STR_EOL})?#~ msgid {_STR_EOL}#~ msgstr ""{_EOL}(?:{_EOL})?', re.MULTILINE)
OLD = re.compile(f'^(?:#~ msgctxt {_G_STR_EOL})?#~ msgid {_G_STR_EOL}#~ msgstr {_G_STR_EOL}(?:{_EOL})?', re.MULTILINE)
def replace_obsolete(text):
'''Removes *blank* obsolete entries, and restores re-added *blank* values from old obsolete ones'''
text = re.sub(OLD_BLANK, '', text)
for obsolete in re.finditer(OLD, text):
_ctxt, _id, _str = obsolete.groups()
ctxt_re = ('' if _ctxt is None else f'msgctxt "{re.escape(_ctxt)}"{_EOL}')
if m := re.search(f'^{ctxt_re}msgid "{re.escape(_id)}"{_EOL}msgstr "()"{_EOL}', text, re.MULTILINE):
text = (text[:m.start(1)] + _str + text[m.end(1):]).replace(obsolete.group(0), '', 1)
return text
def translations_generate():
'''Generates and patches the messages.pot template file'''
pybabel().run(['', 'extract', '--no-wrap', f'--mapping-file={MAPPING}',
'--keywords=_ _l _p:1c,2 _lp:1c,2 lazy_gettext', f'--output-file={TEMPLATE}', BUKUSERVER])
print(f'patching PO template file at {TEMPLATE}')
with open(TEMPLATE, encoding='utf-8') as fin:
text = fin.read()
for k, v in STRINGS.items():
text = re.sub(k, v, text, count=1, flags=re.MULTILINE)
with open(CUSTOM, encoding='utf-8') as fin:
with open(TEMPLATE, 'w', encoding='utf-8') as fout:
fout.write(text + fin.read())
def translations_update(new_locales=[], generate=True, domain=DOMAIN, fuzzy=False):
'''Updates all existing translations (*/LC_MESSAGES/messages.po) based on messages.pot'''
generate and translations_generate()
command = (['', 'update', '--no-wrap'] + ([] if fuzzy else ['--no-fuzzy-matching']) +
[f'--domain={domain}', f'--input-file={TEMPLATE}', f'--output-dir={DIR}'])
pybabel().run(command)
for locale in new_locales:
try: # trying an update first, to prevent clearing an existing translation
pybabel().run(command + ['--init-missing', f'--locale={locale}'])
except FileNotFoundError:
pybabel().run(['', 'init', '--no-wrap', f'--domain={domain}', f'--input-file={TEMPLATE}',
f'--output-dir={DIR}', f'--locale={locale}'])
# handling obsolete entries (removing blank and restoring re-added keys)
for locale in os.listdir(DIR):
filename = os.path.join(DIR, locale, 'LC_MESSAGES', f'{domain}.po')
if os.path.isfile(filename) and os.access(filename, os.W_OK):
with open(filename, encoding='utf-8') as fin:
text = fin.read()
if text != (stripped := replace_obsolete(text)):
print(f'processing obsolete entries from catalog {filename}')
with open(filename, 'w', encoding='utf-8') as fout:
fout.write(stripped)
def translations_compile(update=False, generate=True, domain=DOMAIN, new_locales=[], fuzzy=False):
'''Compiles all existing translations'''
update and translations_update(generate=generate, domain=DOMAIN, new_locales=new_locales, fuzzy=fuzzy)
pybabel().run(['', 'compile', f'--domain={domain}', f'--directory={DIR}'])
================================================
FILE: bukuserver/translations/__main__.py
================================================
#!/usr/bin/env python
import os
import sys
try:
from . import translations_compile, __version__
except ImportError:
from bukuserver.translations import translations_compile, __version__
if __name__ == '__main__':
if any(s in sys.argv[1:] for s in ['-h', '--help']):
print(f' Usage: python {sys.argv[0]} [new-locale [...]]')
print(' This script updates Bukuserver translation files (and/or adds new locales)')
print(' FUZZY=yes (or any other non-empty value) enables fuzzy matching')
print(f' [buku version: {__version__ or "???"}]')
else:
new_locales = [s for s in sys.argv[1:] if s[:1] not in ('', '-')]
translations_compile(update=True, new_locales=new_locales, fuzzy=bool(os.environ.get('FUZZY')))
================================================
FILE: bukuserver/translations/babel.cfg
================================================
[python: **.py]
[jinja2: **/templates/**.html]
================================================
FILE: bukuserver/translations/de/LC_MESSAGES/messages.po
================================================
# German translations for bukuserver.
# Copyright (C) 2024 buku
# This file is distributed under the same license as the buku project.
#
msgid ""
msgstr ""
"Project-Id-Version: 4.9\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2024-09-12 00:00+0000\n"
"PO-Revision-Date: 2024-09-08 19:42+0200\n"
"Last-Translator: FULL NAME \n"
"Language: de\n"
"Language-Team: de \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
#: /home/lex/Work/buku/bukuserver/api.py:272
msgid "Input required."
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:61
msgid "equals"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:62
msgid "not equals"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:63
msgid "contains"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:64
msgid "not contains"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:65
msgid "greater than"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:66
msgid "smaller than"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:67
msgid "in list"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:68
msgid "not in list"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:69
msgid "top X"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:70
msgid "bottom X"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:124
msgid "natural"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:124
msgid "reversed"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:21
msgid "The value must be a string."
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:49
msgid "Invalid input."
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:53
msgid "Keywords"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:54
msgid "Match all keywords"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:54
msgid "Exclude partial matches (with multiple keywords)"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:55
msgid "With markers"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:56
msgid "The search string will be split into multiple keywords, each will be applied to a field based on prefix:"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:57
msgid " - keywords starting with '.', '>' or ':' will be searched for in title, description and URL respectively"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:58
msgid " - '#' will be searched for in tags (comma-separated, partial matches; not affected by Deep Search)"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:59
msgid " - '#,' is the same but will match FULL tags only"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:60
msgid " - '*' will be searched for in all fields (this prefix can be omitted in the 1st keyword)"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:61
msgid "Keywords need to be separated by placing spaces before the prefix."
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:63
msgid "Deep search"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:63
msgid "When unset, only FULL words will be matched."
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:64
msgid "Regex"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:64
msgid "The keyword(s) are regular expressions (overrides other options)."
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:68
msgid "Keyword"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:72
#: /home/lex/Work/buku/bukuserver/views.py:174
msgid "URL"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:73
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:190
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:225
#: /home/lex/Work/buku/bukuserver/views.py:175
msgid "Title"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:74
#: /home/lex/Work/buku/bukuserver/server.py:150
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:31
#: /home/lex/Work/buku/bukuserver/views.py:175
msgid "Tags"
msgstr "Schilder"
#: /home/lex/Work/buku/bukuserver/forms.py:75
#: /home/lex/Work/buku/bukuserver/views.py:175
msgid "Description"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:121
msgid "Delete tags list from existing tags"
msgstr ""
#: /home/lex/Work/buku/bukuserver/server.py:149
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:30
msgid "Bookmarks"
msgstr "Lesezeichen"
#: /home/lex/Work/buku/bukuserver/server.py:151
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:32
msgid "Statistic"
msgstr "Statistik"
#: /home/lex/Work/buku/bukuserver/views.py:112
msgid "Duplicate URL"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:113
msgid "Rejected by the database"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:126
msgid ""
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:174
msgid "Entry"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:174
msgid "Index"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:223
#, python-format
msgid "url invalid: %(url)s"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:234
msgid "Failed to create record."
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:247
#: /home/lex/Work/buku/bukuserver/views.py:562
msgid "Failed to delete record."
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:262
msgid "Invalid search mode combination"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:278
msgid "Reordered bookmarks in DB"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:358
msgid "netloc match"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:391
msgid "contain"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:392
msgid "not contain"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:393
msgid "number equal"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:394
msgid "number not equal"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:395
msgid "number greater than"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:396
msgid "number smaller than"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:422
#: /home/lex/Work/buku/bukuserver/views.py:580
msgid "Failed to update record."
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:437
msgid ""
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:443
#: /home/lex/Work/buku/bukuserver/views.py:480
msgctxt "tag"
msgid "Name"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:443
msgctxt "tag"
msgid "Usage Count"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:543
msgid "top most common"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmark_details_modal.html:7
msgid "Pick another"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:10
#, python-brace-format
msgid "Swap record #{} with record #"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:12
#, python-brace-format
msgid "Not a valid record index: '{}'"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:13
#, python-brace-format
msgid "There are only {} records in total!"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:14
msgid "Swapping a record with itself has no effect!"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:25
msgid "Random"
msgstr "Zufälliger"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:31
msgid "Update indices to match this order"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:32
msgid "Reorder"
msgstr "Neu anordnen"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:35
msgid "Save this order in DB?"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:58
msgid "Swap with…"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:58
msgid "Move down"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:58
msgid "Move up"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:15
msgid "Search bookmark"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:28
msgid "Bookmark manager like a text-based mini-web"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:49
msgid "Bookmarklet"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:51
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:67
msgid "Add to Buku"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:53
msgid "Note: if you select text on the page before activating the bookmarklet, it'll be used as description instead of page metadata."
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:58
msgid "Location Bar (keyboard-only) shortcut"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:60
msgid "in Firefox:"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:61
#, python-format
msgid "Open the bookmarks editor and set %(buku)s in the Keyword field of the bookmarklet."
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:63
msgid "in Chrome:"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:65
#, python-format
msgid "In %(path)s, add a new row by placing %(add_to_buku)s, %(buku)s, and the copied bookmarklet URL in respective fields)."
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:66
msgid "Settings > Search engine > Manage… > Site Search"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:70
msgid "usage:"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:72
#, python-format
msgid "By hitting %(hotkey)s (thus switching to Location Bar), then typing %(buku)s and hitting %(enter)s, you'll be able to open the bookmarklet dialog via keyboard only."
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:75
#, python-format
msgid "Note: in Firefox this changes displayed URL, but you can reset it by switching back to Location Bar and hitting %(escape)s twice."
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:89
msgid "FULL"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:64
msgid "Collect missing data (+extra tags) by fetching & parsing the webpage"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:67
msgid "Fetch"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:97
msgctxt "tags"
msgid "name"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:97
msgctxt "tags"
msgid "usage count"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:98
msgctxt "bookmarks"
msgid "index"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:98
msgctxt "bookmarks"
msgid "url"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:98
msgctxt "bookmarks"
msgid "title"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:99
msgctxt "bookmarks"
msgid "tags"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:99
msgctxt "bookmarks"
msgid "order"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:101
msgid "Delete record"
msgstr "Datenzatz löschen"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:131
msgid "custom"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:132
msgid "Set custom page size (empty for default)"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:138
msgid "Invalid page size"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:9
#, python-brace-format
msgid "{} days ago"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:9
#, python-brace-format
msgid "{} hours ago"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:9
#, python-brace-format
msgid "{} minutes ago"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:9
#, python-brace-format
msgid "{} seconds ago"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:13
msgid "just now"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:22
msgid "Data created"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:24
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/tags_list.html:13
msgid "Refresh"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:26
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:44
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:77
msgid "Netloc"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:37
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:110
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:183
msgid "View all"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:43
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:76
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:116
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:151
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:189
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:224
msgid "Rank"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:45
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:78
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:118
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:153
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:191
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:226
msgid "Number"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:52
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:86
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:258
msgid "(no netloc)"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:61
msgid "No bookmarks found."
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:70
msgid "Netloc ranking"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:99
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:117
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:152
msgid "Tag"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:136
msgid "No tags found."
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:145
msgid "Tag ranking"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:172
msgid "Title (common)"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:199
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:234
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:327
msgid "(no title)"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:209
msgid "No common titles found."
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:218
msgid "Common titles ranking"
msgstr ""
msgid "Original and replacement tags are the same."
msgstr ""
msgid "Tag name cannot be blank."
msgstr ""
msgid "by index"
msgstr ""
msgid "by url"
msgstr ""
msgid "by netloc"
msgstr ""
msgid "by title"
msgstr ""
msgid "by description"
msgstr ""
msgid "by tags"
msgstr ""
msgid "with tag first"
msgstr ""
msgid "with tag last"
msgstr ""
msgid "search"
msgstr ""
msgid "search regex"
msgstr ""
msgid "search deep"
msgstr ""
msgid "search match all"
msgstr ""
msgid "search match all, deep"
msgstr ""
msgid "search markers"
msgstr ""
msgid "search markers, regex"
msgstr ""
msgid "search markers, deep"
msgstr ""
msgid "search markers, match all"
msgstr ""
msgid "search markers, match all, deep"
msgstr ""
================================================
FILE: bukuserver/translations/fr/LC_MESSAGES/messages.po
================================================
# French translations for bukuserver.
# Copyright (C) 2024 buku
# This file is distributed under the same license as the buku project.
#
msgid ""
msgstr ""
"Project-Id-Version: 4.9\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2024-09-12 00:00+0000\n"
"PO-Revision-Date: 2024-09-08 19:42+0200\n"
"Last-Translator: FULL NAME \n"
"Language: fr\n"
"Language-Team: fr \n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
#: /home/lex/Work/buku/bukuserver/api.py:272
msgid "Input required."
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:61
msgid "equals"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:62
msgid "not equals"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:63
msgid "contains"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:64
msgid "not contains"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:65
msgid "greater than"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:66
msgid "smaller than"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:67
msgid "in list"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:68
msgid "not in list"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:69
msgid "top X"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:70
msgid "bottom X"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:124
msgid "natural"
msgstr ""
#: /home/lex/Work/buku/bukuserver/filters.py:124
msgid "reversed"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:21
msgid "The value must be a string."
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:49
msgid "Invalid input."
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:53
msgid "Keywords"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:54
msgid "Match all keywords"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:54
msgid "Exclude partial matches (with multiple keywords)"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:55
msgid "With markers"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:56
msgid "The search string will be split into multiple keywords, each will be applied to a field based on prefix:"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:57
msgid " - keywords starting with '.', '>' or ':' will be searched for in title, description and URL respectively"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:58
msgid " - '#' will be searched for in tags (comma-separated, partial matches; not affected by Deep Search)"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:59
msgid " - '#,' is the same but will match FULL tags only"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:60
msgid " - '*' will be searched for in all fields (this prefix can be omitted in the 1st keyword)"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:61
msgid "Keywords need to be separated by placing spaces before the prefix."
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:63
msgid "Deep search"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:63
msgid "When unset, only FULL words will be matched."
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:64
msgid "Regex"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:64
msgid "The keyword(s) are regular expressions (overrides other options)."
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:68
msgid "Keyword"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:72
#: /home/lex/Work/buku/bukuserver/views.py:174
msgid "URL"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:73
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:190
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:225
#: /home/lex/Work/buku/bukuserver/views.py:175
msgid "Title"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:74
#: /home/lex/Work/buku/bukuserver/server.py:150
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:31
#: /home/lex/Work/buku/bukuserver/views.py:175
msgid "Tags"
msgstr "Étiquettes"
#: /home/lex/Work/buku/bukuserver/forms.py:75
#: /home/lex/Work/buku/bukuserver/views.py:175
msgid "Description"
msgstr ""
#: /home/lex/Work/buku/bukuserver/forms.py:121
msgid "Delete tags list from existing tags"
msgstr ""
#: /home/lex/Work/buku/bukuserver/server.py:149
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:30
msgid "Bookmarks"
msgstr "Signets"
#: /home/lex/Work/buku/bukuserver/server.py:151
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:32
msgid "Statistic"
msgstr "Statistique"
#: /home/lex/Work/buku/bukuserver/views.py:112
msgid "Duplicate URL"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:113
msgid "Rejected by the database"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:126
msgid ""
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:174
msgid "Entry"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:174
msgid "Index"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:223
#, python-format
msgid "url invalid: %(url)s"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:234
msgid "Failed to create record."
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:247
#: /home/lex/Work/buku/bukuserver/views.py:562
msgid "Failed to delete record."
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:262
msgid "Invalid search mode combination"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:278
msgid "Reordered bookmarks in DB"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:358
msgid "netloc match"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:391
msgid "contain"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:392
msgid "not contain"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:393
msgid "number equal"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:394
msgid "number not equal"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:395
msgid "number greater than"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:396
msgid "number smaller than"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:422
#: /home/lex/Work/buku/bukuserver/views.py:580
msgid "Failed to update record."
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:437
msgid ""
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:443
#: /home/lex/Work/buku/bukuserver/views.py:480
msgctxt "tag"
msgid "Name"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:443
msgctxt "tag"
msgid "Usage Count"
msgstr ""
#: /home/lex/Work/buku/bukuserver/views.py:543
msgid "top most common"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmark_details_modal.html:7
msgid "Pick another"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:10
#, python-brace-format
msgid "Swap record #{} with record #"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:12
#, python-brace-format
msgid "Not a valid record index: '{}'"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:13
#, python-brace-format
msgid "There are only {} records in total!"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:14
msgid "Swapping a record with itself has no effect!"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:25
msgid "Random"
msgstr "Aléatoire"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:31
msgid "Update indices to match this order"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:32
msgid "Reorder"
msgstr "Réorganiser"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:35
msgid "Save this order in DB?"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:58
msgid "Swap with…"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:58
msgid "Move down"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:58
msgid "Move up"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:15
msgid "Search bookmark"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:28
msgid "Bookmark manager like a text-based mini-web"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:49
msgid "Bookmarklet"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:51
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:67
msgid "Add to Buku"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:53
msgid "Note: if you select text on the page before activating the bookmarklet, it'll be used as description instead of page metadata."
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:58
msgid "Location Bar (keyboard-only) shortcut"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:60
msgid "in Firefox:"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:61
#, python-format
msgid "Open the bookmarks editor and set %(buku)s in the Keyword field of the bookmarklet."
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:63
msgid "in Chrome:"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:65
#, python-format
msgid "In %(path)s, add a new row by placing %(add_to_buku)s, %(buku)s, and the copied bookmarklet URL in respective fields)."
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:66
msgid "Settings > Search engine > Manage… > Site Search"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:70
msgid "usage:"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:72
#, python-format
msgid "By hitting %(hotkey)s (thus switching to Location Bar), then typing %(buku)s and hitting %(enter)s, you'll be able to open the bookmarklet dialog via keyboard only."
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:75
#, python-format
msgid "Note: in Firefox this changes displayed URL, but you can reset it by switching back to Location Bar and hitting %(escape)s twice."
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:89
msgid "FULL"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:64
msgid "Collect missing data (+extra tags) by fetching & parsing the webpage"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:67
msgid "Fetch"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:97
msgctxt "tags"
msgid "name"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:97
msgctxt "tags"
msgid "usage count"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:98
msgctxt "bookmarks"
msgid "index"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:98
msgctxt "bookmarks"
msgid "url"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:98
msgctxt "bookmarks"
msgid "title"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:99
msgctxt "bookmarks"
msgid "tags"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:99
msgctxt "bookmarks"
msgid "order"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:101
msgid "Delete record"
msgstr "Supprimer l'enregistrement"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:131
msgid "custom"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:132
msgid "Set custom page size (empty for default)"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:138
msgid "Invalid page size"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:9
#, python-brace-format
msgid "{} days ago"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:9
#, python-brace-format
msgid "{} hours ago"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:9
#, python-brace-format
msgid "{} minutes ago"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:9
#, python-brace-format
msgid "{} seconds ago"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:13
msgid "just now"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:22
msgid "Data created"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:24
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/tags_list.html:13
msgid "Refresh"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:26
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:44
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:77
msgid "Netloc"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:37
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:110
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:183
msgid "View all"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:43
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:76
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:116
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:151
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:189
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:224
msgid "Rank"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:45
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:78
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:118
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:153
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:191
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:226
msgid "Number"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:52
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:86
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:258
msgid "(no netloc)"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:61
msgid "No bookmarks found."
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:70
msgid "Netloc ranking"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:99
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:117
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:152
msgid "Tag"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:136
msgid "No tags found."
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:145
msgid "Tag ranking"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:172
msgid "Title (common)"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:199
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:234
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:327
msgid "(no title)"
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:209
msgid "No common titles found."
msgstr ""
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:218
msgid "Common titles ranking"
msgstr ""
msgid "Original and replacement tags are the same."
msgstr ""
msgid "Tag name cannot be blank."
msgstr ""
msgid "by index"
msgstr ""
msgid "by url"
msgstr ""
msgid "by netloc"
msgstr ""
msgid "by title"
msgstr ""
msgid "by description"
msgstr ""
msgid "by tags"
msgstr ""
msgid "with tag first"
msgstr ""
msgid "with tag last"
msgstr ""
msgid "search"
msgstr ""
msgid "search regex"
msgstr ""
msgid "search deep"
msgstr ""
msgid "search match all"
msgstr ""
msgid "search match all, deep"
msgstr ""
msgid "search markers"
msgstr ""
msgid "search markers, regex"
msgstr ""
msgid "search markers, deep"
msgstr ""
msgid "search markers, match all"
msgstr ""
msgid "search markers, match all, deep"
msgstr ""
================================================
FILE: bukuserver/translations/messages_custom.pot
================================================
msgid "Original and replacement tags are the same."
msgstr ""
msgid "Tag name cannot be blank."
msgstr ""
msgid "by index"
msgstr ""
msgid "by url"
msgstr ""
msgid "by netloc"
msgstr ""
msgid "by title"
msgstr ""
msgid "by description"
msgstr ""
msgid "by tags"
msgstr ""
msgid "with tag first"
msgstr ""
msgid "with tag last"
msgstr ""
msgid "search"
msgstr ""
msgid "search regex"
msgstr ""
msgid "search deep"
msgstr ""
msgid "search match all"
msgstr ""
msgid "search match all, deep"
msgstr ""
msgid "search markers"
msgstr ""
msgid "search markers, regex"
msgstr ""
msgid "search markers, deep"
msgstr ""
msgid "search markers, deep"
msgstr ""
msgid "search markers, match all"
msgstr ""
msgid "search markers, match all, deep"
msgstr ""
================================================
FILE: bukuserver/translations/ru/LC_MESSAGES/messages.po
================================================
# Russian translations for bukuserver.
# Copyright (C) 2024 buku
# This file is distributed under the same license as the buku project.
#
msgid ""
msgstr ""
"Project-Id-Version: 4.9\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2024-09-12 00:00+0000\n"
"PO-Revision-Date: 2024-09-08 19:42+0200\n"
"Last-Translator: FULL NAME \n"
"Language: ru\n"
"Language-Team: ru \n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
#: /home/lex/Work/buku/bukuserver/api.py:272
msgid "Input required."
msgstr "Введите данные."
#: /home/lex/Work/buku/bukuserver/filters.py:61
msgid "equals"
msgstr "равняется"
#: /home/lex/Work/buku/bukuserver/filters.py:62
msgid "not equals"
msgstr "не равняется"
#: /home/lex/Work/buku/bukuserver/filters.py:63
msgid "contains"
msgstr "содержит"
#: /home/lex/Work/buku/bukuserver/filters.py:64
msgid "not contains"
msgstr "не содержит"
#: /home/lex/Work/buku/bukuserver/filters.py:65
msgid "greater than"
msgstr "больше чем"
#: /home/lex/Work/buku/bukuserver/filters.py:66
msgid "smaller than"
msgstr "меньше чем"
#: /home/lex/Work/buku/bukuserver/filters.py:67
msgid "in list"
msgstr "в списке"
#: /home/lex/Work/buku/bukuserver/filters.py:68
msgid "not in list"
msgstr "не в списке"
#: /home/lex/Work/buku/bukuserver/filters.py:69
msgid "top X"
msgstr "первые X"
#: /home/lex/Work/buku/bukuserver/filters.py:70
msgid "bottom X"
msgstr "последние X"
#: /home/lex/Work/buku/bukuserver/filters.py:124
msgid "natural"
msgstr "естественный"
#: /home/lex/Work/buku/bukuserver/filters.py:124
msgid "reversed"
msgstr "обратный"
#: /home/lex/Work/buku/bukuserver/forms.py:21
msgid "The value must be a string."
msgstr "Значение должно быть строкой."
#: /home/lex/Work/buku/bukuserver/forms.py:49
msgid "Invalid input."
msgstr "Некорректный ввод"
#: /home/lex/Work/buku/bukuserver/forms.py:53
msgid "Keywords"
msgstr "Ключевые слова"
#: /home/lex/Work/buku/bukuserver/forms.py:54
msgid "Match all keywords"
msgstr "Все подстроки должны совпасть"
#: /home/lex/Work/buku/bukuserver/forms.py:54
msgid "Exclude partial matches (with multiple keywords)"
msgstr "Исключить частичные совпадения (при поиске более одной подстроки)"
#: /home/lex/Work/buku/bukuserver/forms.py:55
msgid "With markers"
msgstr "С маркерами"
#: /home/lex/Work/buku/bukuserver/forms.py:56
msgid "The search string will be split into multiple keywords, each will be applied to a field based on prefix:"
msgstr "Строка поиска разбивается на подстроки, применяемые к полям в зависимости от префикса:"
#: /home/lex/Work/buku/bukuserver/forms.py:57
msgid " - keywords starting with '.', '>' or ':' will be searched for in title, description and URL respectively"
msgstr " - подстроки начинающиеся с '.', '>' или ':' ищутся в названии, описании и ссылке соответственно"
#: /home/lex/Work/buku/bukuserver/forms.py:58
msgid " - '#' will be searched for in tags (comma-separated, partial matches; not affected by Deep Search)"
msgstr " - '#' ищется в тегах (через запятую, частичные совпадения; «глубокий поиск» игнорируется)"
#: /home/lex/Work/buku/bukuserver/forms.py:59
msgid " - '#,' is the same but will match FULL tags only"
msgstr " - '#,' работает так же но проверяет теги на ПОЛНОЕ совпадение"
#: /home/lex/Work/buku/bukuserver/forms.py:60
msgid " - '*' will be searched for in all fields (this prefix can be omitted in the 1st keyword)"
msgstr " - '*' будет искаться во всех полях (этот префикс можно опустить в 1-й подстроке)"
#: /home/lex/Work/buku/bukuserver/forms.py:61
msgid "Keywords need to be separated by placing spaces before the prefix."
msgstr "Подстроки нужно разделять пробелом перед префиксом."
#: /home/lex/Work/buku/bukuserver/forms.py:63
msgid "Deep search"
msgstr "Глубокий поиск"
#: /home/lex/Work/buku/bukuserver/forms.py:63
msgid "When unset, only FULL words will be matched."
msgstr "Если это не выбрано, слова проверяются только на ПОЛНОЕ совпадение."
#: /home/lex/Work/buku/bukuserver/forms.py:64
msgid "Regex"
msgstr "Регулярное выражение"
#: /home/lex/Work/buku/bukuserver/forms.py:64
msgid "The keyword(s) are regular expressions (overrides other options)."
msgstr "Искомая подстрока(-и) – регулярное выражение (замещает остальные параметры)."
#: /home/lex/Work/buku/bukuserver/forms.py:68
msgid "Keyword"
msgstr "Искать"
#: /home/lex/Work/buku/bukuserver/forms.py:72
#: /home/lex/Work/buku/bukuserver/views.py:174
msgid "URL"
msgstr "Ссылка"
#: /home/lex/Work/buku/bukuserver/forms.py:73
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:190
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:225
#: /home/lex/Work/buku/bukuserver/views.py:175
msgid "Title"
msgstr "Название"
#: /home/lex/Work/buku/bukuserver/forms.py:74
#: /home/lex/Work/buku/bukuserver/server.py:150
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:31
#: /home/lex/Work/buku/bukuserver/views.py:175
msgid "Tags"
msgstr "Теги"
#: /home/lex/Work/buku/bukuserver/forms.py:75
#: /home/lex/Work/buku/bukuserver/views.py:175
msgid "Description"
msgstr "Описание"
#: /home/lex/Work/buku/bukuserver/forms.py:121
msgid "Delete tags list from existing tags"
msgstr "Удалить список тегов из существующих"
#: /home/lex/Work/buku/bukuserver/server.py:149
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:30
msgid "Bookmarks"
msgstr "Закладки"
#: /home/lex/Work/buku/bukuserver/server.py:151
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:32
msgid "Statistic"
msgstr "Статистика"
#: /home/lex/Work/buku/bukuserver/views.py:112
msgid "Duplicate URL"
msgstr "Такая ссылка в базе уже есть"
#: /home/lex/Work/buku/bukuserver/views.py:113
msgid "Rejected by the database"
msgstr "База данных отказала в выполнении операции"
#: /home/lex/Work/buku/bukuserver/views.py:126
msgid ""
msgstr "<БЕЗ НАЗВАНИЯ>"
#: /home/lex/Work/buku/bukuserver/views.py:174
msgid "Entry"
msgstr "Закладка"
#: /home/lex/Work/buku/bukuserver/views.py:174
msgid "Index"
msgstr "Номер"
#: /home/lex/Work/buku/bukuserver/views.py:223
#, python-format
msgid "url invalid: %(url)s"
msgstr "некорректная ссылка: %(url)s"
#: /home/lex/Work/buku/bukuserver/views.py:234
msgid "Failed to create record."
msgstr "Ошибка создания записи."
#: /home/lex/Work/buku/bukuserver/views.py:247
#: /home/lex/Work/buku/bukuserver/views.py:562
msgid "Failed to delete record."
msgstr "Ошибка удаления записи."
#: /home/lex/Work/buku/bukuserver/views.py:262
msgid "Invalid search mode combination"
msgstr "Некорректная комбинация фильтров поиска"
#: /home/lex/Work/buku/bukuserver/views.py:278
msgid "Reordered bookmarks in DB"
msgstr "Порядок закладок в БД был успешно изменён"
#: /home/lex/Work/buku/bukuserver/views.py:358
msgid "netloc match"
msgstr "на сайт"
#: /home/lex/Work/buku/bukuserver/views.py:391
msgid "contain"
msgstr "содержат"
#: /home/lex/Work/buku/bukuserver/views.py:392
msgid "not contain"
msgstr "не содержат"
#: /home/lex/Work/buku/bukuserver/views.py:393
msgid "number equal"
msgstr "количество равно"
#: /home/lex/Work/buku/bukuserver/views.py:394
msgid "number not equal"
msgstr "количество не равно"
#: /home/lex/Work/buku/bukuserver/views.py:395
msgid "number greater than"
msgstr "количество больше чем"
#: /home/lex/Work/buku/bukuserver/views.py:396
msgid "number smaller than"
msgstr "количество меньше чем"
#: /home/lex/Work/buku/bukuserver/views.py:422
#: /home/lex/Work/buku/bukuserver/views.py:580
msgid "Failed to update record."
msgstr "Ошибка обновления записи."
#: /home/lex/Work/buku/bukuserver/views.py:437
msgid ""
msgstr "<БЕЗ ТЕГОВ>"
#: /home/lex/Work/buku/bukuserver/views.py:443
#: /home/lex/Work/buku/bukuserver/views.py:480
msgctxt "tag"
msgid "Name"
msgstr "Тег"
#: /home/lex/Work/buku/bukuserver/views.py:443
msgctxt "tag"
msgid "Usage Count"
msgstr "Число закладок"
#: /home/lex/Work/buku/bukuserver/views.py:543
msgid "top most common"
msgstr "самое распространённое"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmark_details_modal.html:7
msgid "Pick another"
msgstr "Показать другую"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:10
#, python-brace-format
msgid "Swap record #{} with record #"
msgstr "Поменять местами запись #{} с записью #"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:12
#, python-brace-format
msgid "Not a valid record index: '{}'"
msgstr "Некорректный номер строки: '{}'"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:13
#, python-brace-format
msgid "There are only {} records in total!"
msgstr "Всего существует только {} записей!"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:14
msgid "Swapping a record with itself has no effect!"
msgstr "Попытка поменять запись местами саму с собой ничего не даст!"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:25
msgid "Random"
msgstr "Случайная"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:31
msgid "Update indices to match this order"
msgstr "Изменить порядок записей в БД на текущий"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:32
msgid "Reorder"
msgstr "Изменить порядок"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:35
msgid "Save this order in DB?"
msgstr "Сохранить закладки в БД в этом порядке?"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:58
msgid "Swap with…"
msgstr "Поменять местами с…"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:58
msgid "Move down"
msgstr "Сдвинуть вниз"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/bookmarks_list.html:58
msgid "Move up"
msgstr "Сдвинуть вверх"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:15
msgid "Search bookmark"
msgstr "Искать закладку"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:28
msgid "Bookmark manager like a text-based mini-web"
msgstr "Менеджер закладок, как текстовая мини-сеть"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:49
msgid "Bookmarklet"
msgstr "Букмарклет"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:51
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:67
msgid "Add to Buku"
msgstr "Добавить в Buku"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:53
msgid "Note: if you select text on the page before activating the bookmarklet, it'll be used as description instead of page metadata."
msgstr "Заметка: если выделить текст на странице перед запуском букмарклета, он будет использован в качестве описания, вместо метаданных страницы."
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:58
msgid "Location Bar (keyboard-only) shortcut"
msgstr "Ссылка для адресной панели (активация с клавиатуры)"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:60
msgid "in Firefox:"
msgstr "в Firefox:"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:61
#, python-format
msgid "Open the bookmarks editor and set %(buku)s in the Keyword field of the bookmarklet."
msgstr "Откройте редактор закладок и введите %(buku)s в поле «Ключевое слово» закладки букмарклета."
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:63
msgid "in Chrome:"
msgstr "в Chrome:"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:65
#, python-format
msgid "In %(path)s, add a new row by placing %(add_to_buku)s, %(buku)s, and the copied bookmarklet URL in respective fields)."
msgstr "В меню %(path)s добавьте новую запись, введя %(add_to_buku)s, %(buku)s, и скопированную ссылку букмарклета в соответствующие поля)."
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:66
msgid "Settings > Search engine > Manage… > Site Search"
msgstr "Настройки > Поисковая система > Управление… > Поиск по сайту"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:70
msgid "usage:"
msgstr "применение:"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:72
#, python-format
msgid "By hitting %(hotkey)s (thus switching to Location Bar), then typing %(buku)s and hitting %(enter)s, you'll be able to open the bookmarklet dialog via keyboard only."
msgstr "Нажав %(hotkey)s (и таким образом перейдя в адресную строку), введя туда %(buku)s и нажав %(enter)s, вы можете открыть окно букмарклета используя только клавиатуру."
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:75
#, python-format
msgid "Note: in Firefox this changes displayed URL, but you can reset it by switching back to Location Bar and hitting %(escape)s twice."
msgstr "Замечание: Firefox при этом меняет отображаемую ссылку; её можно восстановить перейдя обратно в адресную строку и нажав дважды %(escape)s."
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/home.html:89
msgid "FULL"
msgstr "ПОЛНОЕ"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:64
msgid "Collect missing data (+extra tags) by fetching & parsing the webpage"
msgstr "Собрать недостающие данные (+дополнительные теги) из скачанной страницы"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:67
msgid "Fetch"
msgstr "Взять со страницы"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:97
msgctxt "tags"
msgid "name"
msgstr "тег"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:97
msgctxt "tags"
msgid "usage count"
msgstr "число закладок"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:98
msgctxt "bookmarks"
msgid "index"
msgstr "номер"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:98
msgctxt "bookmarks"
msgid "url"
msgstr "ссылка"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:98
msgctxt "bookmarks"
msgid "title"
msgstr "название"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:99
msgctxt "bookmarks"
msgid "tags"
msgstr "теги"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:99
msgctxt "bookmarks"
msgid "order"
msgstr "порядок"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:101
msgid "Delete record"
msgstr "Удалить запись"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:131
msgid "custom"
msgstr "другой"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:132
msgid "Set custom page size (empty for default)"
msgstr "Введите новый размер страницы (пустой = по умолчанию)"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/lib.html:138
msgid "Invalid page size"
msgstr "Некорректный размер страницы"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:9
#, python-brace-format
msgid "{} days ago"
msgstr "{} дней назад"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:9
#, python-brace-format
msgid "{} hours ago"
msgstr "{} часов назад"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:9
#, python-brace-format
msgid "{} minutes ago"
msgstr "{} минут назад"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:9
#, python-brace-format
msgid "{} seconds ago"
msgstr "{} секунд назад"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:13
msgid "just now"
msgstr "только что"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:22
msgid "Data created"
msgstr "Данные созданы"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:24
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/tags_list.html:13
msgid "Refresh"
msgstr "Обновить"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:26
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:44
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:77
msgid "Netloc"
msgstr "Сайт"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:37
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:110
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:183
msgid "View all"
msgstr "Все"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:43
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:76
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:116
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:151
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:189
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:224
msgid "Rank"
msgstr "Ранг"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:45
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:78
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:118
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:153
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:191
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:226
msgid "Number"
msgstr "Количество"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:52
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:86
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:258
msgid "(no netloc)"
msgstr "(без сайта)"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:61
msgid "No bookmarks found."
msgstr "В базе данных нет закладок."
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:70
msgid "Netloc ranking"
msgstr "Рейтинг сайтов"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:99
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:117
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:152
msgid "Tag"
msgstr "Тег"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:136
msgid "No tags found."
msgstr "В базе данных нет тегов."
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:145
msgid "Tag ranking"
msgstr "Рейтинг тегов"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:172
msgid "Title (common)"
msgstr "Названия (повторяющиеся)"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:199
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:234
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:327
msgid "(no title)"
msgstr "(без названия)"
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:209
msgid "No common titles found."
msgstr "В базе данных нет закладок с повторяющимися названиями."
#: /home/lex/Work/buku/bukuserver/templates/bukuserver/statistic.html:218
msgid "Common titles ranking"
msgstr "Рейтинг повторяющихся названий"
msgid "Original and replacement tags are the same."
msgstr "Для замены тега его имя нужно изменить."
msgid "Tag name cannot be blank."
msgstr "Имя тега не может быть пустым."
msgid "by index"
msgstr "по номеру"
msgid "by url"
msgstr "по ссылке"
msgid "by netloc"
msgstr "по сайту"
msgid "by title"
msgstr "по названию"
msgid "by description"
msgstr "по описанию"
msgid "by tags"
msgstr "по тегам"
msgid "with tag first"
msgstr "сначала с тегом"
msgid "with tag last"
msgstr "сначала без тега"
msgid "search"
msgstr "поиск"
msgid "search regex"
msgstr "поиск по регулярному выражению"
msgid "search deep"
msgstr "глубокий поиск"
msgid "search match all"
msgstr "поиск всех совпадений"
msgid "search match all, deep"
msgstr "поиск всех совпадений, глубокий"
msgid "search markers"
msgstr "поиск с маркерами"
msgid "search markers, regex"
msgstr "поиск с маркерами, по регулярному выражению"
msgid "search markers, deep"
msgstr "поиск с маркерами, глубокий"
msgid "search markers, match all"
msgstr "поиск с маркерами, всех совпадений"
msgid "search markers, match all, deep"
msgstr "поиск с маркерами, всех совпадений, глубокий"
================================================
FILE: bukuserver/util.py
================================================
from collections import Counter
from urllib.parse import urlparse
import re
get_netloc = lambda x: urlparse(x).netloc # pylint: disable=no-member
select_filters = lambda args: {k: v for k, v in args.items() if re.match(r'flt.*_', k)}
JINJA_FILTERS = {'flt': select_filters, 'netloc': get_netloc}
def chunks(arr, n):
n = max(1, n)
return [arr[i : i + n] for i in range(0, len(arr), n)]
def sorted_counter(keys, *, min_count=0):
data = Counter(keys)
return Counter({k: v for k, v in sorted(data.items()) if v > min_count})
================================================
FILE: bukuserver/views.py
================================================
"""views module."""
import functools
import itertools
import logging
import random
import re
import json
import types
from argparse import Namespace
from collections import Counter, namedtuple
from typing import Any, List, Optional, Tuple
from urllib.parse import urlparse
import arrow
import wtforms
from jinja2 import pass_context
from flask import current_app as app, flash, redirect, request, session, url_for
from flask_admin.base import AdminIndexView, BaseView, expose
from flask_admin.model import BaseModelView
from flask_wtf import FlaskForm
from markupsafe import Markup, escape
import buku
try:
from . import filters as bs_filters
from . import forms, _, _l, _lp
from .filters import BookmarkField, FilterType
from .util import chunks, sorted_counter
except ImportError:
from bukuserver import filters as bs_filters # type: ignore
from bukuserver import forms, _, _l, _lp
from bukuserver.filters import BookmarkField, FilterType # type: ignore
from bukuserver.util import chunks, sorted_counter
COLORS = ['#F7464A', '#46BFBD', '#FDB45C', '#FEDCBA', '#ABCDEF', '#DDDDDD',
'#ABCABC', '#4169E1', '#C71585', '#FF4500', '#FEDCBA', '#46BFBD']
DEFAULT_URL_RENDER_MODE = 'full'
DEFAULT_PER_PAGE = 10
LOG = logging.getLogger('bukuserver.views')
class CustomAdminIndexView(AdminIndexView):
@expose("/")
def index(self):
return self.render("bukuserver/home.html", form=forms.HomeForm())
@expose('/', methods=['POST'])
def search(self):
"redirect to bookmark search"
form = forms.HomeForm()
regex, markers = form.regex.data, form.markers.data
deep, all_keywords = (x and not regex for x in [form.deep.data, form.all_keywords.data])
flt = bs_filters.BookmarkBukuFilter(deep=deep, regex=regex, markers=markers, all_keywords=all_keywords)
vals = ([('', form.keyword.data)] if not markers else enumerate(buku.split_by_marker(form.keyword.data)))
url = url_for('bookmark.index_view', **{filter_key(flt, idx): val for idx, val in vals})
return redirect(url)
def last_page(self):
"""Generic '/last_page' endpoint handler; based on
https://github.com/flask-admin/flask-admin/blob/v1.6.0/flask_admin/model/base.py#L1956-L1969 """
# Grab parameters from URL
view_args = self._get_list_extra_args()
# Map column index to column name
sort_column = self._get_column_by_idx(view_args.sort)
if sort_column is not None:
sort_column = sort_column[0]
# Get page size
page_size = view_args.page_size or self.page_size
# Get count and data
count, data = self.get_list(-1, sort_column, view_args.sort_desc,
view_args.search, view_args.filters, page_size=page_size)
args = request.args.copy()
args.setlist('page', [max(0, (count - 1) // page_size)])
return redirect(url_for('.index_view', **args))
def app_param(key, default=None):
return app.config.get(f'BUKUSERVER_{key}', default)
def readonly_check(self):
if app_param('READONLY'):
self.can_create = False
self.can_edit = False
self.can_delete = False
class ApplyFiltersMixin: # pylint: disable=too-few-public-methods
def _apply_filters(self, models, filters):
for idx, name, value in filters:
if self._filters:
flt = self._filters[idx]
models = list(flt.apply(models, flt.clean(value)))
return models
class BookmarkModelView(BaseModelView, ApplyFiltersMixin):
@staticmethod
def _filter_arg(flt):
"""Exposes filter slugify logic; works because BookmarkModelView.named_filter_urls = True"""
return BaseModelView.get_filter_arg(BookmarkModelView, None, flt)
def _saved(self, id, url, ok=True):
if id and ok:
session['saved'] = id
else:
raise ValueError(_('Duplicate URL') if self.model.bukudb.get_rec_id(url) not in [id, None] else
_('Rejected by the database'))
def _create_ajax_loader(self, name, options):
pass
def _list_entry(self, context: Any, model: Namespace, name: str) -> Markup:
LOG.debug("context: %s, name: %s", context, name)
parsed_url = urlparse(model.url)
netloc = buku.get_netloc(model.url) or ''
get_index_view_url = functools.partial(url_for, "bookmark.index_view")
res = []
if netloc and not app_param('DISABLE_FAVICON'):
res += [f' ']
title = model.title or _('')
new_tab = app_param('OPEN_IN_NEW_TAB')
url_for_index_view_netloc = None
if netloc:
url_for_index_view_netloc = get_index_view_url(flt0_url_netloc_match=netloc)
_title = (escape(title) if self.url_render_mode == 'full' and (not netloc or not parsed_url.scheme) else
link(title, model.url, new_tab=new_tab))
res += [f'{_title}']
if self.url_render_mode == 'netloc' and url_for_index_view_netloc:
res += [f' ({link(netloc, url_for_index_view_netloc)})']
if not parsed_url.scheme:
res += [f'{escape(model.url)}']
elif self.url_render_mode == 'full':
res += [f'{link(model.url, model.url, new_tab=new_tab)}']
tag_links = []
if netloc and self.url_render_mode != 'netloc' and url_for_index_view_netloc:
tag_links += [link(f'netloc:{netloc}', url_for_index_view_netloc, badge='success')]
for tag in filter(None, model.tags.split(',')):
tag_links += [link(tag, get_index_view_url(flt0_tags_contain=tag.strip()), badge='secondary')]
res += [f'
{"".join(tag_links)}
']
description = model.description and f'
{escape(model.description)}
'
if description:
res += [description]
return Markup("".join(res))
@pass_context
def get_detail_value(self, context, model, name):
value = super().get_detail_value(context, model, name)
if name == 'tags':
tags = (link(s.strip(), url_for('bookmark.index_view', flt0_tags_contain=s.strip()), badge='secondary')
for s in (value or '').split(',') if s.strip())
return Markup(f'
{"".join(tags)}
')
if name == 'url':
res, netloc, scheme = [], buku.get_netloc(value), urlparse(value).scheme
if netloc and not app_param('DISABLE_FAVICON', False):
icon = f''
res += [link(icon, url_for('bookmark.index_view', flt0_url_netloc_match=netloc), html=True)]
elif netloc:
badge = f'netloc:{escape(netloc)}'
res += [link(badge, url_for('bookmark.index_view', flt0_url_netloc_match=netloc), html=True, badge='success')]
res += [escape(value) if not scheme else link(value, value, new_tab=app_param('OPEN_IN_NEW_TAB'))]
return Markup(f'
{" ".join(res)}
')
return Markup(f'
{escape(value)}
')
can_set_page_size = True
can_view_details = True
column_filters = ['buku', 'id', 'url', 'title', 'tags', 'order']
column_list = ['entry']
column_labels = {'entry': _l('Entry'), 'id': _l('Index'), 'url': _l('URL'),
'title': _l('Title'), 'tags': _l('Tags'), 'description': _l('Description')}
column_formatters = {'entry': _list_entry}
list_template = 'bukuserver/bookmarks_list.html'
create_modal = True
create_modal_template = "bukuserver/bookmark_create_modal.html"
create_template = "bukuserver/bookmark_create.html"
details_modal = True
details_modal_template = 'bukuserver/bookmark_details_modal.html'
details_template = 'bukuserver/bookmark_details.html'
edit_modal = True
edit_modal_template = "bukuserver/bookmark_edit_modal.html"
edit_template = "bukuserver/bookmark_edit.html"
named_filter_urls = True
extra_css = ['/static/bukuserver/css/' + it for it in ('bookmark.css', 'modal.css', 'list.css')]
extra_js = ['/static/bukuserver/js/' + it for it in ('last_page.js', 'filters_fix.js')]
last_page = expose('/last-page')(last_page)
def __init__(self, bukudb: buku.BukuDb, *args, **kwargs):
readonly_check(self)
self.bukudb = bukudb
custom_model = types.SimpleNamespace(bukudb=bukudb, __name__='bookmark')
super().__init__(custom_model, *args, **kwargs)
@property
def url_render_mode(self):
return app_param('URL_RENDER_MODE', DEFAULT_URL_RENDER_MODE)
@property
def page_size(self):
return app_param('PER_PAGE', DEFAULT_PER_PAGE)
@property
def page_size_options(self):
return tuple(sorted(set([self.page_size] + list(super().page_size_options))))
def get_safe_page_size(self, page_size): # un-enforcing the restriction
return (page_size if self.can_set_page_size and page_size > 0 else self.page_size)
def create_form(self, obj=None):
form = super().create_form(obj)
if not form.data.get('csrf_token'): # don't override POST data with URL arguments
form.url.data = request.args.get('link', form.url.data)
form.title.data = request.args.get('title', form.title.data)
form.description.data = request.args.get('description', form.description.data)
form.tags.data = request.args.get('tags', form.tags.data)
form.fetch.data = request.args.get('fetch', request.form.get('fetch', app_param('AUTOFETCH', True)))
return form
def create_model(self, form):
try:
model = types.SimpleNamespace(id=None, url=None, title=None, tags=None, description=None, fetch=None)
form.populate_obj(model)
vars(model).pop("id")
self._on_model_change(form, model, True)
if not model.url:
raise ValueError(_('url invalid: %(url)s', url=model.url))
kwargs = {'url': model.url, 'fetch': model.fetch}
if model.tags.strip():
kwargs["tags_in"] = buku.parse_tags([model.tags])
for key, item in (("title_in", model.title), ("desc", model.description)):
if item.strip():
kwargs[key] = item
vars(model)['id'] = self.model.bukudb.add_rec(**kwargs)
self._saved(model.id, model.url)
except Exception as ex:
if not self.handle_view_exception(ex):
msg = _('Failed to create record.')
flash('%(msg)s %(error)s' % {'msg': msg, 'error': _(str(ex))}, 'error')
LOG.exception(msg)
return False
self.after_model_change(form, model, True)
return model
def delete_model(self, model):
try:
self.on_model_delete(model)
res = self.bukudb.delete_rec(model.id, retain_order=True)
except Exception as ex:
if not self.handle_view_exception(ex):
msg = _('Failed to delete record.')
flash('%(msg)s %(error)s' % {'msg': msg, 'error': _(str(ex))}, 'error')
LOG.exception(msg)
return False
self.after_model_delete(model)
return res
def _from_filters(self, filters):
bukudb = self.bukudb
order = bs_filters.BookmarkOrderFilter.value(self._filters, filters)
buku_filters = [x for x in filters if x[1] == 'buku']
if buku_filters:
keywords = [x[2] for x in buku_filters]
mode_id = {x[0] for x in buku_filters}
if len(mode_id) > 1:
flash(_('Invalid search mode combination'), 'error')
return 0, []
try:
kwargs = self._filters[mode_id.pop()].params
except IndexError:
kwargs = {}
bookmarks = bukudb.searchdb(keywords, order=order, **kwargs)
else:
bookmarks = bukudb.get_rec_all(order=order)
return self._apply_filters(bookmarks or [], filters)
@expose('/reorder', methods=['POST'])
def refresh(self):
filters = json.loads(request.form['filters'])
order = bs_filters.BookmarkOrderFilter.value(self._filters, filters)
self.bukudb.reorder(order)
flash(_('Reordered bookmarks in DB'), 'success')
return redirect(url_for('.index_view'))
def get_list(self, page, sort_field, sort_desc, _, filters, page_size=None):
bookmarks = self._from_filters(filters)
count = len(bookmarks)
bookmarks = page_of(bookmarks, page_size, page)
data = []
for bookmark in bookmarks:
bm_sns = types.SimpleNamespace(id=None, url=None, title=None, tags=None, description=None)
for field in list(BookmarkField):
setattr(bm_sns, field.name.lower(), format_value(field, bookmark))
data.append(bm_sns)
return count, data
def get_one(self, id):
if id == 'random':
bookmarks = self._from_filters(self._get_list_filter_args())
bookmark = bookmarks and random.choice(bookmarks)
else:
bookmark = self.model.bukudb.get_rec_by_id(id)
if not bookmark:
return None
bm_sns = types.SimpleNamespace(id=None, url=None, title=None, tags=None, description=None)
for field in list(BookmarkField):
setattr(bm_sns, field.name.lower(), format_value(field, bookmark, spacing=' '))
session['netloc'] = buku.get_netloc(bookmark.url) or ''
return bm_sns
def get_pk_value(self, model):
return model.id
@expose('/swap', methods=['POST'])
def swap(self):
form = forms.SwapForm()
self.bukudb.swap_recs(form.id1.data, form.id2.data)
return redirect(request.form.get('url', url_for('bookmark.index_view')))
def scaffold_list_columns(self):
return [x.name.lower() for x in BookmarkField]
def scaffold_list_form(self, widget=None, validators=None):
pass
def scaffold_sortable_columns(self):
"""Returns a dictionary of sortable columns.
from flask-admin docs:
`If your backend does not support sorting, return None or an empty dictionary.`
"""
return {}
def scaffold_filters(self, name):
res = []
if name == 'buku':
values_combi = sorted(itertools.product([True, False], repeat=4))
for markers, all_keywords, deep, regex in values_combi:
kwargs = {'markers': markers, 'all_keywords': all_keywords, 'deep': deep, 'regex': regex}
if not (regex and (deep or all_keywords)):
res += [bs_filters.BookmarkBukuFilter(**kwargs)]
elif name == 'order':
res += [bs_filters.BookmarkOrderFilter(field)
for field in bs_filters.BookmarkOrderFilter.FIELDS]
elif name == BookmarkField.ID.name.lower():
res += [
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.EQUAL),
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.NOT_EQUAL),
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.IN_LIST),
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.NOT_IN_LIST),
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.GREATER),
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.SMALLER),
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.TOP_X),
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.BOTTOM_X),
]
elif name == BookmarkField.URL.name.lower():
def netloc_match_func(query, value, index):
return filter(lambda x: (buku.get_netloc(x[index]) or '') == value, query)
res += [
bs_filters.BookmarkBaseFilter(name, _l('netloc match'), netloc_match_func),
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.EQUAL),
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.NOT_EQUAL),
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.IN_LIST),
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.NOT_IN_LIST),
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.CONTAINS),
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.NOT_CONTAINS),
]
elif name == BookmarkField.TITLE.name.lower():
res += [
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.EQUAL),
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.NOT_EQUAL),
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.IN_LIST),
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.NOT_IN_LIST),
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.CONTAINS),
bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.NOT_CONTAINS),
]
elif name == BookmarkField.TAGS.name.lower():
def get_list_from_buku_tags(item):
return [x.strip() for x in item.split(",")]
def tags_contain_func(query, value, index):
for item in query:
if value in get_list_from_buku_tags(item[index]):
yield item
def tags_not_contain_func(query, value, index):
for item in query:
if value not in get_list_from_buku_tags(item[index]):
yield item
res += [
bs_filters.BookmarkBaseFilter(name, _l('contain'), tags_contain_func),
bs_filters.BookmarkBaseFilter(name, _l('not contain'), tags_not_contain_func),
bs_filters.BookmarkTagNumberEqualFilter(name, _l('number equal')),
bs_filters.BookmarkTagNumberNotEqualFilter(name, _l('number not equal')),
bs_filters.BookmarkTagNumberGreaterFilter(name, _l('number greater than')),
bs_filters.BookmarkTagNumberSmallerFilter(name, _l('number smaller than')),
]
elif name in self.scaffold_list_columns():
pass
else:
return super().scaffold_filters(name)
return res
def scaffold_form(self):
return forms.BookmarkForm
def update_model(self, form: forms.BookmarkForm, model: Namespace):
res = False
try:
form.populate_obj(model)
self._on_model_change(form, model, False)
res = self.bukudb.update_rec(
model.id,
url=model.url,
title_in=model.title,
tags_in=buku.parse_tags([model.tags]),
desc=model.description,
)
self._saved(model.id, model.url, res)
except Exception as ex:
if not self.handle_view_exception(ex):
msg = _('Failed to update record.')
flash('%(msg)s %(error)s' % {'msg': msg, 'error': _(str(ex))}, 'error')
LOG.exception(msg)
return False
self.after_model_change(form, model, False)
return res
class TagModelView(BaseModelView, ApplyFiltersMixin):
def _create_ajax_loader(self, name, options):
pass
def _name_formatter(self, context, model, name):
data = getattr(model, name)
query, title = (({'flt0_tags_contain': data}, data) if data else
({'flt0_tags_number_equal': 0}, _('')))
return Markup(link(title, url_for('bookmark.index_view', **query)))
can_create = False
can_set_page_size = True
column_filters = ['name', 'usage_count']
column_labels = {'name': _lp('tag', 'Name'), 'usage_count': _lp('tag', 'Usage Count')}
column_formatters = {'name': _name_formatter}
list_template = 'bukuserver/tags_list.html'
edit_template = "bukuserver/tag_edit.html"
named_filter_urls = True
extra_css = ['/static/bukuserver/css/list.css']
extra_js = ['/static/bukuserver/js/' + it for it in ('last_page.js', 'filters_fix.js')]
last_page = expose('/last-page')(last_page)
def _refresh(self):
app.logger.info('Refreshing tags cache')
self.refreshed, self.all_tags = arrow.now(), self.bukudb.get_tag_all()
def __init__(self, bukudb, *args, **kwargs):
readonly_check(self)
self.bukudb = bukudb
self._refresh()
custom_model = types.SimpleNamespace(bukudb=bukudb, __name__='tag')
super().__init__(custom_model, *args, **kwargs)
@property
def page_size(self):
return app_param('PER_PAGE', DEFAULT_PER_PAGE)
@property
def page_size_options(self):
return tuple(sorted(set([self.page_size] + list(super().page_size_options))))
def get_safe_page_size(self, page_size): # un-enforcing the restriction
return (page_size if self.can_set_page_size and page_size > 0 else self.page_size)
@expose('/refresh', methods=['POST'])
def refresh(self):
self._refresh()
return redirect(request.referrer or url_for('.index_view'))
def scaffold_list_columns(self):
return ["name", "usage_count"]
def scaffold_sortable_columns(self):
return {x: x for x in self.scaffold_list_columns()}
def scaffold_form(self):
class CustomForm(FlaskForm): # pylint: disable=too-few-public-methods
name = wtforms.StringField(_lp('tag', 'Name'), validators=[wtforms.validators.DataRequired()])
return CustomForm
def scaffold_list_form(self, widget=None, validators=None):
pass
def get_list(
self,
page: int,
sort_field: str,
sort_desc: bool,
search: Optional[Any],
filters: List[Tuple[int, str, str]],
page_size: int = None,
) -> Tuple[int, List[types.SimpleNamespace]]:
app.logger.debug('search: %s', search)
if (arrow.now() - self.refreshed).seconds > 60: # automatic refresh if more than a minute passed since last one
self._refresh()
else:
app.logger.debug('Tags cache refreshed %ss ago', (arrow.now() - self.refreshed).seconds)
tags = self._apply_filters(sorted(self.all_tags[1].items()), filters)
sort_field_dict = {"usage_count": 1, "name": 0}
if sort_field in sort_field_dict:
tags = sorted(tags, reverse=sort_desc, key=lambda x: x[sort_field_dict[sort_field]])
count = len(tags)
tags = page_of(tags, page_size, page)
data = []
for name, usage_count in tags:
tag_sns = types.SimpleNamespace(name=None, usage_count=None)
tag_sns.name, tag_sns.usage_count = name, usage_count
data.append(tag_sns)
return count, data
def get_pk_value(self, model):
return model.name
def get_one(self, id):
tags = self.all_tags[1]
tag_sns = types.SimpleNamespace(name=id, usage_count=tags.get(id, 0))
return tag_sns
def scaffold_filters(self, name):
res = []
def top_most_common_func(query, value, index):
counter = Counter(x[index] for x in query)
most_common = counter.most_common(value)
most_common_item = {x for x, count in most_common}
return filter((lambda x: x[index] in most_common_item), query)
res += [
bs_filters.TagBaseFilter(name, filter_type=FilterType.EQUAL),
bs_filters.TagBaseFilter(name, filter_type=FilterType.NOT_EQUAL),
bs_filters.TagBaseFilter(name, filter_type=FilterType.IN_LIST),
bs_filters.TagBaseFilter(name, filter_type=FilterType.NOT_IN_LIST),
]
if name == "usage_count":
res += [
bs_filters.TagBaseFilter(name, filter_type=FilterType.GREATER),
bs_filters.TagBaseFilter(name, filter_type=FilterType.SMALLER),
bs_filters.TagBaseFilter(name, filter_type=FilterType.TOP_X),
bs_filters.TagBaseFilter(name, filter_type=FilterType.BOTTOM_X),
bs_filters.TagBaseFilter(name, _l('top most common'), top_most_common_func),
]
elif name == "name":
res += [
bs_filters.TagBaseFilter(name, filter_type=FilterType.CONTAINS),
bs_filters.TagBaseFilter(name, filter_type=FilterType.NOT_CONTAINS),
]
else:
return super().scaffold_filters(name)
return res
def delete_model(self, model):
res = None
try:
self.on_model_delete(model)
res = self.bukudb.delete_tag_at_index(0, model.name, chatty=False)
self._refresh()
except Exception as ex:
if not self.handle_view_exception(ex):
msg = _('Failed to delete record.')
flash('%(msg)s %(error)s' % {'msg': msg, 'error': _(str(ex))}, 'error')
LOG.exception(msg)
return False
self.after_model_delete(model)
return res
def update_model(self, form, model):
try:
original_name = model.name
form.populate_obj(model)
self._on_model_change(form, model, False)
names = {s for s in re.split(r'\s*,\s*', model.name.lower().strip()) if s}
assert names, 'Tag name cannot be blank.' # deleting a tag should be done via a Delete button
self.bukudb.replace_tag(original_name, names)
self._refresh()
except Exception as ex:
if not self.handle_view_exception(ex):
msg = _('Failed to update record.')
flash('%(msg)s %(error)s' % {'msg': msg, 'error': _(str(ex))}, 'error')
LOG.exception(msg)
return False
self.after_model_change(form, model, False)
return True
def create_model(self, form):
pass
class StatisticView(BaseView): # pylint: disable=too-few-public-methods
_data = None
extra_css = ['/static/bukuserver/css/modal.css']
def __init__(self, bukudb, *args, **kwargs):
self.bukudb = bukudb
super().__init__(*args, **kwargs)
@expose("/", methods=("GET", "POST"))
def index(self):
data = StatisticView._data
if not data or request.method == 'POST':
all_bookmarks = self.bukudb.get_rec_all()
netlocs = [buku.get_netloc(x.url) or '' for x in all_bookmarks]
tags = [s for x in all_bookmarks for s in x.taglist]
titles = [x.title for x in all_bookmarks]
data = StatisticView._data = {
'netlocs': sorted_counter(netlocs),
'tags': sorted_counter(tags),
'titles': sorted_counter(titles, min_count=1),
'generated': arrow.now(),
}
datetime = data['generated']
return self.render(
'bukuserver/statistic.html',
netlocs=CountedData(data['netlocs']),
tags=CountedData(data['tags']),
titles=CountedData(data['titles']),
datetime=datetime,
datetime_text=datetime.humanize(arrow.now(), granularity='second'),
)
def page_of(items, size, idx):
try:
return chunks(items, size)[idx] if size and items else items
except IndexError:
return []
def filter_key(flt, idx=''):
if isinstance(idx, int) and idx > 9:
idx = (chr(ord('A') + idx-10) if idx < 36 else chr(ord('a') + idx-36))
return 'flt' + str(idx) + '_' + BookmarkModelView._filter_arg(flt)
def format_value(field, bookmark, spacing=''):
s = bookmark[field.value]
return s if field != BookmarkField.TAGS else (s or '').strip(',').replace(',', ','+spacing)
def link(text, url, new_tab=False, html=False, badge=''):
target = ('' if not new_tab else ' target="_blank"')
cls = ('' if not badge else f' class="btn badge badge-{badge}"')
return f'{text if html else escape(text)}'
ColoredData = namedtuple('ColoredData', 'name amount color')
class CountedData(list):
def __init__(self, counter):
self._counter = Counter(counter)
data = self._counter.most_common(len(COLORS))
self += [ColoredData(name, amount, color) for (name, amount), color in zip(data, COLORS)]
@property
def cropped(self):
return len(self) < len(self._counter)
@property
def all(self):
return self._counter.most_common()
================================================
FILE: bukuserver-runner/README.md
================================================
# Bukuserver runner
This tool can be used to run and restart Bukuserver, switching databases between runs. It has no third-party dependencies, allowing to run Bukuserver sandboxed in a virtualenv as easily as the system-wide install (which is especifally useful for development).
I suggest installing/symlinking it system-wide (e.g. as an `/usr/local/bin/buku-server` executable). Either of the `*.desktop` files can be edited according to match your setup and installed in your `local/share/applications/` folder for access from system menu.
On Windows, you can create a shortcut file pointing to any Python executable (`python.exe` for windowed mode, `pythonw.exe` for headless) with added CLI arguments: path to `buku-server.py` followed by `--stop-if-running`.
Note that windowed mode may be necessary if you want to see Bukuserver logs, or use noGUI mode (see below). The terminal window can be minimized to tray when not in use, by a program like [KDocker](https://github.com/user-none/KDocker), [RBTray](https://github.com/benbuck/rbtray) or [SmartSystemMenu](https://github.com/AlexanderPro/SmartSystemMenu).
## Usage
When running `buku-server.py` without arguments, it will prompt for database file, then start the Bukuserver. These actions will be repeated once Bukuserver stops running (e.g. after hitting `Ctrl+C`). The script will quit if you cancel the prompt.
In GUI mode, the prompt is implemented as 2 dialogs; a list of databases to choose from, and a text input for creating a new DB. In the shell mode, you can type in DB number from the list, or a new DB name. Note that DB names must be valid filenames in your system (sans the `.db` extension). These files are located in your Buku settings folder (along with the default `bookmarks.db` file).
Running `buku-server.py --stop` will kill the currently running Bukuserver process (thus allowing to restart it in the background, like a daemon). `buku-server.py --stop-if-running` will either start the script or kill Bukuserver if it's running already.
Screenshots

_DB selection dialog – shown on startup (unless no DB files were found); initially the previous DB is selected_

_DB creation dialog – shown if no DB was selected (or none found)_

_A confirmation dialog is shown if new DB name is taken already_

_DB name must be a valid filename, sans the `.db` extension (invalid chars: `/` on Linux, or any of `<>:"/\|?*` on Windows)_

_DB selection prompt in console shell/no-GUI mode (`BUKU_NOGUI=y`)_
## Environment variables
The script behaviour can be configured by setting the following environment variables:
* `BUKUSERVER` specifies path to your Bukuserver executable or Buku source directory.
* `BUKU_DEVMODE` – if not empty, Bukuserver will be run in development mode. Normally used with source directory in `BUKUSERVER`.
* `BUKU_VENV` overrides path to your virtualenv sandbox (default depends on whether `BUKU_DEVMODE` is set):
- when devmode is off, the virtualenv location defaults to a `venv/` folder in your Buku settings directory;
- when devmode is on, the virtualenv location defaults to a `venv/` folder in the source directory.
* `BUKU_NOGUI` – if not empty, fallback shell prompt will be used (also happens if Tkinter is not present in your Python installation).
* `BUKU_DEFAULT_DBDIR` – specify directory for DB selection (same as Buku itself).
Default values for all of these (as well as for `BUKUSERVER_` options) can be specified in a `bukuserver.env` file in your Buku settings folder:
```sh
# ~/.local/share/buku/bukuserver.env
BUKUSERVER='~/Sources/buku/' # when running from sources
BUKUSERVER_THEME=slate
BUKUSERVER_DISABLE_FAVICON=false
BUKUSERVER_OPEN_IN_NEW_TAB=true
```
================================================
FILE: bukuserver-runner/buku-server-headless.desktop
================================================
# This file can be used as a windowless startup/restart shortcut on Linux desktop/menu (after editing the Exec= command line)
[Desktop Entry]
Icon=bookmark-add
Name=Bukuserver (headless)
GenericName=Bookmaks manager
Comment=WebUI for the buku bookmarks manager
Categories=Utility;Network;
Exec={BUKU_SERVER} --stop-if-running
#Exec=/usr/local/bin/buku-server --stop-if-running
================================================
FILE: bukuserver-runner/buku-server.desktop
================================================
# This file can be used as a windowed startup/restart shortcut on Linux desktop/menu (after editing the Exec= command line)
[Desktop Entry]
Icon=bookmark-add
Name=Bukuserver
GenericName=Bookmaks manager
Comment=WebUI for the buku bookmarks manager
Categories=Utility;Network;
Exec={TERMINAL} -e '{BUKU_SERVER} --stop-if-running'
#Exec=xfce4-terminal -e '/usr/local/bin/buku-server --stop-if-running'
================================================
FILE: bukuserver-runner/buku-server.py
================================================
#!/usr/bin/env python
# Usage: `buku-server.py` starts up the server, `buku-server.py --stop` sends TERM to the already running server
# `buku-server.py --stop-if-running` will either start the script or kill Bukuserver if it's running already
from signal import signal, SIGINT
from contextlib import contextmanager
from os import environ as env
import sys
import os
import re
import shlex
import csv
import venv
import subprocess
TITLE = re.sub(r'\.py$', '', os.path.basename(__file__))
IS_WINDOWS = sys.platform == 'win32'
is_path = lambda s: ('/' in s or os.sep in s or s in ('.', '..'))
in_venv = lambda virtualenv, name: os.path.join(virtualenv, ('Scripts' if IS_WINDOWS else 'bin'), name)
set_title = lambda s: (print(f'\033]2;{s}\007', end='') if not IS_WINDOWS else run(f'title {s}', shell=True))
unexpand_user = lambda s: re.sub(r'^' + re.escape(os.path.expanduser('~')), '~', s)
try:
from tkinter.messagebox import showerror, askyesno
from tkinter.simpledialog import askstring, Dialog
from tkinter import ttk
import tkinter as tk
class QueryList(Dialog):
def __init__(self, title, prompt, values, initial=None, parent=None):
self._prompt, self._vals, self._initial = prompt, values, (initial if initial in values else values[0])
super().__init__(parent, title)
def body(self, master):
w = ttk.Label(master, text=self._prompt, justify=tk.LEFT)
w.grid(row=0, padx=5, sticky=tk.W)
self._list = ttk.Treeview(master, show='tree')
self._list.grid(row=1, padx=5, sticky=tk.W+tk.E)
scroll = ttk.Scrollbar(master)
scroll.grid(row=1, padx=5, sticky=tk.E+tk.N+tk.S)
self._list.config(yscrollcommand=scroll.set)
scroll.config(command=self._list.yview)
self._keys = [self._list.insert('', 'end', text=s) for s in self._vals]
self.select(self._vals.index(self._initial))
self._list.bind('', self.onkeypress)
self._list.bind('', lambda _: self.after('idle', self.ok))
return self._list
def select(self, index):
self._list.see(self._keys[index])
self._list.focus(self._keys[index])
self._list.selection_set(self._keys[index])
def onkeypress(self, evt):
match = [i for i, s in enumerate(self._vals) if s.startswith(evt.char)]
if evt.char and match:
cur = self._list.index(self._list.focus())
self.select(cur+1 if self._vals[cur].startswith(evt.char) and cur+1 in match else match[0])
def validate(self):
self.result = self._list.item(self._list.focus())['text']
return True
asklist = lambda title, prompt, values, initial=None: QueryList(title, prompt, values, initial=initial).result
GUI = True
except ImportError:
GUI = False
print('Failed to initialize GUI', file=sys.stderr)
def is_valid_filepath(path):
try:
os.lstat(path)
except FileNotFoundError:
pass
except Exception:
return False
if os.path.isdir(path) or path.endswith(os.path.sep):
return False # directory
drive, s = os.path.splitdrive(path)
return not IS_WINDOWS or not re.search(r'[<>:"|?*]', s)
@contextmanager
def ignore_interrupt(): # temporarily disables raising KeyboardInterrupt on Ctrl+C
old_handler = signal(SIGINT, lambda sig, frame: None)
yield
signal(SIGINT, old_handler)
def run(command, *, shell=IS_WINDOWS, check=True):
try:
command = (os.path.expandvars(command) if isinstance(command, str) else [os.path.expandvars(s) for s in command])
with ignore_interrupt():
return subprocess.run(command, shell=shell, check=check)
except Exception as e:
print(e, file=sys.stderr)
sys.exit(1)
def parse_csv(text):
text = (text.decode() if isinstance(text, bytes) else str(text)).strip()
keys, lines = None, re.split(r'[\r\n]+', text)
for row in csv.reader(lines):
if not keys:
keys = row
else:
yield dict(zip(keys, row))
def find_process(query, regex):
if IS_WINDOWS:
query = str(query or "name LIKE '%'")
command = ['wmic', 'process', 'where', query, 'get', 'commandline,processid', '/format:csv']
output = subprocess.check_output(command, shell=True)
for process in parse_csv(output):
if re.search(regex, process['CommandLine']):
return int(process['ProcessId'])
else:
command = 'ps x -o pid,cmd | awk ' + shlex.quote(f'$2 ~ /{query or ".*"}/')
output = subprocess.check_output(command, shell=True)
for line in output.decode().splitlines():
pid, cmdline = line.lstrip().split(' ', 1)
if re.search(regex, cmdline):
return pid
return None # nothing found
def find_bukuserver_process():
if not IS_WINDOWS:
return find_process(r'(^|\/)python[.0-9]*$', r'\b(bukuserver(/server\.py)?) run$')
return find_process('name like "python%.exe"', r'\b(bukuserver-script\.py|bukuserver([/\\]server\.py)?)"? run$')
def kill_process(pid):
run(['kill', str(pid)] if not IS_WINDOWS else ['taskkill', '/F', '/pid', str(pid)])
def get_buku_config_dir():
path = (env.get('APPDATA') if IS_WINDOWS else
env.get('XDG_DATA_HOME') or os.path.join(os.path.expanduser('~'), '.local', 'share'))
return os.path.abspath(os.path.join(path, 'buku'))
def read_env_file(path):
regex = re.compile('([_A-Z]+)=(?:"(.*)"|\'(.*)\'|(.*))')
try:
with open(path, encoding='utf-8') as fin:
for line in fin:
tokens = shlex.split(line, comments=True)
if tokens and (m := regex.fullmatch(tokens[0])):
yield (m[1], m[2] or m[3] or m[4])
except FileNotFoundError:
pass
def selectdb(dbdir, old=None, gui=GUI, title=TITLE):
default = dbdir == get_buku_config_dir()
old = old and old.removeprefix(dbdir+os.path.sep).removesuffix('.db')
if old and any(c in old for c in ['/', os.path.sep]):
old = None
if os.path.isdir(dbdir):
dbs = sorted(s[:-3] for s in os.listdir(dbdir) if s.lower().endswith('.db'))
else:
dbs = []
if gui:
_title = title + ('' if default else f' [{unexpand_user(dbdir)}]')
db = (None if not dbs else
asklist(_title, f'{"Choose DB (or click Cancel to create new DB)":80}', dbs, initial=old or 'bookmarks'))
while not db:
db = askstring(_title, f'{"Create new DB?":90}', initialvalue=old or 'bookmarks')
if db is None:
print('No name given, qutting', file=sys.stderr)
return None
dbfile = os.path.join(dbdir, db+'.db')
if not db or any(c in db for c in ['/', os.path.sep]) or not is_valid_filepath(dbfile):
showerror(_title, f'Invalid DB name: "{db}"')
db = None
elif os.path.exists(dbfile):
if not askyesno(_title, f'"{db}" exists already. Open anyway?'):
db = None
else:
db, _title = None, ('' if default else f' [{unexpand_user(dbdir)}]')
while not db:
try:
print(f'\nType DB name or index (0 to quit){_title}:')
for idx, name in enumerate(dbs, start=1):
print(f'{idx}. {name}')
try:
db = input('> ' if not old else f'> [{old}] ').strip() or old or 'bookmarks'
except EOFError as e:
raise KeyboardInterrupt from e
except KeyboardInterrupt:
with ignore_interrupt():
print()
print('Input cancelled', file=sys.stderr)
return None
try:
idx = int(db)
if idx == 0:
print('Entered "0", quitting', file=sys.stderr)
return None
if idx > 0:
db = dbs[idx-1]
break
except IndexError:
print('No such index!', file=sys.stderr)
db = None
continue
except ValueError:
pass # not an index
dbfile = os.path.join(dbdir, db+'.db')
if not db or any(c in db for c in ['/', os.path.sep]) or not is_valid_filepath(dbfile):
print(f'Invalid DB name: "{db}"', file=sys.stderr)
db = None
elif not os.path.exists(dbfile):
if input(f'"{db}" does not exist yet. Create? [Y/n] ').upper().strip() == 'N':
db = None
return db and os.path.join(dbdir, db+'.db')
def load_virtualenv(virtualenv, devmode=False, reinstall=False):
print(f'Using {os.path.abspath(virtualenv)}')
venv.create(virtualenv, with_pip=True, prompt='buku')
run([in_venv(virtualenv, 'python'), '-m', 'pip', 'install', '--upgrade', 'pip'])
if reinstall:
env.get('BUKUSERVER_LOCALE') and run([in_venv(virtualenv, 'pip'), 'install', 'flask-babel'])
if not devmode:
run([in_venv(virtualenv, 'pip'), 'install', '.[server]'])
else:
run([in_venv(virtualenv, 'pip'), 'install', '--editable', '.[server]'])
def prepare_vars():
confdir = get_buku_config_dir()
for name, value in read_env_file(os.path.join(confdir, 'bukuserver.env')):
print(f'default:{name}={shlex.quote(value)}')
env.setdefault(name, value)
workdir, exec, devmode = None, env.get('BUKUSERVER') or '', bool(env.get('BUKU_DEVMODE'))
devmode and env.setdefault('BUKUSERVER_DEBUG', 'true')
if exec and os.path.isdir(os.path.expanduser(exec)):
workdir, exec = exec, os.path.join('bukuserver', 'server.py')
return {
'dbdir': os.path.abspath(os.path.expanduser(env.get('BUKU_DEFAULT_DBDIR') or confdir)),
'devmode': devmode,
'gui': GUI and not env.get('BUKU_NOGUI'),
'exec': exec or 'bukuserver',
'workdir': workdir,
'virtualenv': env.get('BUKU_VENV') or (workdir and os.path.join(('.' if devmode else confdir), 'venv')),
}
def run_repeatedly(dbdir, devmode=False, gui=GUI, exec=None, workdir=None, virtualenv=None):
virtualenv = virtualenv and os.path.expanduser(virtualenv)
if workdir:
os.chdir(os.path.expanduser(workdir))
elif virtualenv:
os.chdir(virtualenv)
virtualenv = '.'
set_title(TITLE + ('' if dbdir == get_buku_config_dir() else f' [{dbdir}]'))
virtualenv and load_virtualenv(virtualenv, devmode=devmode, reinstall=bool(workdir))
command = [os.path.expanduser(exec), 'run']
if exec.endswith('.py'):
command = ['python'] + command
elif exec == 'bukuserver':
command = ['python', '-m'] + command
if virtualenv:
command[0] = in_venv(virtualenv, command[0])
set_title(f'{TITLE} [{shlex.join(command)}]')
db = env.get('BUKUSERVER_DB_FILE') or os.path.join(dbdir, 'bookmarks.db')
while True:
print('Running Bukuserver…')
if not (db := selectdb(dbdir, gui=gui, old=db)):
break
env['BUKUSERVER_DB_FILE'] = db
print(f'BUKUSERVER_DB_FILE={db}')
run(command, check=False)
if __name__ == '__main__':
if (pid := find_bukuserver_process()):
if any(s in sys.argv for s in ['--stop', '--stop-if-running']):
print(f'Killing process {pid}')
kill_process(pid)
sys.exit()
else:
print('Already running bukuserver!', file=sys.stderr)
sys.exit(1)
if '--stop' in sys.argv:
print('Could not find a running bukuserver process!', file=sys.stderr)
sys.exit(1)
run_repeatedly(**prepare_vars())
================================================
FILE: docker-compose/docker-compose.yml
================================================
services:
bukuserver:
image: bukuserver/bukuserver
restart: unless-stopped
environment:
- BUKUSERVER_PER_PAGE=100
- BUKUSERVER_OPEN_IN_NEW_TAB=true
# - BUKUSERVER_SECRET_KEY=123456789012345678901234
# - BUKUSERVER_URL_RENDER_MODE=full
# - BUKUSERVER_DISABLE_FAVICON=false
ports:
- "5001:5001"
volumes:
- ./data:/root/.local/share/buku
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./data/nginx:/etc/nginx/conf.d
- ./data/basic_auth:/basic_auth
================================================
FILE: docs/source/buku.rst
================================================
buku module
===========
.. automodule:: buku
:members:
:undoc-members:
:show-inheritance:
================================================
FILE: docs/source/bukuserver.rst
================================================
bukuserver package
==================
bukuserver.filters module
-------------------------
.. automodule:: bukuserver.filters
:members:
:undoc-members:
:show-inheritance:
bukuserver.forms module
-----------------------
.. automodule:: bukuserver.forms
:members:
:undoc-members:
:show-inheritance:
bukuserver.response module
--------------------------
.. automodule:: bukuserver.response
:members:
:undoc-members:
:show-inheritance:
bukuserver.server module
------------------------
.. automodule:: bukuserver.server
:members:
:undoc-members:
:show-inheritance:
bukuserver.views module
-----------------------
.. automodule:: bukuserver.views
:members:
:undoc-members:
:show-inheritance:
================================================
FILE: docs/source/conf.py
================================================
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# buku documentation build configuration file, created by
# sphinx-quickstart on Thu Sep 7 12:54:59 2017.
#
# This file is execfile()d with the current directory set to its
# containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import sys
sys.path.insert(0, os.path.abspath('../../'))
# -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#
# needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
"myst_parser",
"sphinx.ext.autodoc",
"sphinx.ext.autosummary",
"sphinx.ext.githubpages",
"sphinx.ext.napoleon",
"sphinx.ext.viewcode",
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
source_suffix = ['.rst', '.md']
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = "buku"
copyright = "2015-2026, Arun Prakash Jana"
author = "Arun Prakash Jana"
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = ''
# The full version, including alpha/beta/rc tags.
release = ''
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This patterns also effect to html_static_path and html_extra_path
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = False
# -- Options for HTML output ----------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
# html_theme = 'alabaster'
html_theme = "sphinx_rtd_theme"
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#
# html_theme_options = {}
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
# html_static_path = ['_static']
html_static_path = []
# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
#
# This is required for the alabaster theme
# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars
html_sidebars = {
'**': [
'about.html',
'navigation.html',
'relations.html', # needs 'show_related': True theme option to display
'searchbox.html',
'donate.html',
]
}
# -- Options for HTMLHelp output ------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = 'bukudoc'
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, 'buku.tex', 'buku documentation',
'Arun Prakash Jana', 'manual'),
]
# -- Options for manual page output ---------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, 'buku', 'buku documentation',
[author], 1)
]
# -- Options for Texinfo output -------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(master_doc, 'buku', 'buku documentation',
author, 'buku', 'One line description of project.',
'Miscellaneous'),
]
================================================
FILE: docs/source/index.rst
================================================
.. buku documentation master file, created by
sphinx-quickstart on Thu Sep 7 12:54:59 2017.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
buku
====
Bookmark manager like a text-based mini-web.
.. toctree::
:maxdepth: 2
:caption: User guide
README.md
.. toctree::
:glob:
:maxdepth: 2
:caption: Wiki
wiki/*
.. toctree::
:maxdepth: 2
:caption: Buku Documentation
buku
.. toctree::
:maxdepth: 2
:caption: Bukuserver Documentation
bukuserver
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
================================================
FILE: docs/source/modules.rst
================================================
buku
==========
.. toctree::
:maxdepth: 4
buku
bukuserver
==========
.. toctree::
:maxdepth: 4
bukuserver
================================================
FILE: docs/source/tutorial_for_developer.md
================================================
# Tutorial for Developer
## get buku database
```python
>>> import buku
>>> bdb = buku.BukuDb()
```
## simplest way to add url to buku
```python
>>> rec_id = bdb.add_rec('http://example.com')
>>> rec_id
40296
```
## get record id from url
```python
>>> url = 'https://example.com'
>>> rec_id = bdb.add_rec(url)
... if rec_id == -1:
... rec_id = bdb.get_rec_id(url)
>>> rec_id
40296
```
## get url data from record id
```python
>>> rec = bdb.get_rec_by_id(40296)
>>> rec
(40296, 'http://example.com', 'Example Domain', ',', '', 0)
```
## get tag list
```python
rec[3].split(buku.delim)
```
================================================
FILE: mypy.ini
================================================
[mypy]
ignore_missing_imports = True
================================================
FILE: packagecore.yaml
================================================
name: buku-cli
maintainer: Arun Prakash Jana
license: GPLv3
summary: Bookmark manager like a text-based mini-web.
homepage: https://github.com/jarun/buku
commands:
install:
- make PREFIX="/usr" install DESTDIR="${BP_DESTDIR}"
packages:
# archlinux:
# builddeps:
# - make
# deps:
# - python
# - python-beautifulsoup4
# - python-certifi
# - python-cryptography
# - python-urllib3
# container: "archlinux/base"
# centos no beautifulsoup4
centos7.5:
builddeps:
- make
deps:
- python
# - python-beautifulsoup4
# - python-certifi
- python-cryptography
- python-urllib3
commands:
pre:
- yum install epel-release
centos7.6:
builddeps:
- make
deps:
- python
# - python-beautifulsoup4
# - python-certifi
- python-cryptography
- python-urllib3
centos7.7:
builddeps:
- make
deps:
- python
# - python-beautifulsoup4
# - python-certifi
- python-cryptography
- python-urllib3
centos8.0:
builddeps:
- make
deps:
- python3
# - python3-beautifulsoup4
# - python3-certifi
- python3-cryptography
- python3-urllib3
commands:
precompile:
- dnf install python3 python3-cryptography python3-urllib3
debian9:
builddeps:
- make
deps:
- python3
- python3-bs4
- python3-certifi
- python3-cryptography
- python3-urllib3
debian10:
builddeps:
- make
deps:
- python3
- python3-bs4
- python3-certifi
- python3-cryptography
- python3-urllib3
fedora31:
builddeps:
- make
deps:
- python3
- python3-beautifulsoup4
- python3-certifi
- python3-cryptography
- python3-urllib3
fedora32:
builddeps:
- make
deps:
- python3
- python3-beautifulsoup4
- python3-certifi
- python3-cryptography
- python3-urllib3
opensuse15.1:
builddeps:
- make
deps:
- python3
- python3-beautifulsoup4
- python3-certifi
- python3-cryptography
- python3-urllib3
opensuse15.2:
builddeps:
- make
deps:
- python3
- python3-beautifulsoup4
- python3-certifi
- python3-cryptography
- python3-urllib3
opensuse.tumbleweed:
builddeps:
- make
deps:
- python3
- python3-beautifulsoup4
- python3-certifi
- python3-cryptography
- python3-urllib3
ubuntu16.04:
builddeps:
- make
deps:
- python3
- python3-bs4
- python3-certifi
- python3-cryptography
- python3-urllib3
ubuntu18.04:
builddeps:
- make
deps:
- python3
- python3-bs4
- python3-certifi
- python3-cryptography
- python3-urllib3
ubuntu20.04:
builddeps:
- make
deps:
- python3
- python3-bs4
- python3-certifi
- python3-cryptography
- python3-urllib3
================================================
FILE: pyproject.toml
================================================
[project]
name = "buku"
description = "Bookmark manager like a text-based mini-web."
keywords = ["cli", "bookmarks", "tag", "utility"]
readme = "README.md"
license = "GPL-3.0-or-later"
authors = [{ name = "Arun Prakash Jana", email = "engineerarun@gmail.com" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: End Users/Desktop",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Internet :: WWW/HTTP :: Indexing/Search",
"Topic :: Utilities",
]
requires-python = ">=3.10"
dependencies = [
"beautifulsoup4>=4.4.1",
"certifi",
"cryptography>=1.2.3",
"html5lib>=1.0.1",
"urllib3>=1.23,<3",
"pyreadline3; sys_platform == 'win32'",
"colorama>=0.4.6; sys_platform == 'win32'",
]
dynamic = ["version"]
[project.urls]
Homepage = "https://github.com/jarun/buku"
Documentation = "https://buku.readthedocs.io/en/latest"
Funding = "https://github.com/sponsors/jarun"
Source = "https://github.com/jarun/buku"
Tracker = "https://github.com/jarun/buku/issues"
[project.optional-dependencies]
server = [
"arrow>=1.2.2",
"Flask-Admin>=2.0.0",
"flask-paginate>=2022.1.8",
"Flask-WTF>=1.0.1",
"Flask>=2.2.2",
"Jinja2>=3",
"flasgger",
]
locales = ["Flask-Admin[translation]"]
[dependency-groups]
dev = [
{ include-group = "tests" },
{ include-group = "docs" },
{ include-group = "packaging" },
]
tests = [
"attrs>=17.4.0",
"beautifulsoup4>=4.6.0",
"Click>=7.0",
"flake8>=3.4.1",
"hypothesis>=6.0.0",
"mypy-extensions==0.4.1",
"py>=1.5.0",
"pylint>=1.7.2",
"pytest-cov",
"pytest-recording>=0.12.1",
"pytest-timeout",
"pytest>=6.2.1",
"PyYAML>=4.2b1",
"tomli; python_version < '3.11'",
"vcrpy>=1.13.0",
"lxml",
"buku[server,locales]",
]
docs = [
"myst-parser>=0.17.0",
"sphinx-autobuild>=2021.3.14",
"sphinx-rtd-theme>=1.0.0",
]
packaging = ["twine>=1.11.0"]
[project.scripts]
buku = "buku:main"
bukuserver = "bukuserver.server:cli"
[tool.setuptools]
py-modules = ["buku"]
packages.find.include = ["bukuserver", "bukuserver.*"]
[tool.setuptools.data-files]
"share/man/man1" = ["*.1"]
[tool.setuptools.dynamic]
version = { attr = "buku.__version__" }
[build-system]
requires = ["setuptools>=77.0.3"]
build-backend = "setuptools.build_meta"
================================================
FILE: requirements.txt
================================================
# use setup.py for latest required package
beautifulsoup4>=4.4.1
certifi
cryptography>=1.2.3
html5lib>=1.0.1
setuptools
urllib3>=1.23,<3
pyreadline3; sys_platform == 'win32'
colorama>=0.4.6; sys_platform == 'win32'
================================================
FILE: tests/.pylintrc
================================================
[MESSAGES CONTROL]
disable=
assigning-non-slot,
broad-except,
c-extension-no-member,
consider-using-f-string,
consider-using-with,
dangerous-default-value,
expression-not-assigned,
fixme,
global-statement,
global-variable-not-assigned,
import-error,
import-outside-toplevel,
invalid-name,
len-as-condition,
logging-format-interpolation,
lost-exception,
missing-docstring,
pointless-statement,
protected-access,
redefined-argument-from-local,
redefined-builtin,
redefined-outer-name,
too-many-arguments,
too-many-branches,
too-many-instance-attributes,
too-many-lines,
too-many-locals,
too-many-nested-blocks,
too-many-positional-arguments,
too-many-public-methods,
too-many-return-statements,
too-many-statements,
undefined-loop-variable,
ungrouped-imports,
unidiomatic-typecheck,
unnecessary-lambda,
unnecessary-lambda-assignment,
unsubscriptable-object,
unsupported-assignment-operation,
unused-argument,
unused-variable,
[FORMAT]
max-line-length=139
================================================
FILE: tests/__init__.py
================================================
================================================
FILE: tests/cassettes/test_buku/test_fetch_data_with_url[http---example.com-exp_res1].yaml
================================================
interactions:
- request:
body: null
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip,deflate
Cookie:
- ''
DNT:
- '1'
User-Agent:
- Mozilla/5.0 (X11; Linux x86_64; rv:101.0) Gecko/20100101 Firefox/101.0
method: GET
uri: http://example.com/
response:
body:
string: "\n\n\n Example Domain\n\n
\ \n \n \n \n\n\n\n
\n
Example Domain
\n
\
This domain is for use in illustrative examples in documents. You may
use this\n domain in literature without prior coordination or asking for
permission.
29 Apr 2020\
\ \u2014 Working AltGr (right Alt key) key on a German\
\ keyboard currently in Debian/testing Xnest and Xephyr report the following\
\ error,\_...
1 Jun 2010\
\ \u2014 this problem occured a month ago, rarely at first, and\
\ now i often need to start X tens of times before alt gr works.\
\ i'm using x.org 1.4.2. xinit says that\_...
25 Sep 2021\
\ \u2014 Errors from xkbcomp are not fatal to the X\
\ server ... My Swiss french keyboard layout is working (with \"Alt Gr\"\
\ as \"Right Alt\" key).
Alt gr (right alt key) stopped working\
\ + XKB error ... circumstanses: - an error in the library libxklavier\
\ - an error in th X-server (the tools xkbcomp,\_...
9 Jul 2009\
\ \u2014 Actually, yes. Please attach the output of \"xkbcomp\
\ -xkb :0 -\" after and before running setxkbmap (i.e. once when\
\ it works, once when it doesn't)\_...
comment for the bookmark here
""",
(
(
"https://github.com/j",
"GitHub",
",tag1,tag2,",
"comment for the bookmark here",
0,
True,
False,
),
),
),
(
"""DT>GitHub
comment for the bookmark here
second line of the comment here""",
(
(
"https://github.com/j",
"GitHub",
",tag1,tag2,",
"comment for the bookmark here",
0,
True,
False,
),
),
),
(
"""DT>GitHub
comment for the bookmark here
second line of the comment here
third line of the comment here
News""",
(
(
"https://github.com/j",
"GitHub",
",tag1,tag2,",
"comment for the bookmark here\n "
"second line of the comment here\n "
"third line of the comment here",
0,
True,
False,
),
("https://news.com/", "News", ",tag1,tag2,tag3,", None, 0, True, False),
),
),
(
"""DT>GitHub
comment for the bookmark here""",
(
(
"https://github.com/j",
"GitHub",
",tag1,tag2,",
"comment for the bookmark here",
0,
True,
False,
),
),
),
],
)
def test_import_html(html_text, exp_res):
"""test method."""
from bs4 import BeautifulSoup
from buku import import_html
html_soup = BeautifulSoup(html_text, "html.parser")
res = list(import_html(html_soup, False, None))
for item, exp_item in zip(res, exp_res):
assert item == exp_item, "Actual item:\n{}".format(item)
def test_import_html_and_add_parent():
from bs4 import BeautifulSoup
from buku import import_html
html_text = """