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:
$ mkdir ~/.cache/buku/
$ mv ~/.cache/markit/bookmarks.db ~/.cache/buku/bookmarks.db
$ rm -rf ~/.cache/markit/bookmarks.db
- 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

Latest release Availability PyPI Build Status Docs Status Privacy Awareness License

buku in action!

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)


Packaging status

Unlisted packagers:


PyPI (pip3 install buku)
● Termux (pip3 install buku)

#### Release packages Auto-generated packages (with only the cli component) for Arch Linux, CentOS, Debian, Fedora, openSUSE Leap and Ubuntu are available with the [latest stable release](https://github.com/jarun/buku/releases/latest). NOTE: CentOS may not have the python3-beautifulsoup4 package in the repos. Install it using pip3. #### From source If you have git installed, clone this repository. Otherwise download the [latest stable release](https://github.com/jarun/buku/releases/latest) or [development version](https://github.com/jarun/buku/archive/master.zip) (*risky*). Install the dependencies. For example, on Ubuntu: $ apt-get install ca-certificates python3-urllib3 python3-cryptography python3-bs4 Install the cli component to default location (`/usr/local`): $ sudo make install To remove, run: $ sudo make uninstall `PREFIX` is supported, in case you want to install to a different location. #### Running standalone `buku` is a standalone utility. From the containing directory, run: $ chmod +x buku.py $ ./buku.py ### Shell completion Shell completion scripts for Bash, Fish and Zsh can be found in respective subdirectories of [auto-completion/](https://github.com/jarun/buku/blob/master/auto-completion). Please refer to your shell's manual for installation instructions. ### Usage #### Command-line options ``` usage: buku [OPTIONS] [KEYWORD [KEYWORD ...]] Bookmark manager like a text-based mini-web. POSITIONAL ARGUMENTS: KEYWORD search keywords GENERAL OPTIONS: -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 EDIT OPTIONS: --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 SEARCH OPTIONS: -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) ENCRYPTION OPTIONS: -l, --lock [N] encrypt DB in N (default 8) # iterations -k, --unlock [N] decrypt DB in N (default 8) # iterations POWER TOYS: --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 SYMBOLS: > url + comment # tags PROMPT KEYS: 1-N browse search result indices and/or ranges R [N] print out N random search results (or random bookmarks if negative or N/A) ^ id1 id2 swap two records at specified indices O [id|range [...]] open search results/indices in GUI browser toggle try GUI browser if no arguments a open all results in browser s keyword [...] search for records with ANY keyword S keyword [...] search for records with ALL keywords d match substrings ('pen' matches 'opened') m search with markers - search string is split into keywords by prefix markers, which determine what field the keywords is searched in: '.', '>' or ':' - title, description or URL '#'/'#,' - tags (comma-separated, partial/full match) '*' - all fields (can be omitted in the 1st keyword) note: tag marker is not affected by 'd' (deep search) v fields change sorting order (default is '+index') multiple comma/space separated fields can be specified v! fields update indices in DB to match specified order r expression run a regex search t [tag, ...] search by tags; show taglist, if no args g taglist id|range [...] [>>|>|<<] [record id|range ...] append, set, remove (all or specific) tags search by taglist id(s) if records are omitted n show next page of search results N show previous page of search results o id|range [...] browse bookmarks by indices and/or ranges p id|range [...] print bookmarks by indices and/or ranges w [editor|id] edit and add or update a bookmark c id copy URL at search result index to clipboard DB [name] check existing DB list or switch to another DB (use full/dir path to switch folders) '~.' can be used as shortcut for default DB ? show this help q, ^D, double Enter exit buku ``` #### Colors `buku` supports custom colors. Visit the wiki page on how to [customize colors](https://github.com/jarun/buku/wiki/Customize-colors) for more details. ### Quickstart 1. Export `VISUAL` or `EDITOR` to point to your favourite editor. Note that `VISUAL` takes precedence over `EDITOR`. 2. Create a sweeter shortcut with some convenience. alias b='buku --suggest' 3. Auto-import bookmarks from your browser(s). Please quit the relevant browsers beforehand to ensure the databases are not locked. b --ai 4. Manually add a bookmark (for hands-on). b -w 5. List your bookmarks with DB index. b -p 6. For GUI and browser integration (or to sync bookmarks with your favourite bookmark management service) refer to the wiki page on [System integration](https://github.com/jarun/buku/wiki/System-integration). 7. Quick (bash/zsh) commands to fuzzy search with fzf and open the selection in Firefox: firefox $(buku -p -f 10 | fzf) firefox $(buku -p -f 40 | fzf | cut -f1) POSIX script to show a preview of the bookmark as well: ```sh #!/usr/bin/env sh url=$(buku -p -f4 | fzf -m --reverse --preview "buku -p {1}" --preview-window=wrap | cut -f2) if [ -n "$url" ]; then echo "$url" | xargs firefox fi ``` ### Examples 1. **Edit and add** a bookmark from editor: $ buku -w $ buku -w 'gedit -w' $ buku -w 'macvim -f' -a https://ddg.gg search engine, privacy The first command picks editor from the environment variable `EDITOR`. The second command opens gedit in blocking mode. The third command opens macvim with option -f and the URL and tags populated in template. 2. **Add** a simple bookmark: $ buku --nostdin -a https://github.com/ 2648. GitHub: Let’s build from here · GitHub > https://github.com/ + GitHub is where over 94 million developers shape the future of software, together. Contribute to the open source community, manage your Git repositories, review code like a pro, track bugs and features, power your CI/CD and DevOps workflows, and secure code before you commit it. $ buku --nostdin -a https://github.com/ [ERROR] URL [https://github.com/] already exists at index 2648 `>`: URL, `+`: comment, `#`: tags Title, description and tags will be fetched from site. Buku only stores unique URLs and will raise error if the URL already present in the database: 3. **Add** a bookmark with **tags** `search engine` and `privacy`, **comment** `Search engine with perks`, **fetch page title** from the web: $ buku -a https://ddg.gg search engine, privacy -c Search engine with perks 336. DuckDuckGo > https://ddg.gg + Alternative search engine with perks # privacy,search engine where, `>`: URL, `+`: comment, `#`: tags 4. **Add** a bookmark with tags `search engine` & `privacy` and **immutable custom title** `DDG`: $ buku -a https://ddg.gg search engine, privacy --title 'DDG' --immutable 1 336. DDG (L) > https://ddg.gg # privacy,search engine Note that URL must precede tags. 5. **Add** a bookmark **without a title** (works for update too): $ buku -a https://ddg.gg search engine, privacy --title 6. **Edit and update** a bookmark from editor: $ buku -w 15012014 This will open the existing bookmark's details in the editor for modifications. Environment variable `EDITOR` must be set. 7. **Update** existing bookmark at index 15012014 with new URL, tags and comments, fetch title from the web: $ buku -u 15012014 --url http://ddg.gg/ --tag web search, utilities -c Private search engine 8. **Fetch and update only title** for bookmark at 15012014: $ buku -u 15012014 9. **Update only comment** for bookmark at 15012014: $ buku -u 15012014 -c this is a new comment Applies to --url, --title and --tag too. 10. **Export** bookmarks tagged `tag 1` or `tag 2` to HTML, XBEL, Markdown, Orgfile or a new database: $ buku -e bookmarks.html --stag tag 1, tag 2 $ buku -e bookmarks.xbel --stag tag 1, tag 2 $ buku -e bookmarks.md --stag tag 1, tag 2 $ buku -e bookmarks.org --stag tag 1, tag 2 $ buku -e bookmarks.db --stag tag 1, tag 2 All bookmarks are exported if search is not opted. 11. **Import** bookmarks from HTML, XBEL, Markdown or Orgfile: $ buku -i bookmarks.html $ buku -i bookmarks.xbel $ buku -i bookmarks.md $ buku -i bookmarks.org $ buku -i bookmarks.db 12. **Delete only comment** for bookmark at 15012014: $ buku -u 15012014 -c Applies to --title and --tag too. URL cannot be deleted without deleting the bookmark. 13. **Update** or refresh **full DB** with page titles from the web: $ buku -u $ buku -u --tacit (show only failures and exceptions) This operation can update the title or description fields of non-immutable bookmarks by parsing the fetched page. Fields are updated only if the fetched fields are non-empty. Tags remain untouched. 14. **Delete** bookmark at index 15012014: $ buku -d 15012014 Index 15012020 moved to 15012014 The last index is moved to the deleted index to keep the DB compact. Add `--tacit` to delete without confirmation. 15. **Delete all** bookmarks: $ buku -d 16. **Delete** a **range or list** of bookmarks: $ buku -d 100-200 $ buku -d 100 15 200 17. **Search** bookmarks for **ANY** of the keywords `kernel` and `debugging` in URL, title or tags: $ buku kernel debugging $ buku -s kernel debugging 18. **Search** bookmarks with **ALL** the keywords `kernel` and `debugging` in URL, title or tags: $ buku -S kernel debugging 19. **Search** bookmarks **tagged** `general kernel concepts`: $ buku --stag general kernel concepts 20. **Search** for bookmarks matching **ANY** of the tags `kernel`, `debugging`, `general kernel concepts`: $ buku --stag kernel, debugging, general kernel concepts 21. **Search** for bookmarks matching **ALL** of the tags `kernel`, `debugging`, `general kernel concepts`: $ buku --stag kernel + debugging + general kernel concepts 22. **Search** for bookmarks matching any of the keywords `hello` or `world`, excluding the keywords `real` and `life`, matching both the tags `kernel` and `debugging`, but **excluding** the tags `general kernel concepts` and `books`: $ buku hello world --exclude real life --stag 'kernel + debugging - general kernel concepts, books' 23. **Search** for bookmarks with different tokens for each field, and print them out sorted by the tags (ascending) and URL (descending) $ buku --order +tags,-url --markers --sall 'global substring' '.title substring' ':url substring' :https '> description substring' '#partial,tags:' '#,exact,tags' '*another global substring' 24. List **all unique tags** alphabetically: $ buku --stag 25. Run a **search and update** the results: $ buku -s kernel debugging -u --tag + linux kernel 26. Run a **search and delete** the results: $ buku -s kernel debugging -d 27. **Encrypt or decrypt** DB with **custom number of iterations** (15) to generate key: $ buku -l 15 $ buku -k 15 The same number of iterations must be specified for one lock & unlock instance. Default is 8, if omitted. 28. **Show details** of bookmarks at index 15012014 and ranges 20-30, 40-50: $ buku -p 20-30 15012014 40-50 29. Show details of the **last 10 bookmarks**: $ buku -p -10 30. **Show all** bookmarks with real index from database: $ buku -p $ buku -p | more 31. **Replace tag** 'old tag' with 'new tag': $ buku --replace 'old tag' 'new tag' 32. **Delete tag** 'old tag' from DB: $ buku --replace 'old tag' 33. **Append (or delete) tags** 'tag 1', 'tag 2' to (or from) existing tags of bookmark at index 15012014: $ buku -u 15012014 --tag + tag 1, tag 2 $ buku -u 15012014 --tag - tag 1, tag 2 34. **Open URL** at index 15012014 in browser: $ buku -o 15012014 35. List bookmarks with **no title or tags** for bookkeeping: $ buku -S blank 36. List bookmarks with **immutable title**: $ buku -S immutable 37. **Append, remove tags at prompt** (taglist index to the left, bookmark index to the right): // append tags at taglist indices 4 and 6-9 to existing tags in bookmarks at indices 5 and 2-3 buku (? for help) g 4 9-6 >> 5 3-2 // set tags at taglist indices 4 and 6-9 as tags in bookmarks at indices 5 and 2-3 buku (? for help) g 4 9-6 > 5 3-2 // remove all tags from bookmarks at indices 5 and 2-3 buku (? for help) g > 5 3-2 // remove tags at taglist indices 4 and 6-9 from tags in bookmarks at indices 5 and 2-3 buku (? for help) g 4 9-6 << 5 3-2 38. List bookmarks with **colored output**: $ buku --colors oKlxm -p 39. Add a bookmark after following all permanent redirects, but only if the server doesn't respond with an error (and there's no network failure) $ buku --add http://wikipedia.net --url-redirect --del-error 2. Wikipedia > https://www.wikipedia.org/ + Wikipedia is a free online encyclopedia, created and edited by volunteers around the world and hosted by the Wikimedia Foundation. 40. Add a bookmark with tag `http redirect` if the server responds with a permanent redirect, or tag shaped like `http 404` on an error response: $ buku --add http://wikipedia.net/notfound --tag-redirect 'http redirect' --tag-error 'http {}' [ERROR] [404] Not Found 3. Not Found > http://wikipedia.net/notfound # http 404,http redirect 41. Update all bookmarks matching the search by updating the URL if the server responds with a permanent redirect, deleting the bookmark if the server responds with HTTP error 400, 401, 402, 403, 404 or 500, or adding a tag shaped like `http:{}` in case of any other HTTP error; then export those affected by such changes into an HTML file, marking deleted records as well as old URLs for those replaced by redirect. $ buku -S ://wikipedia.net -u --url-redirect --tag-error --del-error 400-404,500 --export-on --export backup.html 42. Print out a single **random** bookmark: $ buku --random --print 43. Print out 3 **random** bookmarks **ordered** by netloc (reversed), title and url: $ buku --random 3 --order ,-netloc,title,+url --print 44. Print out a single **random** bookmark matching **search** criteria, and **export** into a Markdown file (in DB order): $ buku --random -S kernel debugging --export random.md 45. Swap positions of records #4 and #5: $ buku --swap 4 5 46. Update indices in all bookmarks to match specified order: $ buku --reorder ,-netloc,title,+url 47. More **help**: $ buku -h $ man buku ### Automation Interactive workflows can be automated using expect. Issue [#368](https://github.com/jarun/buku/issues/368) has a working example on automating auto-import. ### Troubleshooting #### Editor integration You may encounter issues with GUI editors which maintain only one instance by default and return immediately from other instances. Use the appropriate editor option to block the caller when a new document is opened. See issue [#210](https://github.com/jarun/buku/issues/210) for gedit. ### Collaborators - [Arun Prakash Jana](https://github.com/jarun) - [Alexey Gulenko](https://github.com/LeXofLeviafan) - [Rachmadani Haryono](https://github.com/rachmadaniHaryono) - [Johnathan Jenkins](https://github.com/shaggytwodope) - [SZ Lin](https://github.com/szlin) Copyright © 2015-2026 [Arun Prakash Jana](mailto:engineerarun@gmail.com)

gitter chat

### Contributions Missing a feature? There's a rolling [ToDo List](https://github.com/jarun/buku/issues/484) with identified tasks. Contributions are welcome! Please follow the [PR guidelines](https://github.com/jarun/buku/wiki/PR-guidelines). See also our documentation here Stable Docs ### Related projects - [bukubrow](https://github.com/SamHH/bukubrow), WebExtension for browser integration - [oil](https://github.com/AndreiUlmeyda/oil), search-as-you-type cli front-end - [buku_run](https://github.com/carnager/buku_run), rofi front-end - [pinku](https://github.com/mosegontar/pinku), a Pinboard-to-buku import utility - [buku-dmenu](https://gitlab.com/benoliver999/buku-dmenu), a simple bash dmenu wrapper - [poku](https://github.com/shanedabes/poku), sync between Pocket and buku - [Ebuku](https://github.com/flexibeast/ebuku), Emacs interface to buku - [diigoku](https://github.com/dppdppd/diigoku), buku importer for Diigo - [BukuBot](https://git.xmpp-it.net/sch/BukuBot), Chat bot for XMPP with an extended visual interface ### Videos - [Buku: Take Your Bookmarks Everywhere You Go](https://www.youtube.com/embed/9HzEHrUBQXE) - [Buku is a great open-source bookmark manager](https://www.youtube.com/embed/7VxgKMWm-J8) ### In the Press - [2daygeek](http://www.2daygeek.com/buku-command-line-bookmark-manager-linux/) - [Hacker Milk](https://hackermilk.blogspot.com/2020/01/how-to-manage-your-browsers-bookmarks.html) - [It's F.O.S.S.](https://itsfoss.com/buku-command-line-bookmark-manager-linux/) - [LinOxide](https://linoxide.com/linux-how-to/buku-browser-bookmarks-linux/) - [LinuxUser Magazine 01/2017 Issue](http://www.linux-community.de/LU/2017/01/Das-Beste-aus-zwei-Welten) - [Make Tech Easier](https://www.maketecheasier.com/manage-browser-bookmarks-ubuntu-command-line/) - [One Thing Well](http://onethingwell.org/post/144952807044/buku) - [Open Source For You](https://opensourceforu.com/2018/05/buku-a-bookmark-manager-in-the-command-line/) - [ulno.net](https://ulno.net/blog/2017-07-19/of-bookmarks-tags-and-browsers/) ================================================ FILE: auto-completion/bash/buku-completion.bash ================================================ # # Bash completion definition for buku. # # Author: # Arun Prakash Jana # _buku () { COMPREPLY=() local IFS=$' \n' local cur=$2 prev=$3 local -a opts opts_with_args opts=( -a --add --ai -c --comment --cached --colors -d --delete --deep --del-error -e --export --expand --export-on -f --format -h --help -i --import --immutable -j --json -k --unlock -l --lock --markers -n --count --nc --np -o --open --oa --offline --order -p --print -r --sreg --random --replace -s --sany -S --sall --shorten --suggest --swap -t --stag --tacit --tag --tag-error --tag-redirect --threads --title -u --update --url --url-redirect -V -v --version -w --write -x --exclude -g --debug ) opts_with_arg=( -a --add --cached --colors -e --export --expand -f --format -i --import --immutable -n --count --order -r --sreg --replace -s --sany -S --sall --shorten --swap --threads --url -x --exclude ) # Do not complete non option names [[ $cur == -* ]] || return 1 # Do not complete when the previous arg is an option expecting an argument for opt in "${opts_with_arg[@]}"; do [[ $opt == $prev ]] && return 1 done # Complete option names COMPREPLY=( $(compgen -W "${opts[*]}" -- "$cur") ) return 0 } complete -F _buku buku ================================================ FILE: auto-completion/fish/buku.fish ================================================ # # Fish completion definition for buku. # # Author: # Arun Prakash Jana # complete -c buku -s a -l add -r --description 'add bookmark' complete -c buku -l ai --description 'auto-import bookmarks' complete -c buku -s c -l comment --description 'comment on bookmark' complete -c buku -l cached -r --description 'visit Wayback Machine cached version' complete -c buku -l colors -r --description 'set output colors in 5-letter string' complete -c buku -s d -l delete --description 'delete bookmark' complete -c buku -l deep --description 'search matching substrings' complete -c buku -l del-error --description 'delete bookmark on an HTTP error' complete -c buku -s e -l export -r --description 'export bookmarks' complete -c buku -l expand -r --description 'expand a tny.im shortened URL' complete -c buku -l export-on --description 'export bookmarks based on HTTP status' complete -c buku -s f -l format -r --description 'limit fields in print and JSON output' complete -c buku -s h -l help --description 'show help' complete -c buku -s i -l import -r --description 'import bookmarks' complete -c buku -l immutable -r --description 'disable title update from web' complete -c buku -s j -l json --description 'show JSON output for print and search' complete -c buku -s k -l unlock --description 'decrypt database' complete -c buku -s l -l lock --description 'encrypt database' complete -c buku -l markers --description 'enable search-with-markers mode (.>:#*)' complete -c buku -s n -l count -r --description 'results per page' complete -c buku -l nc --description 'disable color output' complete -c buku -l np --description 'non-interactive mode' complete -c buku -s o -l open --description 'open bookmarks in browser' complete -c buku -l oa --description 'browse all search results immediately' complete -c buku -l offline --description 'add a bookmark without connecting to web' complete -c buku -l order -r --description 'order by fields (+/- prefix for direction)' complete -c buku -s p -l print --description 'show bookmark details' complete -c buku -s r -l sreg -r --description 'match a regular expression' complete -c buku -l random --description 'random subset (of 1 or given amount)' complete -c buku -l replace -r --description 'replace a tag' complete -c buku -s s -l sany -r --description 'match any keyword' complete -c buku -s S -l sall -r --description 'match all keywords' complete -c buku -l shorten -r --description 'shorten a URL using tny.im' complete -c buku -l suggest --description 'show a list of similar tags' complete -c buku -l swap -r --description 'swap 2 given bookmark indices' complete -c buku -s t -l stag --description 'search by tag or show tags' complete -c buku -l tacit --description 'reduce verbosity' complete -c buku -l tag --description 'set tags, use + to append, - to remove' complete -c buku -l tag-error --description 'add tag on an HTTP error' complete -c buku -l tag-redirect --description 'add tag on a permanent redirect' complete -c buku -l threads -r --description 'max connections for full refresh' complete -c buku -l title --description 'set custom title' complete -c buku -s u -l update --description 'update bookmark' complete -c buku -l url -r --description 'set url' complete -c buku -l url-redirect --description 'update URL on a permanent redirect' complete -c buku -s V --description 'check latest upstream release' complete -c buku -s v -l version --description 'show program version' complete -c buku -s w -l write --description 'open editor' complete -c buku -s x -l exclude -r --description 'exclude keywords' complete -c buku -s g -l debug --description 'enable debugging mode' ================================================ FILE: auto-completion/zsh/_buku ================================================ #compdef buku # # Completion definition for buku. # # Author: # Arun Prakash Jana # setopt localoptions noshwordsplit noksharrays local -a args args=( '(-a --add)'{-a,--add}'[add bookmark]:URL tags' '(--ai)--ai[auto-import bookmarks]' '(-c --comment)'{-c,--comment}'[comment on bookmark]' '(--cached)--cached[visit Wayback Machine cached version]:index/url' '(--colors)--colors[set output colors in 5-letter string]:color string' '(-d --delete)'{-d,--delete}'[delete bookmark]' '(--deep)--deep[search matching substrings]' '(--del-error)--del-error[delete bookmark on an HTTP error]::HTTP codes' '(-e --export)'{-e,--export}'[export bookmarks]:html/md/db output file' '(--expand)--expand[expand a tny.im shortened URL]:index/shorturl' '(--export-on)--export-on[export bookmarks based on HTTP status]::HTTP codes' '(-f --format)'{-f,--format}'[limit fields in print and JSON output]:value' '(-h --help)'{-h,--help}'[show help]' '(-i --import)'{-i,--import}'[import bookmarks]:html/md/db input file' '(--immutable)--immutable[disable title update from web]:value' '(-j --json)'{-j,--json}'[show JSON output for print and search]::file' '(-k --unlock)'{-k,--unlock}'[decrypt database]' '(-l --lock)'{-l,--lock}'[encrypt database]' '(--markers)--markers[enable search-with-markers mode (.>:#*)]' '(-n --count)'{-n,--count}'[results per page]:value' '(--nc)--nc[disable color output]' '(--np)--np[noninteractive mode]' '(-o --open)'{-o,--open}'[open bookmarks in browser]' '(--oa)--oa[browse all search results immediately]' '(--offline)--offline[add a bookmark without connecting to web]' '(--order)--order[order by fields (+/- prefix for direction)]:fields' '(-p --print)'{-p,--print}'[show bookmark details]' '(-r --sreg)'{-r,--sreg}'[match a regular expression]:regex' '(--random)--random[random subset (of 1 or given amount)]::amount' '(--replace)--replace[replace a tag]:tag to replace' '(-s --sany)'{-s,--sany}'[match any keyword]:keyword(s)' '(-S --sall)'{-S,--sall}'[match all keywords]:keyword(s)' '(--shorten)--shorten[shorten a URL using tny.im]:index/url' '(--suggest)--suggest[show a list of similar tags]' '(--swap)--swap[swap 2 given bookmark indices]:index1 index2' '(-t --stag)'{-t,--stag}'[search by tag or show tags]' '(--tacit)--tacit[reduce verbosity]' '(--tag)--tag[set tags, use + to append, - to remove]' '(--tag-error)--tag-error[add tag on an HTTP error]::tag pattern' '(--tag-redirect)--tag-redirect[add tag on a permanent redirect]::tag pattern' '(--threads)--threads[max connections for full refresh]:value' '(--title)--title[set custom title]' '(-u --update)'{-u,--update}'[update bookmark]' '(--url)--url[set url]:url' '(--url-redirect)--url-redirect[update URL on a permanent redirect]' '(-V)-V[check latest upstream release]' '(-v --version)'{-v,--version}'[show program version]' '(-w --write)'{-w,--write}'[open editor]' '(-x --exclude)'{-x,--exclude}'[exclude keywords]:keyword(s)' '(-g --debug)'{-g,--debug}'[enable debugging mode]' ) _arguments -S -s $args ================================================ FILE: buku.1 ================================================ .TH "BUKU" "1" "07 Dec 2025" "Version 5.1" "User Commands" .SH NAME buku \- Bookmark manager like a text-based mini-web .SH SYNOPSIS .B buku [OPTIONS] [KEYWORD [KEYWORD ...]] .SH DESCRIPTION .B buku is a command-line utility to store, tag, search and organize bookmarks. .PP .B Features .PP * 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 * Has a compation Web-application (Bukuserver) with an HTTP-based API (for personal use only) .SH OPERATIONAL NOTES .PP .IP 1. 4 The database file is stored in: - \fI$BUKU_DEFAULT_DBDIR/bookmarks.db\fR, if BUKU_DEFAULT_DBDIR is defined (first preference), or - \fI$XDG_DATA_HOME/buku/bookmarks.db\fR, if XDG_DATA_HOME is defined (second preference), or - \fI$HOME/.local/share/buku/bookmarks.db\fR, if HOME is defined (third preference), or - \fI%APPDATA%\\buku\\bookmarks.db\fR, if you are on Windows and APPDATA is defined (fourth preference), or - the current directory. .PP .IP 2. 4 If the URL contains characters like ';', '&' or brackets they may be interpreted specially by the shell. To avoid it, add the URL within single or double quotes ('/"). .PP .IP 3. 4 URLs are unique in DB. The same URL cannot be added twice. .PP .IP 4. 4 Bookmarks with immutable titles are listed with '(L)' after the title. .PP .IP 5. 4 \fBTags\fR: - Comma (',') is the tag delimiter in DB. A tag cannot have comma(s) in it. Tags are filtered (for unique tags) and sorted. Tags are stored in lower case and can be replaced, appended or deleted. - Page keywords having a word to comma ratio > 3 are appended to description rather than tags. - Parent folder (and subfolder) names are converted to all-lowercase tags during bookmarks HTML import. - Releases prior to v2.7 support both capital and lower cases in tags. From v2.7 all tags are stored in lowercase. An undocumented option --fixtags is introduced to modify the older tags. It also fixes another issue where the same tag appears multiple times in the tagset of a record. Run \fBbuku --fixtags\fR once. - Tags can be edited from the prompt very easily using '>>' (append), '>' (overwrite) and '<<' (remove) symbols. The LHS of the operands denotes the indices and ranges of tags to apply (as listed by --tag or key 't' at prompt) and the RHS denotes the actual DB indices and ranges of the bookmarks to apply the change to. .PP .IP 6. 4 \fBUpdate\fR operation: - If --title, --tag or --comment is passed without argument, clear the corresponding field from DB. - If --url is passed (and --title is omitted), update the title from web using the URL. Description is updated (if --comment is omitted). Tags remain untouched. - If indices are passed without any other options (--url, --title, --tag, --comment and --immutable), read the URLs from DB and update titles, description and append tags from web. Bookmarks marked immutable are skipped. - Can update bookmarks matching a search, when combined with any of the search options and no arguments to update are passed. - Additionally, --swap allows to modify records order (standalone operation). .PP .IP 7. 4 \fBDelete\fR operation: - When a record is deleted, the last record is moved to the index. - Delete doesn't work with range and indices provided together as arguments. It's an intentional decision to avoid extra sorting, in-range checks and to keep the auto-DB compaction functionality intact. On the same lines, indices are deleted in descending order. - Can delete bookmarks matching a search, when combined with any of the search options and no arguments to delete are passed. .PP .IP 8. 4 \fBSearch\fR works in mysterious ways: - Case-insensitive. - Matches words in URL, title and tags. - --sany : match any of the keywords in URL, title, description or tags. Default search option. - --sall : match all the keywords in URL, title, description or tags. - --deep : match \fBsubstrings\fR (`match` matches `rematched`) in URL, title, description and tags. - --markers : match each keyword to a \fBspecific\fR field, depending on its prefix. - --sreg : match a regular expression (ignores --deep). - --stag : search bookmarks by tags, or list all tags alphabetically with usage count (if no arguments). Delimit the list of tags in the query with `,` to search for bookmarks that match ANY of the listed tags. Delimit tags with `+` to search for bookmarks that match ALL of the listed tags. Note that `,` and `+` cannot be used together in the same search. Exclude bookmarks matching certain tags from the results by using ` - ` followed by the tags. Note that the ` - ` operator and the ` + ` delimiter must be space separated: ` - ` instead of `-` and ` + ` instead of `+`. This is to distinguish them from hyphenated tags (e.g., `some-tag-name`) and tags with '+'s (e.g., `some+tag+name`). - Search for keywords along with tag filtering is possible. Two searches are issued (one for keywords and another for tags) and the intersection of the 2 sets is returned as the resultset. - Search results are indexed incrementally. This index is different from actual database index of a bookmark record which is shown within '[]' after the title. - Results for \fIany\fR keyword matches are ordered by the number of keyword matches - results matching more keywords (\fImatch score\fR) will appear earlier in the list. Results having the same number of matches will be ranked by their record DB index. If only one keyword is searched, results will be ordered by DB index, since all matching records will have the same \fImatch score\fR. - Sorting order can be specified (for matches with same amount of matched keywords, if relevant). This option also works with regular printing/export. .PP .IP 9. 4 \fBImport\fR: - Auto-import looks in the default installation path and default user profile. - URLs starting with `place:`, `file://` and `apt:` are ignored during import. - Parent folder (and subfolder) names are automatically imported as tags if --tacit is used. - Tags are merged even if bookmark URL exists when --tacit is used. - JSON files exported from browser can be imported. Export to JSON is not supported. .PP .IP 10. 4 \fBEncryption\fR is optional and manual. AES256 algorithm is used. To use encryption, the database file should be unlocked (-k) before using \fBbuku\fR and locked (-l) afterwards. Between these 2 operations, the database file lies unencrypted on the disk, and NOT in memory. Also, note that the database file is \fBunencrypted on creation\fR. .PP .IP 11. 4 \fBEditor\fR support: - A single bookmark can be edited before adding. The editor can be set using the environment variable *EDITOR* or by explicitly specifying the editor. The latter takes precedence. If -a is used along with -w, the details are populated in the editor template. - In case of edit and update (a single bookmark), the existing record details are fetched from DB and populated in the editor template. The environment variable EDITOR must be set. Note that -u works independently of -w. - All lines beginning with "#" will be stripped. Then line 1 will be treated as the URL, line 2 will be the title, line 3 will be comma separated tags, and the rest of the lines will be parsed as descriptions. .PP .IP 12. 4 \fBProxy\fR support: please refer to the \fBENVIRONMENT\fR section. .PP .IP 13. 4 \fBAlternative DB file\fR: - The option \fB--db\fR (to specify an alternative database file location) is app-only. Manual usage is prone to issues arising from human error. - Note that this option is useful if you want to store the db file in cloud synced location. Another mechanism could be to have the db file synced and create a symlink to it at the default location. - When the argument to \fB--db\fR contains neither `.` nor directory separator, it's considered a \fIname\fR and is resolved as the matching file with `.db` extension within the default DB directory. - When invoked specifically as \fBbuku --db\fR, the program prints out the list of DB names in the default DB directory. - When running Bukuserver (webUI), alternative DB file can be specified via \fBBUKUSERVER_DB_FILE\fR environment variable. Additionally, the Bukuserver runner script supports switching between DB files within the default Buku DB folder. - In the interactive shell mode, the \fBDB\fR command can be used to similarly switch between DB files by name. (You can use non-standard extensions by specifying them, and switch directories by specifying a path – absolute or relative to the current DB. \fB~.\fR stands for the default database.) .SH GENERAL OPTIONS .TP .BI \-a " " \--add " URL [+|-] [tag, ...]" Bookmark .I URL along with comma-separated tags. A tag can have multiple words. (These tags \fBoverride\fR fetched tags, unless preceded with '+' or '-'.) .TP .BI \-u " " \--update " [...]" Update fields of the bookmarks at specified indices in DB. If no arguments are specified, all titles and descriptions are refreshed from the web. Tags remain untouched. Works with update modifiers for the fields url, title, tag and comment. If only indices are passed without any edit options, titles and descriptions are fetched and updated (if not empty). Accepts hyphenated ranges and space-separated indices. Updates search results when used with search options, if no arguments. .TP .BI \-w " " \--write " [editor|index]" Edit a bookmark in .I editor before adding it. To edit and update an existing bookmark, the .I index should be passed. In this case the environment variable EDITOR must be set. The last record is opened in EDITOR if index=-1. .TP .BI \-d " " \--delete " [...]" Delete bookmarks. Accepts space-separated list of indices (e.g. 5 6 23 4 110 45) or a single hyphenated range (e.g. 100-200). Note that range and list don't work together. Deletes search results when combined with search options, if no arguments. .TP .BI \--retain-order When deleting bookmarks, shift indices of multiple records instead of replacing the deleted record with the last one. .TP .BI \-v " " \--version Show program version and exit. .TP .BI \-h " " \--help Show program help and exit. .SH EDIT OPTIONS .TP .BI \--url " [...]" Specify the URL, works with --update only. Fetches and updates title if --title is not used. .TP .BI \--tag " [+|-] [...]" Specify comma separated tags, works with --add, --update. Clears the tags, if no arguments passed. Appends or deletes tags, if list of tags is preceded by '+' or '-' respectively. .TP .BI \--title " [...]" Manually specify the title, works with --add, --update. Omits or clears the title, if no arguments passed. .TP .BI \-c " " \--comment " [...]" Add notes or description of the bookmark, works with --add, --update. Clears the comment, if no arguments passed. .TP .BI \--immutable " N" Set the title, description and tags of a bookmark immutable during autorefresh. Works with --add, --update. N=1 sets the immutable flag, N=0 removes it. If omitted, bookmarks are added with N=0. .TP .BI \--swap " N M" Swap two records at specified indices. This is a standalone operation (cannot be invoked along with any other). .SH SEARCH OPTIONS .TP .BI \-s " " \--sany " keyword [...]" Search bookmarks with ANY of the keyword(s) in URL, title or tags and show the results. Prompts to enter result number to open in browser. Note that the sequential result index is not the DB index. The DB index is shown within '[]' after the title. .br This is the default search option for positional arguments if no other search option is specified. .TP .BI \-S " " \--sall " keyword [...]" Search bookmarks with ALL keywords in URL, title or tags and show the results. Behaviour same as --sany. .br Special keywords: .br "blank": list entries with empty title/tag .br "immutable": list entries with locked title .br NOTE: To search the keywords, use --sany .TP .BI \--deep Search modifier to match substrings. Works with --sany, --sall. .TP .BI \--markers Search modifier to match specific fields based on (optional) prefix markers (i.e. beginning of the keyword): - '.' : search in title - '>' : search in description - ':' : search in URL - '#' : search in tags (comma-separated, \fBpartial\fR matches; not affected by --deep) - '#,' : search in tags (comma-separated, \fBexact\fR matches; not affected by --deep) - '*' : search in all fields (same as no prefix) .TP .BI \-r " " \--sreg " expression" Scan for a regular expression match. .TP .BI \-t " " \--stag " [tag [,|+] ...] [\- tag, ...]" Search bookmarks by tags. .br Use ',' delimiter to find entries matching ANY of the tags .br Use ' + ' delimiter to find entries matching ALL of the tags. (Note that the ' + ' delimiter must be space separated) .br NOTE: Cannot combine ',' and '+' in the same search .br Use ' - ' to exclude bookmarks that match the tags that follow. (Note that the '-' operator must be space separated). .br List all tags alphabetically, if no arguments. The usage count (number of bookmarks having the tag) is shown within first brackets. .TP .BI \-x " " \--exclude " keyword [...]" Exclude bookmarks matching the specified keywords. Works with --sany, --sall, --sreg and --stag. .TP .BI \--random " [N]" Output random bookmarks out of the selection (1 unless amount is specified). .TP .BI \--order " fields [...]" Order printed/exported records by the given fields (from DB or JSON) and/or netloc. You can specify sort direction for each by prepending '+'/'-' (default is '+'). .SH ENCRYPTION OPTIONS .TP .BI \-l " " \--lock " [N]" Encrypt (lock) the DB file with .I N (> 0, default 8) hash passes to generate key. .TP .BI \-k " " \--unlock " [N]" Decrypt (unlock) the DB file with .I N (> 0, default 8) hash passes to generate key. .SH POWER OPTIONS .TP .BI \--ai Auto-import bookmarks from Firefox, Google Chrome, Chromium, Vivaldi, Brave, and MS Edge browsers. (Firefox profile can be specified using environment variable FIREFOX_PROFILE.) .TP .BI \-e " " \--export " file" Export bookmarks to Firefox bookmarks formatted HTML. Works with all search options. .br XBEL is used if .I file has extension '.xbel'. .br Markdown is used if .I file has extension '.md'. Markdown format: [title](url), 1 entry per line. .br Orgfile is used if .I file has extension '.org' Orgfile format: * [[url][title]], 1 entry per line. .br RSS is used if .I file has extension '.rss'/'.atom' RSS format: per bookmark with , <link>, <category>, <content> elements .br A buku database is generated if .I file has extension '.db'. .TP .BI \-i " " \--import " file" Import bookmarks from Firefox bookmarks formatted HTML. .I file is considered Firefox-exported JSON if it has '.json' extension, XBEL if it is '.xbel', Markdown (compliant with --export format) if it is '.md', Orgfile if the extension is '.org', RSS if the extension is '.rss'/'.atom' or another buku database if the extension is '.db'. .TP .BI \-p " " \--print " [...]" Show details (DB index, URL, title, tags and comment) of bookmark record by DB index. If no arguments, all records with actual index from DB are shown. Accepts hyphenated ranges and space-separated indices. A negative value (introduced for convenience) behaves like the tail utility, e.g., -n shows the details of the last n bookmarks. .TP .BI \-f " " \--format " N" Show selective monochrome output with specific fields. Works with --print. Search results honour the option when used along with --json. Useful for creating batch scripts. .br .I N = 1, show only URL. .br .I N = 2, show URL and tags in a single line. .br .I N = 3, show only title. .br .I N = 4, show URL, title and tags in a single line. .br .I N = 5, show title and tags in a single line. .br To omit DB index from printed results, use N0, e.g., 10, 20, 30, 40, 50. .TP .BI \-j " " \--json Output data formatted as JSON, works with --print output and search results. .TP .BI \--colors " COLORS" Set output colors. Refer to the \fBCOLORS\fR section below for details. .TP .BI \--nc Disable color output in all messages. Useful on terminals which can't handle ANSI color codes or scripted environments. .TP .BI \-n " " \--count " N" Number of results to show per page (default 10). .TP .BI \--np Do not show the prompt, run and exit. .TP .BI \-o " " \--open " [...]" Open bookmarks by DB indices or ranges in browser. Open a random index if argument is omitted. .TP .BI \--oa Open all search results immediately in the browser. Works best with --np. When used along with --update or --delete, URLs are opened in the browser first and then modified or deleted. .TP .BI \--default-scheme " scheme" When opening a bookmark without a scheme in its URI, use this scheme (default http). .TP .BI \--replace " old new" Replace .I old tag with .I new tag if both are passed; delete .I old tag if .I new tag is not specified. .TP .BI \--url-redirect when fetching an URL, use the resulting URL from following \fBpermanent\fR redirects (when combined with --export, the old URL is included as additional metadata). .TP .BI \--tag-redirect " [tag]" when fetching an URL that causes permanent redirect, add a .I tag in specified pattern (using 'http:{}' if not specified). .TP .BI \--tag-error " [tag]" when fetching an URL that causes an HTTP error, add a .I tag in specified pattern (using 'http:{}' if not specified). .TP .BI \--del-error " [...]" when fetching an URL causes any (given) HTTP error, delete/do not add it. (Use a parameter like '404' or '400-404,500') .TP .BI \--export-on " [...]" export records affected by the above options, including removed info (requires --update and --export; specific HTTP response filter can be provided). .TP .BI \--reorder " order..." update DB indices to match specified order (specified the same way as for --order) .TP .BI \--cached " index|URL" Browse the latest cached version of the URL at DB .I index or an independent .I URL using the Wayback Machine. Useful for viewing the content of bookmarks which are not live any more. .TP .BI \--offline Add a bookmark without connecting to the web. .TP .BI \--suggest Show a list of similar tags to choose from when adding a new bookmark. .TP .BI \--tacit Show lesser output. Reduces the verbosity of certain operations like add, update etc. .TP .BI \--nostdin Do not attempt to read data from standard input e.g. when the program is not executed from a tty. .TP .BI \--threads Maximum number of parallel network connection threads to use during full DB refresh. By default 4 connections are spawned. .I N can range from 1 to 10. .TP .BI \-V Check the latest upstream version available. This is FYI. It is possible the latest upstream released version is still not available in your package manager as the process takes a while. .TP .BI \-g " " \--debug Show debug information and additional logs. .SH PROMPT KEYS .TP .BI "1-N" Browse search results by indices and ranges. .TP .BI "O" " [id|range [...]]" Try to open search results or indices (when not in a search context) in a GUI browser. Toggle try to open urls in a GUI based browser (even if BROWSER is set) if no arguments. Toggling is useful when trying to open bookmarks by DB index. .TP .BI "a" Open all search results in browser. .TP .BI "s" " keyword [...]" Search for records with ANY keyword. .TP .BI "S" " keyword [...]" Search for records with ALL keywords. .TP .BI "d" Toggle deep search to match substrings ('pen' matches 'opened'). .TP .BI "m" Search with markers - search string is split into keywords by prefix markers, which determine what field the keywords is searched in: - '.', '>' or ':' - title, description or URL - '#'/'#,' - tags (comma-separated, partial/full match) - '*' - all fields (can be omitted in the 1st keyword) Note: tag marker is not affected by \fBd\fR (deep search) .TP .BI "v" " fields" Change sorting order (default is '+index'). Multiple comma/space separated fields can be specified. .TP .BI "v!" " fields" Update indices in DB to match specified order. .TP .BI "r" " expression" Run a regular expression search. .TP .BI "t" " [...]" Search bookmarks by a tag. List all tags alphabetically, if no arguments. .TP .BI "g" " taglist id|range [...] [>>|>|<<] [record id|range ...]" Append, set, remove specific or all tags by indices and/or ranges to bookmark indices and/or ranges (see \fBEXAMPLES\fR section below). Search by space-separated taglist id(s) and/or range if records are omitted. .TP .BI "n" Display the next page of search results. .TP .BI "N" Display the previous page of search results. .TP .BI "o" " id|range [...]" Browse bookmarks by indices and/or ranges. .TP .BI "p" " id|range [...]" Print bookmarks by indices and/or ranges. .TP .BI "w" " [editor|id]" Edit and add or update a bookmark. .TP .BI "c id" Copy url at search result index to clipboard. .TP .BI "DB" " [name]" If used without \fBname\fR, display list of available DBs (files with '.db' extension in the folder of the current DB). If used with \fBname\fR, switch to the specified DB. You can omit file extension ('.db' will be used), and you can specify a path instead in order to switch a folder (if selected path is a folder, default filename is assumed). If the specified DB file doesn't exist, it will be created. Note: you can use '~.' as a shortcut for default DB. .TP .BI "?" Show help on prompt keys. .TP .BI "q, ^D, double Enter" Exit buku. .SH ENVIRONMENT .TP .BI "Completion scripts" Shell completion scripts for Bash, Fish and Zsh can be found in: .br .I https://github.com/jarun/buku/blob/master/auto-completion .TP .BI BROWSER Overrides the default browser. Refer to: .br .I http://docs.python.org/library/webbrowser.html .TP .BI EDITOR If defined, will be used as the editor to edit bookmarks with option --write. .TP .BI https_proxy If defined, will be used to access http and https resources through the configured proxy. Supported format: .br http[s]://[username:password@]proxyhost:proxyport/ .TP .BI "GUI integration" .B buku can be integrated in a GUI environment with simple tweaks. Please refer to: .br .I https://github.com/jarun/buku/wiki/System-integration .SH COLORS \fBbuku\fR allows you to customize the color scheme via a five-letter string, reminiscent of BSD \fBLSCOLORS\fR. The five letters represent the colors of .IP - 2 index .PD 0 \" Change paragraph spacing to 0 in the list .IP - 2 title .IP - 2 URL .IP - 2 description/comment/note .IP - 2 tag .PD 1 \" Restore paragraph spacing .TP respectively. The five-letter string is passed is as the argument to the \fB--colors\fR option, or as the value of the environment variable \fBBUKU_COLORS\fR. .TP We offer the following colors/styles: .TS tab(;) box; l|l -|- l|l. Letter;Color/Style a;black b;red c;green d;yellow e;blue f;magenta g;cyan h;white i;bright black j;bright red k;bright green l;bright yellow m;bright blue n;bright magenta o;bright cyan p;bright white A-H;bold version of the lowercase-letter color I-P;bold version of the lowercase-letter bright color x;normal X;bold y;reverse video Y;bold reverse video .TE .TP .TP The default colors string is \fIoKlxm\fR, which stands for .IP - 2 bright cyan index .PD 0 \" Change paragraph spacing to 0 in the list .IP - 2 bold bright green title .IP - 2 bright yellow URL .IP - 2 normal description .IP - 2 bright blue tag .PD 1 \" Restore paragraph spacing .TP Note that .IP - 2 Bright colors (implemented as \\x1b[90m - \\x1b[97m) may not be available in all color-capable terminal emulators; .IP - 2 Some terminal emulators draw bold text in bright colors instead; .IP - 2 Some terminal emulators only distinguish between bold and bright colors via a default-off switch. .TP Please consult the manual of your terminal emulator as well as \fIhttps://en.wikipedia.org/wiki/ANSI_escape_code\fR for details. .SH EXAMPLES .PP .IP 1. 4 \fBEdit and add\fR a bookmark from editor: .PP .EX .IP .B buku -w .br .B buku -w 'gedit -w' .br .B buku -w 'macvim -f' -a https://ddg.gg search engine, privacy .EE .PP .IP "" 4 The first command picks editor from the environment variable \fIEDITOR\fR. The second command opens gedit in blocking mode. The third command opens macvim with option -f and the URL and tags populated in template. .PP .IP 2. 4 \fBAdd\fR a simple bookmark: .PP .EX .IP .B buku --nostdin -a https://github.com/ .EE .PP .IP "" 4 In the output, >: url, +: comment, #: tags. .PP .IP 3. 4 \fBAdd\fR a bookmark with \fBtags\fR 'search engine' and 'privacy', \fBcomment\fR 'Search engine with perks', \fBfetch page title\fR from the web: .PP .EX .IP .B buku -a https://ddg.gg search engine, privacy -c Search engine with perks .EE .PP .IP "" 4 In the output, >: url, +: comment, #: tags. .PP .IP 4. 4 \fBAdd\fR a bookmark with tags 'search engine' & 'privacy' and \fBimmutable custom title\fR 'DDG': .PP .EX .IP .B buku -a https://ddg.gg search engine, privacy --title 'DDG' --immutable 1 .EE .PP .IP "" 4 Note that URL must precede tags. .PP .IP 5. 4 \fBAdd\fR a bookmark \fBwithout a title\fR (works for update too): .PP .EX .IP .B buku -a https://ddg.gg search engine, privacy --title .EE .PP .IP 6. 4 \fBEdit and update\fR a bookmark from editor: .PP .EX .IP .B buku -w 15012014 .EE .PP .IP "" 4 This will open the existing bookmark's details in the editor for modifications. Environment variable \fIEDITOR\fR must be set. .PP .IP 7. 4 \fBUpdate\fR existing bookmark at index 15012014 with new URL, tags and comments, fetch title from the web: .PP .EX .IP .B buku -u 15012014 --url http://ddg.gg/ --tag web search, utilities -c Private search engine .EE .PP .IP 8. 4 \fBFetch and update only title\fR for bookmark at 15012014: .PP .EX .IP .B buku -u 15012014 .EE .PP .IP 9. 4 \fBUpdate only comment\fR for bookmark at 15012014: .PP .EX .IP .B buku -u 15012014 -c this is a new comment .EE .PP .IP "" 4 Applies to --url, --title and --tag too. .PP .IP 10. 4 \fBExport\fR bookmarks tagged 'tag 1' or 'tag 2' to HTML, XBEL, Markdown, Orgfile or a new database: .PP .EX .IP .B buku -e bookmarks.html --stag tag 1, tag 2 .br .B buku -e bookmarks.xbel --stag tag 1, tag 2 .br .B buku -e bookmarks.md --stag tag 1, tag 2 .br .B buku -e bookmarks.org --stag tag 1, tag 2 .br .B buku -e bookmarks.db --stag tag 1, tag 2 .EE .PP .IP "" 4 All bookmarks are exported if search is not opted. .PP .IP 11. 4 \fBImport\fR bookmarks from HTML, XBEL, Markdown or Orgfile: .PP .EX .IP .B buku -i bookmarks.html .br .B buku -i bookmarks.xbel .br .B buku -i bookmarks.md .br .B buku -i bookmarks.db .EE .PP .IP 12. 4 \fBDelete only comment\fR for bookmark at 15012014: .PP .EX .IP .B buku -u 15012014 -c .EE .PP .IP "" 4 Applies to --title and --tag too. URL cannot be deleted without deleting the bookmark. .PP .IP 13. 4 \fBUpdate\fR or refresh \fBfull DB\fR with page titles from the web: .PP .EX .IP .B buku -u .br .B buku -u --tacit (show only failures and exceptions) .EE .PP .IP "" 4 This operation can update the title or description fields of non-immutable bookmarks by parsing the fetched page. Fields are updated only if the fetched fields are non-empty. Tags remain untouched. .PP .IP 14. 4 \fBDelete\fR bookmark at index 15012014: .PP .EX .IP .B buku -d 15012014 .EE .PP .IP "" 4 The last index is moved to the deleted index to keep the DB compact. Add --tacit to delete without confirmation. .PP .IP 15. 4 \fBDelete all\fR bookmarks: .PP .EX .IP .B buku -d .EE .PP .IP 16. 4 \fBDelete\fR a \fBrange or list\fR of bookmarks: .PP .EX .IP .B buku -d 100-200 .br .B buku -d 100 15 200 .EE .PP .IP 17. 4 \fBSearch\fR bookmarks for \fBANY\fR of the keywords 'kernel' and 'debugging' in URL, title or tags: .PP .EX .IP .B buku kernel debugging .br .B buku -s kernel debugging .EE .PP .IP 18. 4 \fBSearch\fR bookmarks with \fBALL\fR the keywords 'kernel' and 'debugging' in URL, title or tags: .PP .EX .IP .B buku -S kernel debugging .EE .PP .IP 19. 4 \fBSearch\fR bookmarks \fBtagged\fR 'general kernel concepts': .PP .EX .IP .B buku --stag general kernel concepts .EE .PP .IP 20. 4 \fBSearch\fR for bookmarks matching \fBANY\fR of the tags 'kernel', 'debugging', 'general kernel concepts': .PP .EX .IP .B buku --stag kernel, debugging, general kernel concepts .EE .PP .IP 21. 4 \fBSearch\fR for bookmarks matching \fBALL\fR of the tags 'kernel', 'debugging', 'general kernel concepts': .PP .EX .IP .B buku --stag kernel + debugging + general kernel concepts .EE .PP .IP 22. 4 \fBSearch\fR for bookmarks matching any of the keywords 'hello' or 'world', excluding the keywords 'real' and 'life', matching both the tags 'kernel' and 'debugging', but \fBexcluding\fR the tags 'general kernel concepts' and 'books': .PP .EX .IP .B buku hello world --exclude real life --stag 'kernel + debugging - general kernel concepts, books' .EE .PP .IP 23. 4 \fBSearch\fR for bookmarks with different tokens for each field, and print them out sorted by the tags (ascending) and URL (descending) .PP .EX .IP .B buku --order +tags,-url --markers --sall 'global substring' '.title substring' ':url substring' :https '> description substring' '#partial,tags:' '#,exact,tags' '*another global substring' .EE .PP .IP 24. 4 List \fBall unique tags\fR alphabetically: .PP .EX .IP .B buku --stag .EE .PP .IP 25. 4 Run a \fBsearch and update\fR the results: .PP .EX .IP .B buku -s kernel debugging -u --tag + linux kernel .EE .PP .IP 26. 4 Run a \fBsearch and delete\fR the results: .PP .EX .IP .B buku -s kernel debugging -d .EE .PP .IP 27. 4 \fBEncrypt or decrypt\fR DB with \fBcustom number of iterations\fR (15) to generate key: .PP .EX .IP .B buku -l 15 .br .B buku -k 15 .EE .PP .IP "" 4 The same number of iterations must be specified for one lock & unlock instance. Default is 8, if omitted. .PP .IP 28. 4 \fBShow details\fR of bookmarks at index 15012014 and ranges 20-30, 40-50: .PP .EX .IP .B buku -p 20-30 15012014 40-50 .EE .PP .IP 29. 4 Show details of the \fBlast 10 bookmarks\fR: .PP .EX .IP .B buku -p -10 .EE .PP .IP 30. 4 \fBShow all\fR bookmarks with real index from database: .PP .EX .IP .B buku -p .br .B buku -p | more .EE .PP .IP 31. 4 \fBReplace tag\fR 'old tag' with 'new tag': .PP .EX .IP .B buku --replace 'old tag' 'new tag' .EE .PP .IP 32. 4 \fBDelete tag\fR 'old tag' from DB: .PP .EX .IP .B buku --replace 'old tag' .EE .PP .IP 33. 4 \fBAppend (or delete) tags\fR 'tag 1', 'tag 2' to (or from) existing tags of bookmark at index 15012014: .PP .EX .IP .B buku -u 15012014 --tag + tag 1, tag 2 .br .B buku -u 15012014 --tag - tag 1, tag 2 .EE .PP .IP 34. 4 \fBOpen URL\fR at index 15012014 in browser: .PP .EX .IP .B buku -o 15012014 .EE .PP .IP 35. 4 List bookmarks with \fBno title or tags\fR for bookkeeping: .PP .EX .IP .B buku -S blank .EE .PP .IP 36. 4 List bookmarks with \fBimmutable title\fR: .PP .EX .IP .B buku -S immutable .EE .PP .IP 37. 4 \fBAppend, remove tags at prompt\fR (taglist index to the left, bookmark index to the right): .PP .EX .IP // append tags at taglist indices 4 and 6-9 to existing tags in bookmarks at indices 5 and 2-3 .br .B buku (? for help) g 4 9-6 >> 5 3-2 .br // set tags at taglist indices 4 and 6-9 as tags in bookmarks at indices 5 and 2-3 .br .B buku (? for help) g 4 9-6 > 5 3-2 .br // remove all tags from bookmarks at indices 5 and 2-3 .br .B buku (? for help) g > 5 3-2 .br // remove tags at taglist indices 4 and 6-9 from tags in bookmarks at indices 5 and 2-3 .br .B buku (? for help) g 4 9-6 << 5 3-2 .EE .PP .IP 38. 4 List bookmarks with \fBcolored output\fR: .PP .EX .IP .B $ buku --colors oKlxm -p .EE .PP .IP 39. 4 Add a bookmark after following all permanent redirects, but only if the server doesn't respond with an error (and there's no network failure) .PP .EX .IP .B buku --add http://wikipedia.net --url-redirect --del-error .br 2. Wikipedia .br > https://www.wikipedia.org/ .br + Wikipedia is a free online encyclopedia, created and edited by volunteers around the world and hosted by the Wikimedia Foundation. .EE .PP .IP 40. 4 Add a bookmark with tag 'http redirect' if the server responds with a permanent redirect, or tag shaped like 'http 404' on an error response: .PP .EX .IP .B buku --add http://wikipedia.net/notfound --tag-redirect 'http redirect' --tag-error 'http {}' .br [ERROR] [404] Not Found .br 3. Not Found .br > http://wikipedia.net/notfound .br # http 404,http redirect .EE .PP .IP 41. 4 Update all bookmarks matching the search by updating the URL if the server responds with a permanent redirect, deleting the bookmark if the server responds with HTTP error 400, 401, 402, 403, 404 or 500, or adding a tag shaped like 'http:{}' in case of any other HTTP error; then export those affected by such changes into an HTML file, marking deleted records as well as old URLs for those replaced by redirect. .PP .EX .IP .B buku -S ://wikipedia.net -u --url-redirect --tag-error --del-error 400-404,500 --export-on --export backup.html .EE .PP .IP 42. 4 Print out a single \fBrandom\fR bookmark: .PP .EX .IP .B buku --random .EE .PP .IP 43. 4 Print out 3 \fBrandom\fR bookmarks \fBordered\fR by netloc (reversed), title and url: .PP .EX .IP .B buku --random 3 --order ,-netloc,title,+url .EE .PP .IP 44. 4 Print out a single \fBrandom\fR bookmark matching \fBsearch\fR criteria, and \fBexport\fR into a Markdown file (in DB order): .PP .EX .IP .B buku --random -S kernel debugging --export random.md .EE .PP .IP 45. 4 Swap positions of records #4 and #5: .PP .EX .IP .B buku --swap 4 5 .EE .PP .IP 46. 4 Update indices in all bookmarks to match specified order: .PP .EX .IP .B buku --reorder ,-netloc,title,+url .EE .PP .SH AUTHOR Arun Prakash Jana <engineerarun@gmail.com> .SH HOME .I https://github.com/jarun/buku .SH WIKI .I https://github.com/jarun/buku/wiki .SH REPORTING BUGS .I https://github.com/jarun/buku/issues .SH LICENSE Copyright \(co 2015-2026 Arun Prakash Jana <engineerarun@gmail.com>. .PP License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>. .br This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. ================================================ FILE: buku.py ================================================ #!/usr/bin/env python3 # # Bookmark management utility # # Copyright © 2015-2026 Arun Prakash Jana <engineerarun@gmail.com> # # 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 buku. If not, see <http://www.gnu.org/licenses/>. from __future__ import annotations # for | import argparse import calendar import codecs import collections import contextlib import email.message import json import locale import logging import os import platform import random import re import shutil import signal import sqlite3 import struct import subprocess import sys import tempfile import textwrap import threading import time import unicodedata import webbrowser from enum import Enum from itertools import chain from functools import total_ordering from subprocess import DEVNULL, PIPE, Popen from typing import Any, Dict, List, Optional, Tuple, NamedTuple, TypeAlias, TypeVar from collections.abc import Sequence, Set, Callable from warnings import warn import xml.etree.ElementTree as ET from urllib.parse import urlparse # urllib3.util.parse_url() encodes netloc import urllib3 from bs4 import BeautifulSoup from bs4.dammit import EncodingDetector from urllib3.util import Retry, make_headers try: from mypy_extensions import TypedDict except ImportError: TypedDict = None # type: ignore __version__ = '5.1' __author__ = 'Arun Prakash Jana <engineerarun@gmail.com>' __license__ = 'GPLv3' # Global variables INTERRUPTED = False # Received SIGINT DELIM = ',' # Delimiter used to store tags in DB SKIP_MIMES = {'.pdf', '.txt'} PROMPTMSG = 'buku (? for help): ' # Prompt message string strip_delim = lambda s, delim=DELIM, sub=' ': str(s).replace(delim, sub) taglist = lambda ss: sorted(set(s.lower().strip() for s in ss if (s or '').strip())) parse_order = lambda order: [s for ss in order for s in re.split(r'\s*,\s*', ss.strip()) if s] like_escape = lambda s, c='`': s.replace(c, c+c).replace('_', c+'_').replace('%', c+'%') split_by_marker = lambda s: re.split(r'\s+(?=[.:>#*])', s) def taglist_str(tag_str, convert=None): tags = taglist(tag_str.split(DELIM)) return delim_wrap(DELIM.join(tags if not convert else taglist(convert(tags)))) def filter_from(values, subset, *, exclude=False): subset, exclude = set(subset), bool(exclude) return [x for x in values if (x in subset) != exclude] # Default format specifiers to print records ID_STR = '%d. %s [%s]\n' ID_DB_STR = '%d. %s' MUTE_STR = '%s (L)\n' URL_STR = ' > %s\n' DESC_STR = ' + %s\n' DESC_WRAP = '%s%s' TAG_STR = ' # %s\n' TAG_WRAP = '%s%s' # Colormap for color output from "googler" project COLORMAP = {k: '\x1b[%sm' % v for k, v in { 'a': '30', 'b': '31', 'c': '32', 'd': '33', 'e': '34', 'f': '35', 'g': '36', 'h': '37', 'i': '90', 'j': '91', 'k': '92', 'l': '93', 'm': '94', 'n': '95', 'o': '96', 'p': '97', 'A': '30;1', 'B': '31;1', 'C': '32;1', 'D': '33;1', 'E': '34;1', 'F': '35;1', 'G': '36;1', 'H': '37;1', 'I': '90;1', 'J': '91;1', 'K': '92;1', 'L': '93;1', 'M': '94;1', 'N': '95;1', 'O': '96;1', 'P': '97;1', 'x': '0', 'X': '1', 'y': '7', 'Y': '7;1', 'z': '2', }.items()} # DB flagset values [FLAG_NONE, FLAG_IMMUTABLE] = [0x00, 0x01] FIELD_FILTER = { 1: ('id', 'url'), 2: ('id', 'url', 'tags'), 3: ('id', 'title'), 4: ('id', 'url', 'title', 'tags'), 5: ('id', 'title', 'tags'), 10: ('url',), 20: ('url', 'tags'), 30: ('title',), 40: ('url', 'title', 'tags'), 50: ('title', 'tags'), } ALL_FIELDS = ('id', 'url', 'title', 'desc', 'tags') JSON_FIELDS = {'id': 'index', 'url': 'uri', 'desc': 'description'} USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0' MYHEADERS = None # Default dictionary of headers MYPROXY = None # Default proxy TEXT_BROWSERS = ['elinks', 'links', 'links2', 'lynx', 'w3m', 'www-browser'] IGNORE_FF_BOOKMARK_FOLDERS = frozenset(["placesRoot", "bookmarksMenuFolder"]) PERMANENT_REDIRECTS = {301, 308} SCHEME_HTTP = 'http' IntSet: TypeAlias = Set[int] | range Ints: TypeAlias = Sequence[int] | IntSet IntOrInts: TypeAlias = int | Ints _T = TypeVar('T') # pylint: disable=typevar-name-mismatch Values: TypeAlias = Sequence[_T] | Set[_T] # Set up logging LOGGER = logging.getLogger() LOGDBG = LOGGER.debug LOGERR = LOGGER.error # Define the default path to ca-certificates # In Linux distros with openssl, it is /etc/ssl/certs/ca-certificates.crt # Fall back to use `certifi` otherwise if sys.platform.startswith('linux') and os.path.isfile('/etc/ssl/certs/ca-certificates.crt'): CA_CERTS = '/etc/ssl/certs/ca-certificates.crt' else: import certifi CA_CERTS = certifi.where() class BukuCrypt: """Class to handle encryption and decryption of the database file. Functionally a separate entity. Involves late imports in the static functions but it saves ~100ms each time. Given that encrypt/decrypt are not done automatically and any one should be called at a time, this doesn't seem to be an outrageous approach. """ # Crypto constants BLOCKSIZE = 0x10000 # 64 KB blocks SALT_SIZE = 0x20 CHUNKSIZE = 0x80000 # Read/write 512 KB chunks @staticmethod def get_filehash(filepath): """Get the SHA256 hash of a file. Parameters ---------- filepath : str Path to the file. Returns ------- hash : bytes Hash digest of file. """ from hashlib import sha256 with open(filepath, 'rb') as fp: hasher = sha256() buf = fp.read(BukuCrypt.BLOCKSIZE) while len(buf) > 0: hasher.update(buf) buf = fp.read(BukuCrypt.BLOCKSIZE) return hasher.digest() @staticmethod def encrypt_file(iterations=8, dbfile=None, encfile=None, password=None, replace=True): """Encrypt the bookmarks database file. Parameters ---------- iterations : int Number of iterations for key generation. (Defaults to 8) dbfile : str, optional Custom database file path (including filename). Fallback value is the default DB. encfile : str, optional Encoded dbfile. (Defaults to dbfile + '.enc') password : str, optional Password to use (if not provided, will be prompted from the user). replace : bool If True (default), the original file will be removed on success. """ BukuCrypt(iterations, dbfile, encfile, password, replace)._encrypt_file() @staticmethod def decrypt_file(iterations=8, dbfile=None, encfile=None, password=None, replace=True): """Decrypt the bookmarks database file. Parameters ---------- iterations : int Number of iterations for key generation. (Defaults to 8) dbfile : str, optional Custom database file path (including filename). The '.enc' suffix must be omitted. encfile : str, optional Encoded dbfile. (Defaults to dbfile + '.enc') password : str, optional Password to use (if not provided, will be prompted from the user). replace : bool If True (default), the original file will be removed on success. """ BukuCrypt(iterations, dbfile, encfile, password, replace)._decrypt_file() def __init__(self, iterations=8, dbfile=None, encfile=None, password=None, replace=True): try: from getpass import getpass from hashlib import sha256 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes except ImportError as e: raise RuntimeError('cryptography lib(s) missing') from e self._sha256, self._default_backend = sha256, default_backend self._Cipher, self._algorithms, self._modes = Cipher, algorithms, modes self._getpass = (getpass if sys.stdin.isatty() else (lambda: sys.stdin.readline().rstrip('\n'))) if iterations < 1: raise RuntimeError('Iterations must be >= 1') self.iterations, self.password, self.replace = iterations, password, replace self.dbfile = dbfile or os.path.join(BukuDb.get_default_dbdir(), 'bookmarks.db') self.encfile = encfile or (self.dbfile + '.enc') self._db_exists = os.path.exists(self.dbfile) self._enc_exists = os.path.exists(self.encfile) if self._db_exists and self._enc_exists: raise RuntimeError('Both encrypted and flat DB files exist!') def _encrypt_file(self): if not self._db_exists: raise RuntimeError(f'{self.dbfile} missing. Already encrypted?') if not self.password: self.password = self._getpass() if not self.password: raise RuntimeError('Empty password') passconfirm = self._getpass() if not passconfirm: raise RuntimeError('Empty password') if self.password != passconfirm: raise RuntimeError('Passwords do not match') try: self._encrypt() if self.replace: os.remove(self.dbfile) except Exception as e: with contextlib.suppress(FileNotFoundError): os.remove(self.encfile) raise RuntimeError(e) from e def _decrypt_file(self): if not self._enc_exists: raise RuntimeError(f'{self.encfile} missing') self.password = self.password or self._getpass() if not self.password: raise RuntimeError('Empty password') try: enchash = self._decrypt() # Match hash of generated file with that of original DB file dbhash = BukuCrypt.get_filehash(self.dbfile) if dbhash != enchash: os.remove(self.dbfile) raise RuntimeError('Decryption failed') if self.replace: os.remove(self.encfile) except struct.error as e: with contextlib.suppress(FileNotFoundError): os.remove(self.dbfile) raise RuntimeError('Tainted file') from e except Exception as e: with contextlib.suppress(FileNotFoundError): os.remove(self.dbfile) raise RuntimeError(e) from e def _cipher(self, key, iv): return self._Cipher(self._algorithms.AES(key), self._modes.CBC(iv), backend=self._default_backend()) def _key(self, salt): key = ('%s%s' % (self.password, salt.decode('utf-8', 'replace'))).encode('utf-8') for _ in range(self.iterations): key = self._sha256(key).digest() return key def _encrypt(self): # Get SHA256 hash of DB file dbhash = BukuCrypt.get_filehash(self.dbfile) # Generate random 256-bit salt and key salt = os.urandom(BukuCrypt.SALT_SIZE) iv = os.urandom(16) encryptor = self._cipher(self._key(salt), iv).encryptor() filesize = os.path.getsize(self.dbfile) with open(self.dbfile, 'rb') as infp, open(self.encfile, 'wb') as outfp: outfp.write(struct.pack('<Q', filesize)) outfp.write(salt) outfp.write(iv) # Embed DB file hash in encrypted file outfp.write(dbhash) while chunk := infp.read(BukuCrypt.CHUNKSIZE): if len(chunk) % 16 != 0: chunk = b'%b%b' % (chunk, b' ' * (16 - len(chunk) % 16)) outfp.write(encryptor.update(chunk)) outfp.write(encryptor.finalize()) return dbhash def _decrypt(self): with open(self.encfile, 'rb') as infp: size = struct.unpack('<Q', infp.read(struct.calcsize('Q')))[0] # Read 256-bit salt and generate key salt = infp.read(32) iv = infp.read(16) decryptor = self._cipher(self._key(salt), iv).decryptor() # Get original DB file's SHA256 hash from encrypted file enchash = infp.read(32) with open(self.dbfile, 'wb') as outfp: while chunk := infp.read(BukuCrypt.CHUNKSIZE): outfp.write(decryptor.update(chunk)) outfp.write(decryptor.finalize()) outfp.truncate(size) return enchash @total_ordering class SortKey: def __init__(self, value, ascending=True): self.value, self.ascending = value, bool(ascending) def __eq__(self, other): other = (other.value if isinstance(other, SortKey) else other) return self.value == other def __lt__(self, other): other = (other.value if isinstance(other, SortKey) else other) return self.value != other and ((self.value < other) == self.ascending) def __repr__(self): return ('+' if self.ascending else '-') + repr(self.value) class FetchResult(NamedTuple): url: str # resulting URL after following PERMANENT redirects title: str = '' desc: str = '' keywords: str = '' mime: bool = False bad: bool = False fetch_status: Optional[int] = None # None means no fetch occurred (e.g. due to a network error) def tag_redirect(self, pattern: str = None) -> str: return ('' if self.fetch_status not in PERMANENT_REDIRECTS else (pattern or 'http:{}').format(self.fetch_status)) def tag_error(self, pattern: str = None) -> str: return ('' if (self.fetch_status or 0) < 400 else (pattern or 'http:{}').format(self.fetch_status)) def tags(self, *, keywords: bool = True, redirect: bool | str = False, error: bool | str = False) -> str: _redirect = redirect and self.tag_redirect(None if redirect is True else redirect) _error = error and self.tag_error(None if error is True else error) return DELIM.join(taglist((keywords and self.keywords or '').split(DELIM) + [_redirect, _error])) class BookmarkVar(NamedTuple): """Bookmark data named tuple""" id: int url: str title: Optional[str] = None tags_raw: str = '' desc: str = '' flags: int = FLAG_NONE @property def immutable(self) -> bool: return bool(self.flags & FLAG_IMMUTABLE) @property def tags(self) -> str: return self.tags_raw[1:-1] @property def taglist(self) -> List[str]: return [x for x in self.tags_raw.split(',') if x] @property def netloc(self) -> str: return get_netloc(self.url) or '' bookmark_vars = lambda xs: ((x if isinstance(x, BookmarkVar) else BookmarkVar(*x)) for x in xs) class BukuDb: """Abstracts all database operations. Attributes ---------- conn : sqlite database connection. cur : sqlite database cursor. json : string Empty string if results should be printed in JSON format to stdout. Nonempty string if results should be printed in JSON format to file. The string has to be a valid path. None if the results should be printed as human-readable plaintext. field_filter : int Indicates format for displaying bookmarks. Default is 0. chatty : bool Sets the verbosity of the APIs. Default is False. """ def __init__( self, json: Optional[str] = None, field_filter: int = 0, chatty: bool = False, dbfile: Optional[str] = None, colorize: bool = True, default_scheme: str = SCHEME_HTTP) -> None: """Database initialization API. Parameters ---------- json : string Empty string if results should be printed in JSON format to stdout. Nonempty string if results should be printed in JSON format to file. The string has to be a valid path. None if the results should be printed as human-readable plaintext. field_filter : int Indicates format for displaying bookmarks. Default is 0. chatty : bool Sets the verbosity of the APIs. Default is False. colorize : bool Indicates whether color should be used in output. Default is True. default_scheme : str Scheme to assume if missing from bookmark's URI. Default is http. """ self.json = json self.field_filter = field_filter self.chatty = chatty self.colorize = colorize self.conn, self.cur = BukuDb.initdb(dbfile, self.chatty) self.lock = threading.RLock() # repeatable lock, only blocks *concurrent* access self._to_export = None # type: Optional[Dict[str, str | BookmarkVar]] self._to_delete = None # type: Optional[int | Sequence[int] | Set[int] | range] self.default_scheme = default_scheme @staticmethod def get_default_dbdir(): """Determine the directory path where dbfile will be stored. If $BUKU_DEFAULT_DBDIR is specified, use it else if $XDG_DATA_HOME is defined, use $XDG_DATA_HOME/buku else if $HOME exists, use $HOME/.local/share/buku else if the platform is Windows and %APPDATA% exists, use %APPDATA%\\buku else use the current directory. Returns ------- str Path to database file. """ _get = os.environ.get if _get('BUKU_DEFAULT_DBDIR'): return os.path.abspath(_get('BUKU_DEFAULT_DBDIR')) home_locations = [ _get('XDG_DATA_HOME'), _get('HOME') and os.path.join(_get('HOME'), '.local', 'share'), sys.platform == 'win32' and _get('APPDATA'), ] data_home = next((s for s in home_locations if s), None) return (os.path.join(data_home, 'buku') if data_home else os.getcwd()) @staticmethod def initdb(dbfile: Optional[str] = None, chatty: bool = False) -> Tuple[sqlite3.Connection, sqlite3.Cursor]: """Initialize the database connection. Create DB file and/or bookmarks table if they don't exist. Alert on encryption options on first execution. Parameters ---------- dbfile : str, optional Custom database file path (including filename). chatty : bool If True, shows informative message on DB creation. Returns ------- tuple (connection, cursor). """ if not dbfile: dbpath = BukuDb.get_default_dbdir() filename = 'bookmarks.db' dbfile = os.path.join(dbpath, filename) else: dbfile = os.path.abspath(dbfile) dbpath, filename = os.path.split(dbfile) try: if not os.path.exists(dbpath): os.makedirs(dbpath) except Exception as e: LOGERR(e) os._exit(1) db_exists = os.path.exists(dbfile) enc_exists = os.path.exists(dbfile + '.enc') if db_exists and not enc_exists: pass elif enc_exists and not db_exists: LOGERR('Unlock database first') sys.exit(1) elif db_exists and enc_exists: LOGERR('Both encrypted and flat DB files exist!') sys.exit(1) elif chatty: # not db_exists and not enc_exists print('DB file is being created at %s.\nYou should encrypt it.' % dbfile) try: # Create a connection conn = sqlite3.connect(dbfile, check_same_thread=False) conn.create_function('REGEXP', 2, regexp) conn.create_function('NETLOC', 1, get_netloc) cur = conn.cursor() # Create table if it doesn't exist # flags: designed to be extended in future using bitwise masks # Masks: # 0b00000001: set title immutable cur.execute('CREATE TABLE if not exists bookmarks (' 'id integer PRIMARY KEY, ' 'URL text NOT NULL UNIQUE, ' 'metadata text default \'\', ' 'tags text default \',\', ' 'desc text default \'\', ' 'flags integer default 0)') conn.commit() except Exception as e: LOGERR('initdb(): %s', e) raise e return (conn, cur) @property def dbfile(self) -> str: return next(path for _, name, path in self.conn.execute('PRAGMA database_list') if name == 'main') @property def dbname(self) -> str: return os.path.basename(self.dbfile).removesuffix('.db') def _fetch(self, query: str, *args, lock: bool = True) -> List[BookmarkVar]: if not lock: self.cur.execute(query, args) return [BookmarkVar(*x) for x in self.cur.fetchall()] with self.lock: return self._fetch(query, *args, lock=False) def _fetch_first(self, query: str, *args, lock: bool = True) -> Optional[BookmarkVar]: rows = self._fetch(query + ' LIMIT 1', *args, lock=lock) return rows[0] if rows else None def _ordering(self, fields=['+id'], for_db=True) -> List[Tuple[str, bool]]: """Converts field list to ordering parameters (for DB query or entity list sorting). Fields are listed in priority order, with '+'/'-' prefix signifying ASC/DESC; assuming ASC if not specified. Other than names from DB, you can pass those from JSON export.""" _field = lambda s: re.sub(r'^[+-]?(#)? *', r'\1', s).rstrip().lower() tags = {_field(s) for s in (fields or []) if re.fullmatch(r'[+-]?#[^,]+', s)} names = {'index': 'id', 'uri': 'url', 'description': 'desc', **({'title': 'metadata'} if for_db else {'metadata': 'title'})} valid = list(names) + list(names.values()) + ['tags', 'netloc'] + list(tags) _fields = [(_field(s), not s.startswith('-')) for s in (fields or [])] _fields = [(names.get(field, field), direction) for field, direction in _fields if field in valid] return _fields or [('id', True)] def _sort(self, records: List[BookmarkVar], fields=['+id'], ignore_case=True) -> List[BookmarkVar]: text_fields = (set() if not ignore_case else {'url', 'desc', 'title', 'tags', 'netloc'}) get = lambda x, k: (k[1:] in x.taglist if k.startswith('#') else getattr(x, k) if k not in text_fields else str(getattr(x, k) or '').lower()) order = self._ordering(fields, for_db=False) return sorted(bookmark_vars(records), key=lambda x: [SortKey(get(x, k), ascending=asc) for k, asc in order]) def _order(self, fields=['+id'], ignore_case=True) -> str: """Converts field list to SQL 'ORDER BY' parameters. (See also BukuDb._ordering().)""" text_fields = (set() if not ignore_case else {'url', 'desc', 'metadata', 'tags'}) get = lambda field: ("tags LIKE '%,{0},%'".format(field[1:].replace("'", "''")) if field.startswith('#') else 'LOWER(NETLOC(url))' if field == 'netloc' else field if field not in text_fields else f'LOWER({field})') return ', '.join(f'{get(field)} {"ASC" if direction else "DESC"}' for field, direction in self._ordering(fields)) def get_rec_all(self, *, lock: bool = True, order: List[str] = ['id'], ignore_case: bool = True): """Get all the bookmarks in the database. Parameters ---------- lock : bool Whether to restrict concurrent access (True by default). order : list of str Order description (fields from JSON export or DB, prepended with '+'/'-' for ASC/DESC). ignore_case : bool Whether to ignore case when applying order (True by default). Returns ------- list A list of tuples representing bookmark records. """ return self._fetch(f'SELECT * FROM bookmarks ORDER BY {self._order(order, ignore_case)}', lock=lock) def get_rec_by_id(self, index: int, *, lock: bool = True) -> Optional[BookmarkVar]: """Get a bookmark from database by its ID. Parameters ---------- index : int DB index of bookmark record. lock : bool Whether to restrict concurrent access (True by default). Returns ------- BookmarkVar or None Bookmark data, or None if index is not found. """ return self._fetch_first('SELECT * FROM bookmarks WHERE id = ?', index, lock=lock) def get_rec_all_by_ids(self, indices: Ints, *, lock: bool = True, order: List[str] = ['id']): """Get all the bookmarks in the database. Parameters ---------- indices : int[] | int{} | range DB indices of bookmark records. lock : bool Whether to restrict concurrent access (True by default). order : list of str Order description (fields from JSON export or DB, prepended with '+'/'-' for ASC/DESC). Returns ------- list A list of tuples representing bookmark records. """ _order, placeholder = self._order(order), ', '.join(['?'] * len(indices)) return indices and self._fetch(f'SELECT * FROM bookmarks WHERE id IN ({placeholder}) ORDER BY {_order}', *list(indices), lock=lock) def get_rec_id(self, url: str, *, lock: bool = True): """Check if URL already exists in DB. Parameters ---------- url : str A URL to search for in the DB. lock : bool Whether to restrict concurrent access (True by default). Returns ------- int DB index, or None if URL not found in DB. """ row = self._fetch_first('SELECT * FROM bookmarks WHERE url = ?', url, lock=lock) return row and row.id def get_rec_ids(self, urls: Values[str], *, lock: bool = True): """Check if URL already exists in DB. Parameters ---------- urls : str[] | str{} URLs to search for in the DB. lock : bool Whether to restrict concurrent access (True by default). Returns ------- list A list of DB indices. """ if not urls: return [] if not lock: placeholder = ', '.join(['?'] * len(urls)) self.cur.execute(f'SELECT id FROM bookmarks WHERE url IN ({placeholder})', list(urls)) return [x[0] for x in self.cur.fetchall()] with self.lock: return self.get_rec_ids(urls, lock=False) def get_max_id(self, *, lock: bool = True) -> int: """Fetch the ID of the last record. Parameters ---------- lock : bool Whether to restrict concurrent access (True by default). Returns ------- int ID of the record if any record exists, else None. """ if not lock: self.cur.execute('SELECT MAX(id) FROM bookmarks') return self.cur.fetchall()[0][0] with self.lock: return self.get_max_id(lock=False) def reorder(self, order: List[str], *, ignore_case=True): """Change indices of all records in DB to match the specified order. Parameters ---------- order : list of str Order description (fields from JSON export or DB, prepended with '+'/'-' for ASC/DESC). ignore_case : bool Whether to ignore case when applying order (True by default). """ with self.lock: sorted_urls = [x.url for x in self.get_rec_all(lock=False, order=order, ignore_case=ignore_case)] self.cur.execute('UPDATE bookmarks SET id = -id') for idx, url in enumerate(sorted_urls, start=1): self.cur.execute('UPDATE bookmarks SET id = ? WHERE url = ?', (idx, url)) self.conn.commit() self.cur.execute('VACUUM') def add_rec( self, url: str, title_in: Optional[str] = None, tags_in: Optional[str] = None, desc: Optional[str] = None, immutable: bool = False, delay_commit: bool = False, fetch: bool = True, url_redirect: bool = False, tag_redirect: bool | str = False, tag_error: bool | str = False, del_error: Optional[IntSet] = None, tags_fetch: bool = True, tags_except: Optional[str] = None) -> int: """Add a new bookmark. Parameters ---------- url : str URL to bookmark. title_in : str, optional Title to add manually. Default is None. tags_in : str, optional Comma-separated tags to add manually, instead of fetching them. Default is None. tags_except : str, optional These are removed from the resulting tags list. Default is None. tags_fetch : bool True if tags parsed from the fetched page should be included. Default is True. desc : str, optional Description of the bookmark. Default is None. immutable : bool Indicates whether to disable title fetch from web. Default is False. delay_commit : bool True if record should not be committed to the DB, leaving commit responsibility to caller. Default is False. fetch : bool Fetch page from web and parse for data. Required fetch-status params to take effect. url_redirect : bool Bookmark the URL produced after following all PERMANENT redirects. tag_redirect : bool | str Adds a tag by the given pattern if the url resolved to a PERMANENT redirect. (True means the default pattern 'http:{}'.) tag_error : bool | str Adds a tag by the given pattern if the url resolved to a HTTP error. (True means the default pattern 'http:{}'.) del_error : int{} | range, optional Do not add the bookmark if HTTP response status is in the given set or range. Also prevents the bookmark from being added on a network error. Returns ------- int DB index of new bookmark on success, None on failure. """ # Return error for empty URL if not url: LOGERR('Invalid URL') return None # Ensure that the URL does not exist in DB already id = self.get_rec_id(url) if id: LOGERR('URL [%s] already exists at index %d', url, id) return None if fetch: # Fetch data result = fetch_data(url) if result.bad: print('Malformed URL\n') elif result.mime: LOGDBG('HTTP HEAD requested') elif not result.title and title_in is None: print('No title\n') else: LOGDBG('Title: [%s]', result.title) else: result = FetchResult(url, fetch_status=200) LOGDBG('ptags: [%s]', result.tags(redirect=tag_redirect, error=tag_error)) url = (result.url if url_redirect else url) title = (title_in if title_in is not None else result.title) # Fix up tags, if broken tags_exclude = set(taglist((tags_except or '').split(DELIM))) tags_fetched = result.tags(keywords=tags_fetch, redirect=tag_redirect, error=tag_error) tags = taglist_str((tags_in or '') + DELIM + tags_fetched, lambda ss: [s for s in ss if s not in tags_exclude]) LOGDBG('tags: [%s]', tags) # Process description desc = (desc if desc is not None else result.desc) or '' try: assert not del_error or result.fetch_status is not None, 'Network error' assert not del_error or result.fetch_status not in del_error, f'HTTP error {result.fetch_status}' flagset = FLAG_NONE if immutable: flagset |= FLAG_IMMUTABLE qry = 'INSERT INTO bookmarks(URL, metadata, tags, desc, flags) VALUES (?, ?, ?, ?, ?)' with self.lock: self.cur.execute(qry, (url, title, tags, desc, flagset)) if not delay_commit: self.conn.commit() if self.chatty: self.print_rec(self.cur.lastrowid) return self.cur.lastrowid except Exception as e: LOGERR('add_rec(): %s', e) return None def append_tag_at_index(self, index, tags_in, delay_commit=False): """Append tags to bookmark tagset at index. Parameters ---------- index : int | int[] | int{} | range, optional DB index of the record. 0 or empty indicates all records. tags_in : str Comma-separated tags to add manually. delay_commit : bool True if record should not be committed to the DB, leaving commit responsibility to caller. Default is False. Returns ------- bool True on success, False on failure. """ if tags_in is None or tags_in == DELIM: return True indices = (None if not index else [index] if isinstance(index, int) else index) with self.lock: if not indices: resp = read_in('Append the tags to ALL bookmarks? (y/n): ') if resp != 'y': return False self.cur.execute('SELECT id, tags FROM bookmarks ORDER BY id ASC') else: placeholder = ', '.join(['?'] * len(indices)) self.cur.execute(f'SELECT id, tags FROM bookmarks WHERE id IN ({placeholder}) ORDER BY id ASC', tuple(indices)) resultset = self.cur.fetchall() if resultset: query = 'UPDATE bookmarks SET tags = ? WHERE id = ?' for row in resultset: tags = row[1] + tags_in[1:] tags = parse_tags([tags]) self.cur.execute(query, (tags, row[0],)) if self.chatty and not delay_commit: self.print_rec(row[0]) else: return False if not delay_commit: self.conn.commit() return True def delete_tag_at_index(self, index, tags_in, delay_commit=False, chatty=True): """Delete tags from bookmark tagset at index. Parameters ---------- index : int | int[] | int{} | range, optional DB index of bookmark record. 0 or empty indicates all records. tags_in : str Comma-separated tags to delete manually. delay_commit : bool True if record should not be committed to the DB, leaving commit responsibility to caller. Default is False. chatty: bool Skip confirmation when set to False. Returns ------- bool True on success, False on failure. """ if tags_in is None or tags_in == DELIM: return True tags_to_delete = tags_in.strip(DELIM).split(DELIM) indices = (None if not index else [index] if isinstance(index, int) else index) if len(indices or []) != 1: if not indices and chatty: resp = read_in('Delete the tag(s) from ALL bookmarks? (y/n): ') if resp != 'y': return False query = "UPDATE bookmarks SET tags = replace(tags, ?, ?) WHERE tags LIKE ? ESCAPE '`'" if indices: query += ' AND id IN ({})'.format(', '.join(['?'] * len(indices))) count = 0 with self.lock: for tag in tags_to_delete: tag = delim_wrap(tag) args = (tag, DELIM, '%'+like_escape(tag, '`')+'%') + tuple(indices or []) self.cur.execute(query, args) count += self.cur.rowcount if count > 0 and not delay_commit: self.conn.commit() if self.chatty: print('%d record(s) updated' % count) return True # Process a single index # Use SELECT and UPDATE to handle multiple tags at once with self.lock: query = 'SELECT id, tags FROM bookmarks WHERE id = ? LIMIT 1' self.cur.execute(query, list(indices)) resultset = self.cur.fetchall() if not resultset: return False query = 'UPDATE bookmarks SET tags = ? WHERE id = ?' for row in resultset: tags = row[1] for tag in tags_to_delete: tags = tags.replace(delim_wrap(tag), DELIM) self.cur.execute(query, (parse_tags([tags]), row[0],)) if self.chatty and not delay_commit: self.print_rec(row[0]) if not delay_commit: self.conn.commit() return True def update_rec( self, index: Optional[IntOrInts], url: Optional[str] = None, title_in: Optional[str] = None, tags_in: Optional[str] = None, desc: Optional[str] = None, immutable: Optional[bool] = None, threads: int = 4, url_redirect: bool = False, tag_redirect: bool | str = False, tag_error: bool | str = False, del_error: Optional[IntSet] = None, export_on: Optional[IntSet] = None, retain_order: bool = False) -> bool: """Update an existing record at (each) index. Update all records if index is 0 or empty, and url is not specified. URL is an exception because URLs are unique in DB. Parameters ---------- index : int | int[] | int{} | range, optional DB index(es) of record(s). 0 or empty value indicates all records. url : str, optional Bookmark address. title_in : str, optional Title to add manually. tags_in : str, optional Comma-separated tags to add manually. Must start and end with comma. Prefix with '+,' to append to current tags. Prefix with '-,' to delete from current tags. desc : str, optional Description of bookmark. immutable : bool, optional Disable title fetch from web if True. Default is None (no change). threads : int Number of threads to use to refresh full DB. Default is 4. url_redirect : bool Update the URL to one produced after following all PERMANENT redirects. (This could fail if the new URL is bookmarked already.) tag_redirect : bool | str Adds a tag by the given pattern if the url resolved to a PERMANENT redirect. (True means the default pattern 'http:{}'.) tag_error : bool | str Adds a tag by the given pattern if the url resolved to a HTTP error. (True means the default pattern 'http:{}'.) del_error : int{} | range, optional Delete the bookmark if HTTP response status is in the given set or range. Does NOT cause deletion of the bookmark on a network error. export_on : int{} | range, optional Limit the export to URLs returning one of given HTTP codes; store old URLs. retain_order : bool If True, bookmark deletion will not result in their order being changed (multiple indices will be updated instead). Returns ------- bool True on success, False on failure. (Deletion by del_error counts as success.) """ arguments = [] # type: List[Any] query = 'UPDATE bookmarks SET' tag_modified = False ret = True indices = (None if not index else [index] if isinstance(index, int) else index) index = indices and list(indices or [])[0] single = len(indices or []) == 1 export_on, self._to_export = (export_on or set()), ({} if export_on else None) tags_in = (tags_in or None if not tags_in or re.match('[+-],', tags_in) else delim_wrap(tags_in)) if url and not single: LOGERR('All URLs cannot be same') return False if tags_in in ('+,', '-,'): LOGERR('Please specify a tag') return False if indices and min(indices) > (self.get_max_id() or 0): # none of the indices exist in DB? return False # Update description if passed as an argument if desc is not None: query += ' desc = ?,' arguments += (desc,) # Update immutable flag if passed as argument if immutable is not None: if immutable: query += ' flags = flags | ?,' arguments += (FLAG_IMMUTABLE,) else: query += ' flags = flags & ?,' arguments += (~FLAG_IMMUTABLE,) # Update title # # 1. If --title has no arguments, delete existing title # 2. If --title has arguments, update existing title # 3. If --title option is omitted at cmdline: # If URL is passed, update the title from web using the URL # 4. If no other argument (url, tag, comment, immutable) passed, # update title from web using DB URL (if title is mutable) fetch_title = {url, title_in, tags_in, desc, immutable} == {None} network_test = url_redirect or tag_redirect or tag_error or del_error or export_on or fetch_title if url and title_in is None: network_test = False _url = url or self.get_rec_by_id(index).url result = fetch_data(_url) if result.bad: print('Malformed URL') elif result.mime: LOGDBG('HTTP HEAD requested') elif not result.title: print('No title') else: LOGDBG('Title: [%s]', result.title) if result.desc and not desc: query += ' desc = ?,' arguments += (result.desc,) if url_redirect and result.url != _url: url = result.url if result.fetch_status in export_on: # storing the old URL self._to_export[url or _url] = _url else: result = FetchResult(url, title_in) if result.title is not None: query += ' metadata = ?,' arguments += (result.title,) # Update URL if passed as argument if url: query += ' URL = ?,' arguments += (url,) if result.fetch_status in (del_error or []): if result.fetch_status in export_on: # storing the old record self._to_export[url] = self.get_rec_by_id(index) LOGERR('HTTP error %s', result.fetch_status) return self.delete_rec(index, retain_order=retain_order) if not indices and (arguments or tags_in): resp = read_in('Update ALL bookmarks? (y/n): ') if resp != 'y': return False if network_test: # doing this before updates to backup records to-be-deleted in their original state custom_tags = (tags_in if (tags_in or '').startswith(DELIM) else None) ret = ret and self.refreshdb(indices, threads, url_redirect=url_redirect, tag_redirect=tag_redirect, tag_error=tag_error, del_error=del_error, export_on=export_on, update_title=fetch_title, custom_url=url, custom_tags=custom_tags, delay_delete=True) # Update tags if passed as argument _tags = result.tags(keywords=False, redirect=tag_redirect, error=tag_error) if tags_in or _tags: if not tags_in or tags_in.startswith('+,'): tags = taglist_str((tags_in or '')[1:] + _tags) chatty = self.chatty self.chatty = False ret = self.append_tag_at_index(indices, tags) self.chatty = chatty tag_modified = True elif tags_in.startswith('-,'): chatty = self.chatty self.chatty = False ret = self.delete_tag_at_index(indices, tags_in[1:]) if _tags: self.append_tag_at_index(indices, _tags) self.chatty = chatty tag_modified = True elif not network_test: # rely on custom_tags to avoid overwriting fetch-status tags query += ' tags = ?,' arguments += (taglist_str(tags_in + _tags),) if not arguments: # no arguments => nothing to update if (tag_modified or network_test) and self.chatty: self.print_rec(indices) self.commit_delete(retain_order=retain_order) return ret query = query[:-1] if indices: # Only specified indices query += ' WHERE id IN ({})'.format(', '.join(['?'] * len(indices))) arguments += tuple(indices) LOGDBG('update_rec query: "%s", args: %s', query, arguments) with self.lock: try: self.cur.execute(query, arguments) self.conn.commit() if self.cur.rowcount > 0 and self.chatty: self.print_rec(index) elif self.cur.rowcount == 0: if single: LOGERR('No matching index %d', index) else: LOGERR('No matches found') return False except sqlite3.IntegrityError: LOGERR('URL already exists') return False except sqlite3.OperationalError as e: LOGERR(e) return False finally: self.commit_delete(retain_order=retain_order) return True def refreshdb( self, index: Optional[IntOrInts], threads: int, url_redirect: bool = False, tag_redirect: bool | str = False, tag_error: bool | str = False, del_error: Optional[IntSet] = None, export_on: Optional[IntSet] = None, update_title: bool = True, custom_url: Optional[str] = None, custom_tags: Optional[str] = None, delay_delete: bool = False, retain_order: bool = False) -> bool: """Refresh ALL (or specified) records in the database. Fetch title for each bookmark from the web and update the records. Doesn't update the title if fetched title is empty. Notes ----- This API doesn't change DB index, URL or tags of a bookmark. (Unless one or more fetch-status parameters are supplied.) This API is verbose. Parameters ---------- index : int | int[] | int{} | range, optional DB index(es) of record(s) to update. 0 or empty value indicates all records. threads: int Number of threads to use to refresh full DB. Default is 4. url_redirect : bool Update the URL to one produced after following all PERMANENT redirects. (This could fail if the new URL is bookmarked already.) tag_redirect : bool | str Adds a tag by the given pattern if the url resolved to a PERMANENT redirect. (True means the default pattern 'http:{}'.) tag_error : bool | str Adds a tag by the given pattern if the url resolved to a HTTP error. (True means the default pattern 'http:{}'.) del_error : int{} | range, optional Delete the bookmark if HTTP response status is in the given set or range. export_on : int{} | range, optional Limit the export to URLs returning one of given HTTP codes; store old URLs. update_title : bool Update titles/descriptions. (Can be turned off for network testing.) custom_url : str, optional Override URL to fetch. (Use for network testing of a single record before updating it.) custom_tags : str, optional Overwrite all tags. (Use to combine network testing with tags overwriting.) delay_delete : bool Delay scheduled deletions by del_error. (Use for network testing during update.) retain_order : bool If True, bookmark deletion will not result in their order being changed (multiple indices will be updated instead). Returns ------- bool True on success, False on failure. (Deletion by del_error counts as success.) """ indices = (None if not index else [index] if isinstance(index, int) else index) index = indices and list(indices)[0] export_on, self._to_export = (export_on or set()), ({} if export_on else None) self._to_delete = [] if not update_title and not (url_redirect or tag_redirect or tag_error or del_error or export_on): LOGERR('Noop update request') return False if custom_url and len(indices or []) != 1: LOGERR('custom_url is only supported for a singular index') return False with self.lock: if not indices: self.cur.execute('SELECT id, url, tags, flags FROM bookmarks ORDER BY id ASC') else: placeholder = ', '.join(['?'] * len(indices)) self.cur.execute(f'SELECT id, url, tags, flags FROM bookmarks WHERE id IN ({placeholder}) ORDER BY id ASC', tuple(indices)) resultset = self.cur.fetchall() recs = len(resultset) if not recs: LOGERR('No matching index or title immutable or empty DB') return False # Set up strings to be printed if self.colorize: bad_url_str = '\x1b[1mIndex %d: Malformed URL\x1b[0m\n' mime_str = '\x1b[1mIndex %d: HTTP HEAD requested\x1b[0m\n' blank_url_str = '\x1b[1mIndex %d: No title\x1b[0m\n' success_str = 'Title: [%s]\n\x1b[92mIndex %d: updated\x1b[0m\n' else: bad_url_str = 'Index %d: Malformed URL\n' mime_str = 'Index %d: HTTP HEAD requested\n' blank_url_str = 'Index %d: No title\n' success_str = 'Title: [%s]\nIndex %d: updated\n' done = {'value': 0} # count threads completed processed = {'value': 0} # count number of records processed # An additional call to generate default headers # gen_headers() is called within fetch_data() # However, this initial call to setup headers # ensures there is no race condition among the # initial threads to setup headers if not MYHEADERS: gen_headers() def refresh(thread_idx, cond): """Inner function to fetch titles and update records. Parameters ---------- thread_idx : int Thread index/ID. cond : threading condition object. """ _count = 0 while True: query = 'UPDATE bookmarks SET' arguments = [] with cond: if resultset: id, url, tags, flags = resultset.pop() else: break result = fetch_data(custom_url or url, http_head=(flags & FLAG_IMMUTABLE) > 0) _count += 1 with cond: if result.bad: print(bad_url_str % id) if custom_tags: self.cur.execute('UPDATE bookmarks SET tags = ? WHERE id = ?', (custom_tags, id)) continue if result.fetch_status in (del_error or []): if result.fetch_status in export_on: self._to_export[url] = self.get_rec_by_id(id, lock=False) LOGERR('HTTP error %s', result.fetch_status) self._to_delete += [id] if result.mime and self.chatty: print(mime_str % id) if custom_tags: self.cur.execute('UPDATE bookmarks SET tags = ? WHERE id = ?', (custom_tags, id)) continue if result.mime: if self.chatty: print(mime_str % id) if custom_tags: self.cur.execute('UPDATE bookmarks SET tags = ? WHERE id = ?', (custom_tags, id)) continue if not result.title: LOGERR(blank_url_str, id) elif update_title: query += ' metadata = ?,' arguments += (result.title,) if update_title and result.desc: query += ' desc = ?,' arguments += (result.desc,) _url = url if url_redirect and result.url != url: query += ' url = ?,' arguments += (result.url,) _url = result.url if result.fetch_status in export_on: self._to_export[_url] = url _tags = result.tags(keywords=False, redirect=tag_redirect, error=tag_error) if _tags: query += ' tags = ?,' arguments += (taglist_str((custom_tags or tags) + DELIM + _tags),) elif custom_tags: query += ' tags = ?,' arguments += (taglist_str(custom_tags),) if not arguments: # nothing to update continue query = query[:-1] + ' WHERE id = ?' arguments += (id,) LOGDBG('refreshdb query: "%s", args: %s', query, arguments) self.cur.execute(query, arguments) # Save after fetching 32 titles per thread if _count % 32 == 0: self.conn.commit() if self.chatty: print(success_str % (result.title, id)) if INTERRUPTED: break LOGDBG('Thread %d: processed %d', threading.get_ident(), _count) with cond: done['value'] += 1 processed['value'] += _count cond.notify() with self.lock: # preventing external concurrent access cond = threading.Condition() with cond: # preventing concurrent access between workers threads = min(threads, recs) for i in range(threads): thread = threading.Thread(target=refresh, args=(i, cond)) thread.start() while done['value'] < threads: cond.wait() LOGDBG('%d threads completed', done['value']) # Guard: records found == total records processed if recs != processed['value']: LOGERR('Records: %d, processed: %d !!!', recs, processed['value']) if delay_delete: self.conn.commit() else: self.commit_delete(retain_order=retain_order) return True def commit_delete(self, apply: bool = True, retain_order: bool = False): """Commit delayed delete commands.""" if apply and self._to_delete is not None: with self.lock: for id in sorted(set(self._to_delete), reverse=True): self.delete_rec(id, delay_commit=True, chatty=False, retain_order=retain_order) self.conn.commit() self.cur.execute('VACUUM') self._to_delete = None def edit_update_rec(self, index, immutable=None): """Edit in editor and update a record. Parameters ---------- index : int DB index of the record. Last record, if index is -1. immutable : bool, optional Disable title fetch from web if True. Default is None (no change). Returns ------- bool True if updated, else False. """ editor = get_system_editor() if editor == 'none': LOGERR('EDITOR must be set to use index with -w') return False if index == -1: # Edit the last records index = self.get_max_id() if not index: LOGERR('Empty database') return False rec = self.get_rec_by_id(index) if not rec: LOGERR('No matching index %d', index) return False # If reading from DB, show empty title and desc as empty lines. We have to convert because # even in case of add with a blank title or desc, '' is used as initializer to show '-'. result = edit_rec(editor, rec.url, rec.title or None, rec.tags_raw, rec.desc or None) if result is not None: url, title, tags, desc = result return self.update_rec(index, url, title, tags, desc, immutable) if immutable is not None: return self.update_rec(index, immutable=immutable) return False def list_using_id(self, ids=[], order=['+id']): """List entries in the DB using the specified id list. Parameters ---------- ids : list of ids/ranges in string form order : list of strings Order description (fields from JSON export or DB, prepended with '+'/'-' for ASC/DESC). Returns ------- list """ q0 = 'SELECT * FROM bookmarks' if ids: q0 += ' WHERE id in (' for idx in ids: if '-' not in idx: q0 += idx + ',' else: val = idx.split('-') if val[0]: _range = list(map(int, val)) _range[1] += 1 part_ids = range(*_range) else: end = int(val[1]) qtemp = 'SELECT id FROM bookmarks ORDER BY id DESC LIMIT {0}'.format(end) with self.lock: self.cur.execute(qtemp, []) part_ids = chain.from_iterable(self.cur.fetchall()) q0 += ','.join(map(str, part_ids)) q0 = q0.strip(',') q0 += ')' try: return self._fetch(q0 + f' ORDER BY {self._order(order)}') except sqlite3.OperationalError as e: LOGERR(e) return [] def _search_tokens(self, keyword: str, deep=False, regex=False, markers=False): """Converts a keyword into a list of tokens, based on search parameters. A token is a varied-length tuple of following values: (SQL field, deep, *SQL params).""" deep = not regex and deep if not markers or (re.sub(r'^\*', '', keyword) and not re.match(r'^[.:>#]', keyword)): s = (keyword if not markers else re.sub(r'^\*', '', keyword)) if not s: return [] tags = ([s] if regex and not markers else taglist(s.split(DELIM))) return [('metadata', deep, s), ('url', deep, s), ('desc', deep, s)] + (tags and [('tags', deep, *tags)]) if re.match(r'^\..', keyword): # checking prefix + ensuring keyword[1:] is not empty return [('metadata', deep, keyword[1:])] if re.match(r'^:.', keyword): return [('url', deep, keyword[1:])] if re.match(r'^>.', keyword): return [('desc', deep, keyword[1:])] if re.match(r'^#,?[^,]', keyword): tags = ([re.sub(r'^#,?', '', keyword)] if regex else taglist(keyword[1:].split(DELIM))) return tags and [('tags', not keyword.startswith('#,'), *tags)] return [] def _search_clause(self, tokens, regex=False) -> Tuple[str, List[str]]: """Converts a list of tokens into an SQL clause. (See also: BukuDb._search_tokens().) If regex is True, the token is treated as a raw regex and the paired deep parameter is ignored.""" border = lambda k, c: (',' if k == 'tags' else r'\b' if c.isalnum() else '') args, clauses = [], [] if regex: for field, deep, param in tokens: clauses += [field + ' REGEXP ?'] args += [param] else: for field, deep, *params in tokens: _clauses = [] for param in params: if deep: _clauses += [field + " LIKE ('%' || ? || '%')"] else: _clauses += [field + ' REGEXP ?'] param = border(field, param[0]) + re.escape(param) + border(field, param[-1]) args += [param] clauses += (_clauses if len(_clauses) < 2 else [f'({" AND ".join(_clauses)})']) return ' OR '.join(clauses), args def searchdb( self, keywords: List[str], all_keywords: bool = False, deep: bool = False, regex: bool = False, markers: bool = False, order: List[str] = ['+id'], ) -> List[BookmarkVar]: """Search DB for entries where tags, URL, or title fields match keywords. Parameters ---------- keywords : list of str Keywords to search. order : list of str Order description (fields from JSON export or DB, prepended with '+'/'-' for ASC/DESC). Note: this applies to fields with the same number of matched keywords. all_keywords : bool False (default value) to return records matching ANY keyword. True to return records matching ALL keywords. This also enables special behaviour when keywords in (['blank'], ['immutable']). deep : bool True to search for matching substrings. Default is False. markers : bool True to use prefix markers for different fields. Default is False. regex : bool Match a regular expression if True. Default is False. Overrides deep, all_keywords, and comma matching in tags with markers. Returns ------- list List of search results. """ _order = self._order(order) clauses, qargs = [], [] for keyword in keywords: tokens = self._search_tokens(keyword, deep=deep, markers=markers) clause, args = self._search_clause(tokens, regex=regex) if clause and args: clauses += [f'({clause})'] qargs += args if not qargs: return [] _count = lambda x: f'CASE WHEN {x} THEN 1 ELSE 0 END' if regex: query = ('SELECT id, url, metadata, tags, desc, flags\nFROM (SELECT *, (' + '\n + '.join(map(_count, clauses)) + f') AS score\n FROM bookmarks WHERE score > 0 ORDER BY score DESC, {_order})') elif all_keywords: if keywords == ['blank']: qargs, query = [DELIM], "SELECT * FROM bookmarks WHERE metadata = '' OR tags = ?" elif keywords == ['immutable']: qargs, query = [], 'SELECT * FROM bookmarks WHERE flags & 1 == 1' else: query = 'SELECT id, url, metadata, tags, desc, flags FROM bookmarks WHERE ' + '\n AND '.join(clauses) query += f'\nORDER BY {_order}' elif not all_keywords: query = ('SELECT id, url, metadata, tags, desc, flags\nFROM (SELECT *, (' + '\n + '.join(map(_count, clauses)) + f') AS score\n FROM bookmarks WHERE score > 0 ORDER BY score DESC, {_order})') else: LOGERR('Invalid search option') return [] LOGDBG('query: "%s", args: %s', query, qargs) try: return self._fetch(query, *qargs) except sqlite3.OperationalError as e: LOGERR(e) return [] def search_by_tag(self, tags: Optional[str], order: List[str] = ['+id']) -> List[BookmarkVar]: """Search bookmarks for entries with given tags. Parameters ---------- tags : str String of tags to search for. Retrieves entries matching ANY tag if tags are delimited with ','. Retrieves entries matching ALL tags if tags are delimited with '+'. order : list of str Order description (fields from JSON export or DB, prepended with '+'/'-' for ASC/DESC). Note: this applies to fields with the same number of matched tags. Returns ------- list List of search results. """ _order = self._order(order) LOGDBG(tags) if tags is None or tags == DELIM or tags == '': return [] qargs, search_operator, excluded_tags = prep_tag_search(tags) if search_operator is None: LOGERR("Cannot use both '+' and ',' in same search") return [] LOGDBG('tags: %s', qargs) LOGDBG('search_operator: %s', search_operator) LOGDBG('excluded_tags: %s', excluded_tags) if search_operator == 'AND': query = ('SELECT id, url, metadata, tags, desc, flags FROM bookmarks WHERE (' + f' {search_operator} '.join("tags LIKE '%' || ? || '%'" for tag in qargs) + ')' + ('' if not excluded_tags else ' AND tags NOT REGEXP ?') + f' ORDER BY {_order}') else: query = ('SELECT id, url, metadata, tags, desc, flags FROM (SELECT *, ' + ' + '.join("CASE WHEN tags LIKE '%' || ? || '%' THEN 1 ELSE 0 END" for tag in qargs) + ' AS score FROM bookmarks WHERE score > 0' + ('' if not excluded_tags else ' AND tags NOT REGEXP ?') + f' ORDER BY score DESC, {_order})') if excluded_tags: qargs += [excluded_tags] LOGDBG('query: "%s", args: %s', query, qargs) return self._fetch(query, *qargs) def search_keywords_and_filter_by_tags( self, keywords: List[str], all_keywords: bool = False, deep: bool = False, regex: bool = False, stag: Optional[List[str]] = None, without: Optional[List[str]] = None, markers: bool = False, order: List[str] = ['+id']) -> List[BookmarkVar]: """Search bookmarks for entries with keywords and specified criteria while filtering out entries with matching tags. Parameters ---------- keywords : list of str Keywords to search. without : list of str Keywords to exclude; ignored if empty. Default is None. all_keywords : bool True to return records matching ALL keywords. False to return records matching ANY keyword. (This is the default.) deep : bool True to search for matching substrings. Default is False markers: bool True to use prefix markers for different fields. Default is False. regex : bool Match a regular expression if True. Default is False. stag : list of str Strings of tags to search for. Default is None. Retrieves entries matching ANY tag if tags are delimited with ','. Retrieves entries matching ALL tags if tags are delimited with '+'. Returns ------- list List of search results. """ results = self.searchdb(keywords, all_keywords=all_keywords, deep=deep, regex=regex, markers=markers, order=order) results = (results if not stag else filter_from(results, self.search_by_tag(''.join(stag)))) return self.exclude_results_from_search(results, without, deep=deep, markers=markers) def exclude_results_from_search(self, search_results, without, deep=False, markers=False): """Excludes records that match keyword search using without parameters Parameters ---------- search_results : list List of search results. without : list of str Keywords to exclude. If empty, returning search_results unchanged. deep : bool True to search for matching substrings. Default is False. markers: bool True to use prefix markers for different fields. Default is False. Returns ------- list List of search results. """ if not without: return search_results return filter_from(search_results, self.searchdb(without, deep=deep, markers=markers), exclude=True) def swap_recs(self, index1: int, index2: int, *, lock: bool = True, delay_commit: bool = False): """Swaps two records with given indices Parameters ---------- index1 : int Index of the 1st record to be exchanged. index2 : int Index of the 2nd record to be exchanged. lock : bool Whether to restrict concurrent access (True by default). delay_commit : bool True if record should not be committed to the DB, leaving commit responsibility to caller. Default is False. Returns ------- bool True on success, False on failure. """ if lock: with self.lock: return self.swap_recs(index1, index2, lock=False, delay_commit=delay_commit) max_id = self.get_max_id() if not max_id or index1 == index2 or not all(0 < x <= max_id for x in [index1, index2]): return False self.cur.executemany('UPDATE bookmarks SET id = ? WHERE id = ?', [(max_id+1, index1), (index1, index2), (index2, max_id+1)]) if not delay_commit: self.conn.commit() return True def compactdb(self, index: int, delay_commit: bool = False, upto: Optional[int] = None, retain_order: bool = False): """When an entry at index is deleted, move the last entry in DB to index, if index is lesser. Parameters ---------- index : int DB index of deleted entry. delay_commit : bool True if record should not be committed to the DB, leaving commit responsibility to caller. Default is False. upto : int, optional If specified, multiple indices are moved at once. retain_order: bool Shift indices of multiple records by 1 instead of replacing the deleted record with the last one. Default is False. """ # Return if the last index left in DB was just deleted max_id = self.get_max_id() if not max_id or (upto and upto < index): return # NOOP if the just deleted index was the last one if max_id > index: with self.lock: if retain_order or (upto or 0) > index: step = (max(max_id - upto, upto + 1 - index) if not retain_order else 1 if not upto else upto + 1 - index) self.cur.execute('UPDATE bookmarks SET id = id-? WHERE id >= ?', (step, index+step)) msg = f'Indices {index+step}-{max_id} moved to {index}-{max_id-step}' else: self.cur.execute('UPDATE bookmarks SET id = ? WHERE id = ?', (index, max_id)) msg = f'Index {max_id} moved to {index}' if not delay_commit: self.conn.commit() self.cur.execute('VACUUM') if self.chatty: print(msg) def delete_rec( self, index: int = None, low: int = 0, high: int = 0, is_range: bool = False, delay_commit: bool = False, chatty: Optional[bool] = None, retain_order: bool = False, ) -> bool: """Delete a single record or remove the table if index is 0. Parameters ---------- index : int, optional DB index of deleted entry. low : int Actual lower index of range. high : int Actual higher index of range. is_range : bool A range is passed using low and high arguments. An index is ignored if is_range is True. delay_commit : bool True if record should not be committed to the DB, leaving commit responsibility to caller. Default is False. chatty : Optional[bool] Override for self.chatty retain_order: bool Shift indices of multiple records instead of replacing the deleted record with the last one. Default is False. Raises ------ TypeError If any of index, low, or high variable is not integer. Returns ------- bool True on success, False on failure. Examples -------- >>> from tempfile import NamedTemporaryFile >>> import buku >>> sdb = buku.BukuDb(dbfile=NamedTemporaryFile().name) # single record database >>> sdb.add_rec('https://example.com') 1 >>> sdb.delete_rec(1) Index 1 deleted True Delete record with default range. >>> sdb = buku.BukuDb(dbfile=NamedTemporaryFile().name) >>> sdb.add_rec('https://example.com') 1 >>> sdb.delete_rec(is_range=True) # doctest: +SKIP Remove ALL bookmarks? (y/n): y All bookmarks deleted True Running the function without any parameter will raise TypeError. >>> sdb = buku.BukuDb(dbfile=NamedTemporaryFile().name) >>> sdb.add_rec('https://example.com') 1 >>> sdb.delete_rec() Traceback (most recent call last): ... TypeError: index, low, or high variable is not integer Negative number on `high` and `low` parameters when is_range is True will log error and return False >>> edb = buku.BukuDb(dbfile=NamedTemporaryFile().name) >>> edb.delete_rec(low=-1, high=-1, is_range=True) False Remove the table >>> sdb = buku.BukuDb(dbfile=NamedTemporaryFile().name) >>> sdb.delete_rec(0) # doctest: +SKIP Remove ALL bookmarks? (y/n): y All bookmarks deleted True """ chatty = (chatty if chatty is not None else self.chatty) params = [low, high] if not is_range: params.append(index) if any(map(lambda x: not isinstance(x, int), params)): raise TypeError('index, low, or high variable is not integer') if is_range: # Delete a range of indices if low < 0 or high < 0: LOGERR('Negative range boundary') return False if low > high: low, high = high, low # If range starts from 0, delete all records if low == 0: return self.cleardb() try: if chatty: with self.lock: self.cur.execute('SELECT COUNT(*) from bookmarks where id ' 'BETWEEN ? AND ?', (low, high)) count = self.cur.fetchone() if count[0] < 1: print('Index %d-%d: 0 deleted' % (low, high)) return False if self.print_rec(0, low, high, True) is True: resp = input('Delete these bookmarks? (y/n): ') if resp != 'y': return False query = 'DELETE from bookmarks where id BETWEEN ? AND ?' with self.lock: self.cur.execute(query, (low, high)) print('Index %d-%d: %d deleted' % (low, high, self.cur.rowcount)) if not self.cur.rowcount: return False # Compact DB in a single operation for the range # Delayed commit is forced with self.lock: self.compactdb(low, upto=high, delay_commit=True, retain_order=retain_order) if not delay_commit: self.conn.commit() self.cur.execute('VACUUM') except IndexError: LOGERR('No matching index') return False elif index == 0: # Remove the table return self.cleardb() else: # Remove a single entry try: if chatty: with self.lock: self.cur.execute('SELECT COUNT(*) FROM bookmarks WHERE ' 'id = ? LIMIT 1', (index,)) count = self.cur.fetchone() if count[0] < 1: LOGERR('No matching index %d', index) return False if self.print_rec(index) is True: resp = input('Delete this bookmark? (y/n): ') if resp != 'y': return False with self.lock: query = 'DELETE FROM bookmarks WHERE id = ?' self.cur.execute(query, (index,)) if self.cur.rowcount == 1: print('Index %d deleted' % index) self.compactdb(index, delay_commit=True, retain_order=retain_order) if not delay_commit: self.conn.commit() self.cur.execute('VACUUM') else: LOGERR('No matching index %d', index) return False except IndexError: LOGERR('No matching index %d', index) return False except sqlite3.OperationalError as e: LOGERR(e) return False return True def delete_resultset(self, results, retain_order=False): """Delete search results in descending order of DB index. Indices are expected to be unique and in ascending order. Notes ----- This API forces a delayed commit. Parameters ---------- results : list of tuples List of results to delete from DB. retain_order: bool Shift indices of multiple records instead of replacing the deleted record with the last one. Default is False. Returns ------- bool True on success, False on failure. """ if self.chatty: resp = read_in('Delete the search results? (y/n): ') if resp != 'y': return False # delete records in reverse order ids = sorted(set(x[0] for x in results)) with self.lock: for pos, id in reversed(list(enumerate(ids))): self.delete_rec(id, delay_commit=True, retain_order=retain_order) # Commit at every 200th removal, counting from the end if pos % 200 == 0: self.conn.commit() self.cur.execute('VACUUM') return True def delete_rec_all(self, delay_commit=False): """Removes all records in the Bookmarks table. Parameters ---------- delay_commit : bool True if record should not be committed to the DB, leaving commit responsibility to caller. Default is False. Returns ------- bool True on success, False on failure. """ try: with self.lock: self.cur.execute('DELETE FROM bookmarks') if not delay_commit: self.conn.commit() self.cur.execute('VACUUM') return True except Exception as e: LOGERR('delete_rec_all(): %s', e) return False def cleardb(self, confirm=True): """Drops the bookmark table if it exists. Parameters ---------- confirm : bool False if confirmation prompt is required. Default is True. Returns ------- bool True on success, False on failure. """ resp = ('y' if not confirm else read_in('Remove ALL bookmarks? (y/n): ')) if resp != 'y': print('No bookmarks deleted') return False if self.delete_rec_all(): print('All bookmarks deleted') return True return False def print_rec(self, index: Optional[IntOrInts] = 0, low: int = 0, high: int = 0, is_range: bool = False, order: List[str] = []) -> bool: """Print bookmark details at index or all bookmarks if index is 0. A negative index behaves like tail, if title is blank show "Untitled". Empty database check will run when `index` < 0 and `is_range` is False. Parameters ----------- index : int | int[] | int{} | range, optional DB index(es) of record(s) to print. 0 or empty prints all records. Negative value prints out last `index` rows. low : int Actual lower index of range. high : int Actual higher index of range. is_range : bool A range is passed using low and high arguments. An index is ignored if is_range is True. order : list of str Order description (fields from JSON export or DB, prepended with '+'/'-' for ASC/DESC). Returns ------- bool True on success, False on failure. Examples -------- >>> import buku >>> from tempfile import NamedTemporaryFile >>> edb = buku.BukuDb(dbfile=NamedTemporaryFile().name) # empty database >>> edb.print_rec() True Print negative index on empty database will log error and return False >>> edb.print_rec(-3) False print non empty database with default argument. >>> sdb = buku.BukuDb(dbfile=NamedTemporaryFile().name) # single record database >>> sdb.add_rec('https://example.com') 1 >>> assert sdb.print_rec() 1. Example Domain > https://example.com <BLANKLINE> Negative number on `high` and `low` parameters when is_range is True will log error and return False >>> sdb.print_rec(low=-1, high=-1, is_range=True) False >>> edb.print_rec(low=-1, high=-1, is_range=True) False """ if isinstance(index, range) and index.step == 1 and index.start != 0: # low=0 triggers custom behaviour return self.print_rec(None, is_range=True, low=index.start, high=index.stop-1, order=order) if not is_range and isinstance(index, int) and index < 0: # Show the last n records _id = self.get_max_id() if not _id: LOGERR('Empty database') return False low = (1 if _id <= -index else _id + index + 1) return self.print_rec(None, is_range=True, low=low, high=_id, order=order) _order = self._order(order) if is_range: if low < 0 or high < 0: LOGERR('Negative range boundary') return False if low > high: low, high = high, low try: # If range starts from 0 print all records with self.lock: if low == 0: query = f'SELECT * from bookmarks ORDER BY {_order}' resultset = self.cur.execute(query) else: query = f'SELECT * from bookmarks where id BETWEEN ? AND ? ORDER BY {_order}' resultset = self.cur.execute(query, (low, high)) except IndexError: LOGERR('Index out of range') return False elif index: # Show record at index try: if isinstance(index, int): results = self._fetch('SELECT * FROM bookmarks WHERE id = ? LIMIT 1', index) else: placeholder = ', '.join(['?'] * len(index)) results = self._fetch(f'SELECT * FROM bookmarks WHERE id IN ({placeholder}) ORDER BY {_order}', *index) except IndexError: results = None if not results: LOGERR('No matching index %s', index) return False single_record = len(results) == 1 if self.json is None: print_rec_with_filter(results, self.field_filter) elif self.json: write_string_to_file(format_json(results, single_record, field_filter=self.field_filter), self.json) else: print_json_safe(results, single_record, field_filter=self.field_filter) return True else: # Show all entries with self.lock: self.cur.execute(f'SELECT * FROM bookmarks ORDER BY {_order}') resultset = self.cur.fetchall() if not resultset: LOGERR('0 records') return True if self.json is None: print_rec_with_filter(resultset, self.field_filter) elif self.json: write_string_to_file(format_json(resultset, field_filter=self.field_filter), self.json) else: print_json_safe(resultset, field_filter=self.field_filter) return True def get_tag_all(self): """Get list of tags in DB. Returns ------- tuple (list of unique tags sorted alphabetically, dictionary of {tag: usage_count}). """ tags = [] unique_tags = [] dic = {} qry = 'SELECT DISTINCT tags, COUNT(tags) FROM bookmarks GROUP BY tags' with self.lock: for row in self.cur.execute(qry): tagset = row[0].strip(DELIM).split(DELIM) for tag in tagset: if tag not in tags: dic[tag] = row[1] tags += (tag,) else: dic[tag] += row[1] if not tags: return tags, dic if tags[0] == '': unique_tags = sorted(tags[1:]) else: unique_tags = sorted(tags) return unique_tags, dic def suggest_similar_tag(self, tagstr): """Show list of tags those go together in DB. Parameters ---------- tagstr : str Original tag string. Returns ------- str DELIM separated string of tags. """ if not tagstr: return '' tags = tagstr.split(',') qry = 'SELECT DISTINCT tags FROM bookmarks WHERE tags LIKE ?' tagset = set() for tag in tags: if tag == '': continue with self.lock: self.cur.execute(qry, ('%' + delim_wrap(tag) + '%',)) results = self.cur.fetchall() for row in results: # update tagset with unique tags in row tagset |= set(row[0].strip(DELIM).split(DELIM)) # remove user supplied tags from tagset tagset.difference_update(tags) if not len(tagset): return tagstr unique_tags = sorted(tagset) print('similar tags:\n') for count, tag in enumerate(unique_tags): print('%d. %s' % (count + 1, tag)) selected_tags = input('\nselect: ').split() print() if not selected_tags: return tagstr tags = [tagstr] for index in selected_tags: try: tags.append(delim_wrap(unique_tags[int(index) - 1])) except Exception as e: LOGERR(e) continue return parse_tags(tags) def replace_tag(self, orig: str, new: List[str] = []): """Replace original tag by new tags in all records. Remove original tag if new tag is empty. Parameters ---------- orig : str Original tag. new : list Replacement tags. Raises ------- ValueError: Invalid input(s) provided. RuntimeError: Tag deletion failed. """ if DELIM in orig: raise ValueError("Original tag cannot contain delimiter ({}).".format(DELIM)) orig = delim_wrap(orig) newtags = taglist_str(DELIM.join(new)) if orig == newtags: raise ValueError("Original and replacement tags are the same.") # Remove original tag from DB if new tagset reduces to delimiter if newtags == DELIM: if not self.delete_tag_at_index(0, orig, chatty=self.chatty): raise RuntimeError("Tag deletion failed.") # Update bookmarks with original tag with self.lock: query = 'SELECT id, tags FROM bookmarks WHERE tags LIKE ?' self.cur.execute(query, ('%' + orig + '%',)) results = self.cur.fetchall() if results: query = 'UPDATE bookmarks SET tags = ? WHERE id = ?' for row in results: tags = row[1].replace(orig, newtags) tags = parse_tags([tags]) self.cur.execute(query, (tags, row[0],)) print('Index %d updated' % row[0]) self.conn.commit() def get_tagstr_from_taglist(self, id_list, taglist): """Get a string of delimiter-separated (and enclosed) string of tags from a dictionary of tags by matching ids. The inputs are the outputs from BukuDb.get_tag_all(). Parameters ---------- id_list : list List of ids. taglist : list List of tags. Returns ------- str Delimiter separated and enclosed list of tags. """ tags = DELIM for id in id_list: if is_int(id) and int(id) > 0: tags += taglist[int(id) - 1] + DELIM elif '-' in id: vals = [int(x) for x in id.split('-')] if vals[0] > vals[-1]: vals[0], vals[-1] = vals[-1], vals[0] for _id in range(vals[0], vals[-1] + 1): tags += taglist[_id - 1] + DELIM return tags def set_tag(self, cmdstr, taglist): """Append, overwrite, remove tags using the symbols >>, > and << respectively. Parameters ---------- cmdstr : str Command pattern. taglist : list List of tags. Returns ------- int Number of indices updated on success, -1 on failure, -2 on no symbol found. """ if not cmdstr or not taglist: return -1 flag = 0 # 0: invalid, 1: append, 2: overwrite, 3: remove index = cmdstr.find('>>') if index == -1: index = cmdstr.find('>') if index != -1: flag = 2 else: index = cmdstr.find('<<') if index != -1: flag = 3 else: flag = 1 if not flag: return -2 tags = DELIM id_list = cmdstr[:index].split() try: tags = self.get_tagstr_from_taglist(id_list, taglist) if tags == DELIM and flag != 2: return -1 except ValueError: return -1 if flag != 2: index += 1 with self.lock: update_count = 0 query = 'UPDATE bookmarks SET tags = ? WHERE id = ?' try: db_id_list = cmdstr[index + 1:].split() for id in db_id_list: if is_int(id) and int(id) > 0: if flag == 1: if self.append_tag_at_index(id, tags, True): update_count += 1 elif flag == 2: tags = parse_tags([tags]) self.cur.execute(query, (tags, id,)) update_count += self.cur.rowcount else: self.delete_tag_at_index(id, tags, True) update_count += 1 elif '-' in id: vals = [int(x) for x in id.split('-')] if vals[0] > vals[-1]: vals[0], vals[-1] = vals[-1], vals[0] for _id in range(vals[0], vals[-1] + 1): if flag == 1: if self.append_tag_at_index(_id, tags, True): update_count += 1 elif flag == 2: tags = parse_tags([tags]) self.cur.execute(query, (tags, _id,)) update_count += self.cur.rowcount else: if self.delete_tag_at_index(_id, tags, True): update_count += 1 else: return -1 except ValueError: return -1 except sqlite3.IntegrityError: return -1 try: self.conn.commit() except Exception as e: LOGERR(e) return -1 return update_count def browse_by_index(self, index=0, low=0, high=0, is_range=False): """Open URL at index or range of indices in browser. Parameters ---------- index : int Index to browse. 0 opens a random bookmark. low : int Actual lower index of range. high : int Higher index of range. is_range : bool A range is passed using low and high arguments. If True, index is ignored. Default is False. Returns ------- bool True on success, False on failure. """ if is_range: if low < 0 or high < 0: LOGERR('Negative range boundary') return False if low > high: low, high = high, low try: # If range starts from 0 throw an error if low <= 0: raise IndexError qry = 'SELECT URL from bookmarks where id BETWEEN ? AND ?' with self.lock: for row in self.cur.execute(qry, (low, high)): browse(row[0], self.default_scheme) return True except IndexError: LOGERR('Index out of range') return False if index < 0: LOGERR('Invalid index %d', index) return False if index == 0: max_id = self.get_max_id() if not max_id: print('No bookmarks added yet ...') return False index = random.randint(1, max_id) LOGDBG('Opening random index %d', index) qry = 'SELECT URL FROM bookmarks WHERE id = ? LIMIT 1' try: with self.lock: for row in self.cur.execute(qry, (index,)): browse(row[0], self.default_scheme) return True LOGERR('No matching index %d', index) except IndexError: LOGERR('No matching index %d', index) return False def exportdb(self, filepath: str, resultset: Optional[List[BookmarkVar]] = None, order: List[str] = ['id'], pick: Optional[int] = None) -> bool: """Export DB bookmarks to file. Exports full DB, if resultset is None. Additionally, if run after a (batch) update with export_on, only export those records. If destination file name ends with '.db', bookmarks are exported to a buku database file. If destination file name ends with '.md', bookmarks are exported to a Markdown file. If destination file name ends with '.org' bookmarks are exported to an org file. If destination file name ends with '.xbel' bookmarks are exported to a XBEL file. If destination file name ends with '.rss'/'.atom' bookmarks are exported to an RSS file. Otherwise, bookmarks are exported to a Firefox bookmarks.html formatted file. Parameters ---------- filepath : str Path to export destination file. resultset : list of tuples List of results to export. Use `None` to get current DB. Ignored if run after a (batch) update with export_on. order : list of str Order description (fields from JSON export or DB, prepended with '+'/'-' for ASC/DESC). pick : int, optional Reduce the export to a random subset of up to given (positive) size. Default is None. Returns ------- bool True on success, False on failure. """ count = 0 if not resultset: resultset = self.get_rec_all(order=order) if not resultset: print('No records found') return False old = self._to_export or {} if self._to_export is not None: _resultset = dict(old) _resultset.update({x.url: x for x in resultset if x.url in old}) resultset = self._sort(_resultset.values(), order) self._to_export = None if not resultset: print('No records to export') return False if pick and pick < len(resultset): resultset = self._sort(random.sample(resultset, pick), order) if os.path.exists(filepath): resp = read_in(filepath + ' exists. Overwrite? (y/n): ') if resp != 'y': return False if filepath.endswith('.db'): os.remove(filepath) if filepath.endswith('.db'): outdb = BukuDb(dbfile=filepath) qry = 'INSERT INTO bookmarks(URL, metadata, tags, desc, flags) VALUES (?, ?, ?, ?, ?)' for row in resultset: _old = old.get(row.url) _add = (f' (OLD URL = {_old})' if isinstance(_old, str) and _old != row.url else ' (DELETED)' if _old is row else '') title = ((row.title or '') + _add if _add else row.title) outdb.cur.execute(qry, (row.url, title, row.tags_raw, row.desc, row.flags)) count += 1 outdb.conn.commit() outdb.close() print('%s exported' % count) return True with open(filepath, mode='w', encoding='utf-8') as outfp: res = {} # type: Dict if filepath.endswith('.md'): res = convert_bookmark_set(resultset, 'markdown', old) count += res['count'] outfp.write(res['data']) elif filepath.endswith('.org'): res = convert_bookmark_set(resultset, 'org', old) count += res['count'] outfp.write(res['data']) elif filepath.endswith('.xbel'): res = convert_bookmark_set(resultset, 'xbel', old) count += res['count'] outfp.write(res['data']) elif filepath.endswith('.rss') or filepath.endswith('.atom'): res = convert_bookmark_set(resultset, 'rss', old) count += res['count'] outfp.write(res['data']) else: res = convert_bookmark_set(resultset, 'html', old) count += res['count'] outfp.write(res['data']) print('%s exported' % count) return True return False def traverse_bm_folder(self, sublist, unique_tag, folder_name, add_parent_folder_as_tag): """Traverse bookmark folders recursively and find bookmarks. Parameters ---------- sublist : list List of child entries in bookmark folder. unique_tag : str Timestamp tag in YYYYMonDD format. folder_name : str Name of the parent folder. add_parent_folder_as_tag : bool True if bookmark parent folders should be added as tags else False. Returns ------- tuple Bookmark record data. """ for item in sublist: if item['type'] == 'folder': next_folder_name = folder_name + DELIM + strip_delim(item['name']) yield from self.traverse_bm_folder( item['children'], unique_tag, next_folder_name, add_parent_folder_as_tag) elif item['type'] == 'url': try: if is_nongeneric_url(item['url']): continue except KeyError: continue tags = '' if add_parent_folder_as_tag: tags += folder_name if unique_tag: tags += DELIM + unique_tag yield (item['url'], item['name'], parse_tags([tags]), None, 0, True, False) def load_chrome_database(self, path, unique_tag, add_parent_folder_as_tag): """Open Chrome Bookmarks JSON file and import data. Parameters ---------- path : str Path to Google Chrome bookmarks file. unique_tag : str Timestamp tag in YYYYMonDD format. add_parent_folder_as_tag : bool True if bookmark parent folders should be added as tags else False. """ with open(path, 'r', encoding="utf8") as datafile: data = json.load(datafile) roots = data['roots'] for entry in roots: # Needed to skip 'sync_transaction_version' key from roots if isinstance(roots[entry], str): continue for item in self.traverse_bm_folder( roots[entry]['children'], unique_tag, roots[entry]['name'], add_parent_folder_as_tag): self.add_rec(*item) def load_firefox_database(self, path, unique_tag, add_parent_folder_as_tag): """Connect to Firefox sqlite db and import bookmarks into BukuDb. Parameters ---------- path : str Path to Firefox bookmarks sqlite database. unique_tag : str Timestamp tag in YYYYMonDD format. add_parent_folder_as_tag : bool True if bookmark parent folders should be added as tags else False. """ # Connect to input DB conn = sqlite3.connect('file:%s?mode=ro' % path, uri=True) cur = conn.cursor() res = cur.execute('SELECT DISTINCT fk, parent, title FROM moz_bookmarks WHERE type=1') # get id's and remove duplicates for row in res.fetchall(): # get the url res = cur.execute('SELECT url FROM moz_places where id={}'.format(row[0])) url = res.fetchone()[0] if is_nongeneric_url(url): continue # get tags res = cur.execute('SELECT parent FROM moz_bookmarks WHERE ' 'fk={} AND title IS NULL'.format(row[0])) bm_tag_ids = [tid for item in res.fetchall() for tid in item] bookmark_tags = [] for bm_tag_id in bm_tag_ids: res = cur.execute('SELECT title FROM moz_bookmarks WHERE id={}'.format(bm_tag_id)) bookmark_tags.append(res.fetchone()[0]) if add_parent_folder_as_tag: # add folder name parent_id = row[1] while parent_id: res = cur.execute('SELECT title,parent FROM moz_bookmarks ' 'WHERE id={}'.format(parent_id)) parent = res.fetchone() if parent: title, parent_id = parent bookmark_tags.append(title) if unique_tag: # add timestamp tag bookmark_tags.append(unique_tag) formatted_tags = [DELIM + strip_delim(tag) for tag in bookmark_tags] tags = parse_tags(formatted_tags) # get the title title = row[2] or '' self.add_rec(url, title, tags, None, 0, True, False) try: cur.close() conn.close() except Exception as e: LOGERR(e) def load_edge_database(self, path, unique_tag, add_parent_folder_as_tag): """Open Edge Bookmarks JSON file and import data. Parameters ---------- path : str Path to Microsoft Edge bookmarks file. unique_tag : str Timestamp tag in YYYYMonDD format. add_parent_folder_as_tag : bool True if bookmark parent folders should be added as tags else False. """ with open(path, 'r', encoding="utf8") as datafile: data = json.load(datafile) roots = data['roots'] for entry in roots: # Needed to skip 'sync_transaction_version' key from roots if isinstance(roots[entry], str): continue for item in self.traverse_bm_folder( roots[entry]['children'], unique_tag, roots[entry]['name'], add_parent_folder_as_tag): self.add_rec(*item) def auto_import_from_browser(self, firefox_profile=None): """Import bookmarks from a browser default database file. Supports Firefox, Google Chrome, Chromium, Vivaldi, Brave, and MS Edge. Returns ------- bool True on success, False on failure. """ if sys.platform.startswith(('linux', 'freebsd', 'openbsd')): gc_bm_db_path = '~/.config/google-chrome/Default/Bookmarks' cb_bm_db_path = '~/.config/chromium/Default/Bookmarks' vi_bm_db_path = '~/.config/vivaldi/Default/Bookmarks' br_bm_db_path = '~/.config/BraveSoftware/Brave-Browser/Default/Bookmarks' me_bm_db_path = '~/.config/microsoft-edge/Default/Bookmarks' default_ff_folder = '~/.mozilla/firefox' elif sys.platform == 'darwin': gc_bm_db_path = '~/Library/Application Support/Google/Chrome/Default/Bookmarks' cb_bm_db_path = '~/Library/Application Support/Chromium/Default/Bookmarks' vi_bm_db_path = '~/Library/Application Support/Vivaldi/Default/Bookmarks' br_bm_db_path = '~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Bookmarks' me_bm_db_path = '~/Library/Application Support/Microsoft Edge/Default/Bookmarks' default_ff_folder = '~/Library/Application Support/Firefox' elif sys.platform == 'win32': gc_bm_db_path = os.path.expandvars('%LOCALAPPDATA%/Google/Chrome/User Data/Default/Bookmarks') cb_bm_db_path = os.path.expandvars('%LOCALAPPDATA%/Chromium/User Data/Default/Bookmarks') vi_bm_db_path = os.path.expandvars('%LOCALAPPDATA%/Vivaldi/User Data/Default/Bookmarks') br_bm_db_path = os.path.expandvars('%LOCALAPPDATA%/BraveSoftware/Brave-Browser/User Data/Default/Bookmarks') me_bm_db_path = os.path.expandvars('%LOCALAPPDATA%/Microsoft/Edge/User Data/Default/Bookmarks') default_ff_folder = os.path.expandvars('%APPDATA%/Mozilla/Firefox/') else: LOGERR('buku does not support {} yet'.format(sys.platform)) self.close_quit(1) return # clarifying execution interrupt for the linter ff_bm_db_paths = get_firefox_db_paths(default_ff_folder, firefox_profile) if self.chatty: resp = input('Generate auto-tag (YYYYMonDD)? (y/n): ') if resp == 'y': newtag = gen_auto_tag() else: newtag = None resp = input('Add parent folder names as tags? (y/n): ') else: newtag = None resp = 'y' add_parent_folder_as_tag = resp == 'y' with self.lock: resp = 'y' chrome_based = {'Google Chrome': gc_bm_db_path, 'Chromium': cb_bm_db_path, 'Vivaldi': vi_bm_db_path, 'Brave': br_bm_db_path} for name, path in chrome_based.items(): try: if os.path.isfile(os.path.expanduser(path)): if self.chatty: resp = input(f'Import bookmarks from {name}? (y/n): ') if resp == 'y': bookmarks_database = os.path.expanduser(path) if not os.path.exists(bookmarks_database): raise FileNotFoundError self.load_chrome_database(bookmarks_database, newtag, add_parent_folder_as_tag) except Exception as e: LOGERR(e) print(f'Could not import bookmarks from {name}') try: ff_bm_db_paths = {k: s for k, s in ff_bm_db_paths.items() if os.path.isfile(os.path.expanduser(s))} for idx, (name, ff_bm_db_path) in enumerate(ff_bm_db_paths.items(), start=1): if self.chatty: profile = ('' if len(ff_bm_db_paths) < 2 else f' profile {name} [{idx}/{len(ff_bm_db_paths)}]') resp = input(f'Import bookmarks from Firefox{profile}? (y/n): ') if resp == 'y': bookmarks_database = os.path.expanduser(ff_bm_db_path) if not os.path.exists(bookmarks_database): raise FileNotFoundError self.load_firefox_database(bookmarks_database, newtag, add_parent_folder_as_tag) break except Exception as e: LOGERR(e) print('Could not import bookmarks from Firefox.') try: if os.path.isfile(os.path.expanduser(me_bm_db_path)): if self.chatty: resp = input('Import bookmarks from microsoft edge? (y/n): ') if resp == 'y': bookmarks_database = os.path.expanduser(me_bm_db_path) if not os.path.exists(bookmarks_database): raise FileNotFoundError self.load_edge_database(bookmarks_database, newtag, add_parent_folder_as_tag) except Exception as e: LOGERR(e) print('Could not import bookmarks from microsoft-edge') self.conn.commit() if newtag: print('\nAuto-generated tag: %s' % newtag) def importdb(self, filepath, tacit=False): """Import bookmarks from an HTML or a Markdown file. Supports Firefox, Google Chrome, and IE exported HTML bookmarks. Supports XBEL standard bookmarks. Supports RSS files with extension '.rss', '.atom'. Supports Markdown files with extension '.md', '.org'. Supports importing bookmarks from another buku database file. Parameters ---------- filepath : str Path to file to import. tacit : bool If True, no questions asked and folder names are automatically imported as tags from bookmarks HTML. If True, automatic timestamp tag is NOT added. Default is False. Returns ------- bool True on success, False on failure. """ if filepath.endswith('.db'): return self.mergedb(filepath) newtag = None append_tags_resp = 'y' if not tacit: if input('Generate auto-tag (YYYYMonDD)? (y/n): ') == 'y': newtag = gen_auto_tag() append_tags_resp = input('Append tags when bookmark exist? (y/n): ') items = [] if filepath.endswith('.md'): items = import_md(filepath=filepath, newtag=newtag) elif filepath.endswith('org'): items = import_org(filepath=filepath, newtag=newtag) elif filepath.endswith('rss') or filepath.endswith('atom'): items = import_rss(filepath=filepath, newtag=newtag) elif filepath.endswith('json'): if not tacit: resp = input('Add parent folder names as tags? (y/n): ') else: resp = 'y' add_bookmark_folder_as_tag = resp == 'y' try: with open(filepath, 'r', encoding='utf-8') as datafile: data = json.load(datafile) items = import_firefox_json(data, add_bookmark_folder_as_tag, newtag) except ValueError as e: LOGERR("ff_json: JSON Decode Error: {}".format(e)) return False except Exception as e: LOGERR(e) return False elif filepath.endswith('xbel'): try: with open(filepath, mode='r', encoding='utf-8') as infp: soup = BeautifulSoup(infp, 'html.parser') except ImportError: LOGERR('Beautiful Soup not found') return False except Exception as e: LOGERR(e) return False add_parent_folder_as_tag = False use_nested_folder_structure = False if not tacit: resp = input("""Add bookmark's parent folder as tag? a: add all parent folders of the bookmark n: don't add parent folder as tag (a/[n]): """) else: resp = 'y' if resp == 'a': add_parent_folder_as_tag = True use_nested_folder_structure = True items = import_xbel(soup, add_parent_folder_as_tag, newtag, use_nested_folder_structure) infp.close() else: try: with open(filepath, mode='r', encoding='utf-8') as infp: soup = BeautifulSoup(infp, 'html.parser') except ImportError: LOGERR('Beautiful Soup not found') return False except Exception as e: LOGERR(e) return False add_parent_folder_as_tag = False use_nested_folder_structure = False if not tacit: resp = input("""Add bookmark's parent folder as tag? y: add single, direct parent folder a: add all parent folders of the bookmark n: don't add parent folder as tag (y/a/[n]): """) else: resp = 'y' if resp in ('y', 'a'): add_parent_folder_as_tag = True if resp == 'a': use_nested_folder_structure = True items = import_html(soup, add_parent_folder_as_tag, newtag, use_nested_folder_structure) infp.close() with self.lock: for item in items: add_rec_res = self.add_rec(*item) if not add_rec_res and append_tags_resp == 'y': rec_id = self.get_rec_id(item[0]) self.append_tag_at_index(rec_id, item[2]) self.conn.commit() if newtag: print('\nAuto-generated tag: %s' % newtag) return True def mergedb(self, path): """Merge bookmarks from another buku database file. Parameters ---------- path : str Path to DB file to merge. Returns ------- bool True on success, False on failure. """ try: # Connect to input DB indb_conn = sqlite3.connect('file:%s?mode=ro' % path, uri=True) indb_cur = indb_conn.cursor() indb_cur.execute('SELECT * FROM bookmarks') except Exception as e: LOGERR(e) return False resultset = indb_cur.fetchall() if resultset: with self.lock: for row in bookmark_vars(resultset): self.add_rec(row.url, row.title, row.tags_raw, row.desc, row.flags, True, False) self.conn.commit() try: indb_cur.close() indb_conn.close() except Exception: pass return True def tnyfy_url( self, index: Optional[int] = None, url: Optional[str] = None, shorten: bool = True) -> Optional[str]: """Shorten/expand a URL using the tny.im service. Tny.im is no longer available; don't use this method. """ warn('\'BukuDb.tnyfy_url()\' no longer works due to the takedown of tny.im service.', DeprecationWarning) def browse_cached_url(self, arg): """Open URL at index or URL. Parameters ---------- arg : str Index or url to browse Returns ------- str Wayback Machine URL, None if not cached """ from urllib.parse import quote_plus if is_int(arg): rec = self.get_rec_by_id(int(arg)) if not rec: LOGERR('No matching index %d', int(arg)) return None url = rec[1] else: url = arg # Try fetching cached page from Wayback Machine api_url = 'https://archive.org/wayback/available?url=' + quote_plus(url) manager = get_PoolManager() resp = manager.request('GET', api_url) respobj = json.loads(resp.data) try: if ( len(respobj['archived_snapshots']) and respobj['archived_snapshots']['closest']['available'] is True): manager.clear() return respobj['archived_snapshots']['closest']['url'] except Exception: pass finally: manager.clear() LOGERR('Uncached') return None def fixtags(self): """Undocumented API to fix tags set in earlier versions. Functionalities: 1. Remove duplicate tags 2. Sort tags 3. Use lower case to store tags """ to_commit = False with self.lock: self.cur.execute('SELECT id, tags FROM bookmarks ORDER BY id ASC') resultset = self.cur.fetchall() query = 'UPDATE bookmarks SET tags = ? WHERE id = ?' for row in resultset: oldtags = row[1] if oldtags == DELIM: continue tags = parse_tags([oldtags]) if tags == oldtags: continue self.cur.execute(query, (tags, row[0],)) to_commit = True if to_commit: self.conn.commit() def close(self): """Close a DB connection.""" if self.conn is not None: try: self.cur.close() self.conn.close() except Exception: # ignore errors here, we're closing down pass def close_quit(self, exitval=0): """Close a DB connection and exit. Parameters ---------- exitval : int Program exit value. """ if self.conn is not None: try: self.cur.close() self.conn.close() except Exception: # ignore errors here, we're closing down pass sys.exit(exitval) class ExtendedArgumentParser(argparse.ArgumentParser): """Extend classic argument parser.""" def __init__(self, *args, **kwargs): self._nodefaults, self._unset = None, object() super().__init__(*args, **kwargs) self._nodefaults = argparse.ArgumentParser(*args, **kwargs) def _add_argument(self, old_add_arg, nodefaults, *args, **kwargs): old_add_arg(*args, **kwargs) kwargs = dict(kwargs) kwargs.pop('type', None) kwargs.pop('choices', None) kwargs['default'] = self._unset nodefaults and nodefaults.add_argument(*args, **kwargs) def add_argument(self, *args, **kwargs): self._add_argument(super().add_argument, self._nodefaults, *args, **kwargs) def add_argument_group(self, *args, **kwargs): group = super().add_argument_group(*args, **kwargs) nodefaults = self._nodefaults and self._nodefaults.add_argument_group(*args, **kwargs) old_add_arg = group.add_argument group.add_argument = lambda *a, **kw: self._add_argument(old_add_arg, nodefaults, *a, **kw) return group def parse_args(self, *args, **kwargs): result = super().parse_args(*args, **kwargs) nodefaults = self._nodefaults.parse_args(*args, **kwargs) params = {k for k in dir(nodefaults) if not k.startswith('_')} setattr(result, '_passed', {k for k in params if getattr(nodefaults, k) != self._unset}) return result @staticmethod def program_info(file=sys.stdout): """Print program info. Parameters ---------- file : file File to write program info to. Default is sys.stdout. """ if sys.platform == 'win32' and file == sys.stdout: file = sys.stderr file.write(''' SYMBOLS: > url + comment # tags Version %s Copyright © 2015-2026 %s License: %s Webpage: https://github.com/jarun/buku ''' % (__version__, __author__, __license__)) @staticmethod def prompt_help(file=sys.stdout): """Print prompt help. Parameters ---------- file : file File to write program info to. Default is sys.stdout. """ file.write(''' PROMPT KEYS: 1-N browse search result indices and/or ranges R [N] print out N random search results (or random bookmarks if negative or N/A) ^ id1 id2 swap two records at specified indices O [id|range [...]] open search results/indices in GUI browser toggle try GUI browser if no arguments a open all results in browser s keyword [...] search for records with ANY keyword S keyword [...] search for records with ALL keywords d match substrings ('pen' matches 'opened') m search with markers - search string is split into keywords by prefix markers, which determine what field the keywords is searched in: '.', '>' or ':' - title, description or URL '#'/'#,' - tags (comma-separated, partial/full match) '*' - all fields (can be omitted in the 1st keyword) note: tag marker is not affected by 'd' (deep search) v fields change sorting order (default is '+index') multiple comma/space separated fields can be specified v! fields update indices in DB to match specified order r expression run a regex search t [tag, ...] search by tags; show taglist, if no args g taglist id|range [...] [>>|>|<<] [record id|range ...] append, set, remove (all or specific) tags search by taglist id(s) if records are omitted n show next page of search results N show previous page of search results o id|range [...] browse bookmarks by indices and/or ranges p id|range [...] print bookmarks by indices and/or ranges w [editor|id] edit and add or update a bookmark c id copy url at search result index to clipboard DB [name] check existing DB list or switch to another DB (use full/dir path to switch folders) '~.' can be used as shortcut for default DB ? show this help q, ^D, double Enter exit buku ''') @staticmethod def is_colorstr(arg): """Check if a string is a valid color string. Parameters ---------- arg : str Color string to validate. Returns ------- str Same color string that was passed as an argument. Raises ------ ArgumentTypeError If the arg is not a valid color string. """ try: assert len(arg) == 5 for c in arg: assert c in COLORMAP except AssertionError as e: raise argparse.ArgumentTypeError('%s is not a valid color string' % arg) from e return arg # Help def print_help(self, file=sys.stdout): """Print help prompt. Parameters ---------- file : file File to write program info to. Default is sys.stdout. """ super().print_help(file) self.program_info(file) # ---------------- # Helper functions # ---------------- ConverterResult = TypedDict('ConverterResult', {'data': str, 'count': int}) if TypedDict else Dict[str, Any] def convert_tags_to_org_mode_tags(tags: str) -> str: """convert buku tags to org-mode compatible tags.""" if tags != DELIM: buku_tags = tags.split(DELIM)[1:-1] buku_tags = [re.sub(r'[^a-zA-Z0-9_@]', ' ', tag) for tag in buku_tags] buku_tags = [re.sub(r'\s+', ' ', tag) for tag in buku_tags] buku_tags = taglist(x.replace(' ', '_') for x in buku_tags) if buku_tags: return ' :{}:\n'.format(':'.join(buku_tags)) return '\n' def convert_bookmark_set( bookmark_set: List[BookmarkVar], export_type: str, old: Optional[Dict[str, str | BookmarkVar]] = None) -> ConverterResult: # type: ignore """Convert list of bookmark set into multiple data format. Parameters ---------- bookmark_set: bookmark set export_type: one of supported type: markdown, html, org, XBEL old: cached values of deleted records/replaced URLs to save Returns ------- converted data and count of converted bookmark set """ import html assert export_type in ['markdown', 'html', 'org', 'xbel', 'rss'] # compatibility resultset = bookmark_vars(bookmark_set) old = old or {} def title(row): _old = old.get(row.url) _add = (f' (OLD URL = {_old})' if isinstance(_old, str) and _old != row.url else ' (DELETED)' if _old == row else '') return (row.title or '') + _add count = 0 out = '' if export_type == 'markdown': for row in resultset: _title = title(row) out += (f'- <{row.url}>' if not _title else f'- [{_title}]({row.url})') if row.tags: out += ' <!-- TAGS: {} -->\n'.format(row.tags) else: out += '\n' count += 1 elif export_type == 'org': for row in resultset: _title = title(row) out += (f'* [[{row.url}]]' if not _title else f'* [[{row.url}][{_title}]]') out += convert_tags_to_org_mode_tags(row.tags_raw) count += 1 elif export_type == 'xbel': timestamp = str(int(time.time())) out = ( '<?xml version="1.0" encoding="UTF-8"?>\n' '<!DOCTYPE xbel PUBLIC \ "+//IDN python.org//DTD XML Bookmark Exchange Language 1.0//EN//XML" \ "http://pyxml.sourceforge.net/topics/dtds/xbel.dtd">\n\n' '<xbel version="1.0">\n') for row in resultset: out += ' <bookmark href="%s"' % (html.escape(row.url)).encode('ascii', 'xmlcharrefreplace').decode('utf-8') if row.tags: out += ' TAGS="' + html.escape(row.tags).encode('ascii', 'xmlcharrefreplace').decode('utf-8') + '"' out += '>\n <title>{}'\ .format(html.escape(title(row)).encode('ascii', 'xmlcharrefreplace').decode('utf-8')) if row.desc: out += '\n {}'.format(html.escape(row.desc).encode('ascii', 'xmlcharrefreplace').decode('utf-8')) out += '\n \n' count += 1 out += '' elif export_type == 'rss': out = ( '\n' ' Bookmarks\n' ' buku\n' ) for row in resultset: out += ' \n' out += ' ' + title(row) + '\n' _url = html.escape(row.url).encode('ascii', 'xmlcharrefreplace').decode('utf-8') out += ' \n' % _url out += ' %s\n' % row.id for tag in (t for t in row.tags.split(',') if t): _tag = html.escape(tag).encode('ascii', 'xmlcharrefreplace').decode('utf-8') out += ' \n' % _tag if row.desc: _desc = html.escape(row.desc).encode('ascii', 'xmlcharrefreplace').decode('utf-8') out += ' %s

]]>
\n' % _desc out += '
\n' count += 1 out += '
' elif export_type == 'html': timestamp = str(int(time.time())) out = ( '\n\n' '\n' 'Bookmarks\n' '

Bookmarks

\n\n' '

\n' '

buku bookmarks

\n' '

\n'.format(timestamp)) for row in resultset: out += '

` (or ` `) _named_link, _raw_link = r'\[(?P.*)\]\((?P<url>.+)\)', r'\<(?P<url_raw>[^!>][^>]*)\>' pattern = re.compile(r'(%s|%s)(\s+<!-- TAGS: (?P<tags>.*) -->)?' % (_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'(?<!\:)\:', tag_string) if s] tag_list_cleaned = [] for i, tag in enumerate(tag_list_raw): if tag.startswith(":"): if tag_list_raw[i-1] == ' ': tag_list_cleaned.append(tag.strip()) else: new_item = tag_list_cleaned[-1] + tag del tag_list_cleaned[-1] tag_list_cleaned.append(new_item.strip()) elif tag != ' ': tag_list_cleaned.append(tag.strip()) return tag_list_cleaned # Supported OrgMode format: `[[url][title]] :tags:` (or `[[url]] :tags:`) _url, _maybe_title = r'(?P<url>((?!\]\[).)+?)', r'(\]\[(?P<title>.+))?' pattern = re.compile(r'\[\[%s%s\]\](?P<tags>\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 "<no title>" 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 <desc> 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 <dd> 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 = '<unknown>' if locale_encoding is None: locale_encoding = '<unknown>' 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) <!-- TAGS --> 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<em>³</em> | path string [default: standard path for buku] | | READONLY | read-only mode | boolean<em>¹</em> [default: `false`] | | DISABLE_FAVICON | disable bookmark [favicons](https://wikipedia.org/wiki/Favicon) | boolean<em>¹</em> [default: `true`] ([here's why](#why-favicons-are-disabled-by-default))| | AUTOFETCH | initial Fetch value in Create form | boolean<em>¹</em> [default: `true`] | | OPEN_IN_NEW_TAB | url link open in new tab | boolean<em>¹</em> [default: `false`] | | REVERSE_PROXY_PATH | reverse proxy path<em>⁵</em> | string | | SERVER_NAME | canonical host:port for URL generation<em>⁶</em> | 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<em>⁴</em> (partial support) | string [default: `en`] | | DEBUG | debug mode (verbose logging etc.) | boolean<em>¹</em> [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)**_ <p><br></p> <p align="center"> <a href="https://github.com/Buku-dev/docs/blob/v5.0-bootstrap4/bukuserver/home-page.png?raw=true"> <img src="https://github.com/Buku-dev/docs/blob/v5.0-bootstrap4/bukuserver/home-page.png" alt="home page" width="650"/> </a> </p> <p align="center"><i>home page</i></p> <p><br><br></p> <p align="center"> <a href="https://github.com/Buku-dev/docs/blob/v5.0-bootstrap4/bukuserver/bookmark-stats.png?raw=true"> <img src="https://github.com/Buku-dev/docs/blob/v5.0-bootstrap4/bukuserver/bookmark-stats.png" alt="bookmark stats" width="650"/> </a> </p> <p align="center"><i>bookmark stats</i></p> <p><br><br></p> <p align="center"> <a href="https://github.com/Buku-dev/docs/blob/v5.0-bootstrap4/bukuserver/bookmark-page-with-favicon-enabled.png?raw=true"> <img src="https://github.com/Buku-dev/docs/blob/v5.0-bootstrap4/bukuserver/bookmark-page-with-favicon-enabled.png" alt="bookmark page with favicon enabled and 'netloc-tag' URL render mode" width="650"/> </a> </p> <p align="center"><i>bookmark page <a href="#configuration">with favicon enabled and 'netloc-tag' URL render mode</a></i></p> <p><br><br></p> <p align="center"> <a href="https://github.com/Buku-dev/docs/blob/v5.0-bootstrap4/bukuserver/bookmark-page-with-slate-theme-and-favicon-enabled.png?raw=true"> <img src="https://github.com/Buku-dev/docs/blob/v5.0-bootstrap4/bukuserver/bookmark-page-with-slate-theme-and-favicon-enabled.png" alt="bookmark page with 'slate' theme and favicon enabled" width="650"/> </a> </p> <p align="center"><i>bookmark page with 'slate' theme, favicon enabled and 'netloc-tag' URL render mode</i></p> <p><br><br></p> <p align="center"> <a href="https://github.com/Buku-dev/docs/blob/v5.0-bootstrap4/bukuserver/create-bookmark.png?raw=true"> <img src="https://github.com/Buku-dev/docs/blob/v5.0-bootstrap4/bukuserver/create-bookmark.png" alt="create bookmark" width="650"/> </a> </p> <p align="center"><i>create bookmark</i></p> <p><br><br></p> <p align="center"> <a href="https://github.com/Buku-dev/docs/blob/v5.0-bootstrap4/bukuserver/edit-bookmark.png?raw=true"> <img src="https://github.com/Buku-dev/docs/blob/v5.0-bootstrap4/bukuserver/edit-bookmark.png" alt="edit bookmark" width="650"/> </a> </p> <p align="center"><i>edit bookmark</i></p> <p><br><br></p> <p align="center"> <a href="https://github.com/Buku-dev/docs/blob/v5.0-bootstrap4/bukuserver/view-bookmark-details.png?raw=true"> <img src="https://github.com/Buku-dev/docs/blob/v5.0-bootstrap4/bukuserver/view-bookmark-details.png" alt="view bookmark details" width="650"/> </a> </p> <p align="center"><i>view bookmark details</i></p> <p><br><br></p> <p align="center"> <a href="https://github.com/Buku-dev/docs/blob/v5.0-bootstrap4/bukuserver/tag-page.png?raw=true"> <img src="https://github.com/Buku-dev/docs/blob/v5.0-bootstrap4/bukuserver/tag-page.png" alt="tag page" width="650"/> </a> </p> <p align="center"><i>tag page</i></p> <p><br><br></p> <p align="center"> <a href="https://github.com/Buku-dev/docs/blob/v4.10/bukuserver/apidocs.png?raw=true"> <img src="https://github.com/Buku-dev/docs/blob/v4.10/bukuserver/apidocs.png" alt="interactive API documentation" width="650"/> </a> </p> <p align="center"><i>interactive <a href="#api">API</a> documentation</i></p> ================================================ 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/<tag>', 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/<int:index>', 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/<int:index>/refresh', 'bookmark_refresh', api.refresh_bookmark, methods=['POST']) app.add_url_rule('/api/bookmarks/<int:index>/tiny', 'tiny_url', api.get_tiny_url, methods=['GET']) app.add_url_rule('/api/bookmarks/<int:start_index>/<int:end_index>', 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<r.length;o++)s(r[o]);return s}return e})()({1:[function(require,module,exports){ },{}],2:[function(require,module,exports){ /* MIT license */ var colorNames = require(6); module.exports = { getRgba: getRgba, getHsla: getHsla, getRgb: getRgb, getHsl: getHsl, getHwb: getHwb, getAlpha: getAlpha, hexString: hexString, rgbString: rgbString, rgbaString: rgbaString, percentString: percentString, percentaString: percentaString, hslString: hslString, hslaString: hslaString, hwbString: hwbString, keyword: keyword } function getRgba(string) { if (!string) { return; } var abbr = /^#([a-fA-F0-9]{3})$/i, hex = /^#([a-fA-F0-9]{6})$/i, rgba = /^rgba?\(\s*([+-]?\d+)\s*,\s*([+-]?\d+)\s*,\s*([+-]?\d+)\s*(?:,\s*([+-]?[\d\.]+)\s*)?\)$/i, per = /^rgba?\(\s*([+-]?[\d\.]+)\%\s*,\s*([+-]?[\d\.]+)\%\s*,\s*([+-]?[\d\.]+)\%\s*(?:,\s*([+-]?[\d\.]+)\s*)?\)$/i, keyword = /(\w+)/; var rgb = [0, 0, 0], a = 1, match = string.match(abbr); if (match) { match = match[1]; for (var i = 0; i < rgb.length; i++) { rgb[i] = parseInt(match[i] + match[i], 16); } } else if (match = string.match(hex)) { match = match[1]; for (var i = 0; i < rgb.length; i++) { rgb[i] = parseInt(match.slice(i * 2, i * 2 + 2), 16); } } else if (match = string.match(rgba)) { for (var i = 0; i < rgb.length; i++) { rgb[i] = parseInt(match[i + 1]); } a = parseFloat(match[4]); } else if (match = string.match(per)) { for (var i = 0; i < rgb.length; i++) { rgb[i] = Math.round(parseFloat(match[i + 1]) * 2.55); } a = parseFloat(match[4]); } else if (match = string.match(keyword)) { if (match[1] == "transparent") { return [0, 0, 0, 0]; } rgb = colorNames[match[1]]; if (!rgb) { return; } } for (var i = 0; i < rgb.length; i++) { rgb[i] = scale(rgb[i], 0, 255); } if (!a && a != 0) { a = 1; } else { a = scale(a, 0, 1); } rgb[3] = a; return rgb; } function getHsla(string) { if (!string) { return; } var hsl = /^hsla?\(\s*([+-]?\d+)(?:deg)?\s*,\s*([+-]?[\d\.]+)%\s*,\s*([+-]?[\d\.]+)%\s*(?:,\s*([+-]?[\d\.]+)\s*)?\)/; var match = string.match(hsl); if (match) { var alpha = parseFloat(match[4]); var h = scale(parseInt(match[1]), 0, 360), s = scale(parseFloat(match[2]), 0, 100), l = scale(parseFloat(match[3]), 0, 100), a = scale(isNaN(alpha) ? 1 : alpha, 0, 1); return [h, s, l, a]; } } function getHwb(string) { if (!string) { return; } var hwb = /^hwb\(\s*([+-]?\d+)(?:deg)?\s*,\s*([+-]?[\d\.]+)%\s*,\s*([+-]?[\d\.]+)%\s*(?:,\s*([+-]?[\d\.]+)\s*)?\)/; var match = string.match(hwb); if (match) { var alpha = parseFloat(match[4]); var h = scale(parseInt(match[1]), 0, 360), w = scale(parseFloat(match[2]), 0, 100), b = scale(parseFloat(match[3]), 0, 100), a = scale(isNaN(alpha) ? 1 : alpha, 0, 1); return [h, w, b, a]; } } function getRgb(string) { var rgba = getRgba(string); return rgba && rgba.slice(0, 3); } function getHsl(string) { var hsla = getHsla(string); return hsla && hsla.slice(0, 3); } function getAlpha(string) { var vals = getRgba(string); if (vals) { return vals[3]; } else if (vals = getHsla(string)) { return vals[3]; } else if (vals = getHwb(string)) { return vals[3]; } } // generators function hexString(rgb) { return "#" + hexDouble(rgb[0]) + hexDouble(rgb[1]) + hexDouble(rgb[2]); } function rgbString(rgba, alpha) { if (alpha < 1 || (rgba[3] && rgba[3] < 1)) { return rgbaString(rgba, alpha); } return "rgb(" + rgba[0] + ", " + rgba[1] + ", " + rgba[2] + ")"; } function rgbaString(rgba, alpha) { if (alpha === undefined) { alpha = (rgba[3] !== undefined ? rgba[3] : 1); } return "rgba(" + rgba[0] + ", " + rgba[1] + ", " + rgba[2] + ", " + alpha + ")"; } function percentString(rgba, alpha) { if (alpha < 1 || (rgba[3] && rgba[3] < 1)) { return percentaString(rgba, alpha); } var r = Math.round(rgba[0]/255 * 100), g = Math.round(rgba[1]/255 * 100), b = Math.round(rgba[2]/255 * 100); return "rgb(" + r + "%, " + g + "%, " + b + "%)"; } function percentaString(rgba, alpha) { var r = Math.round(rgba[0]/255 * 100), g = Math.round(rgba[1]/255 * 100), b = Math.round(rgba[2]/255 * 100); return "rgba(" + r + "%, " + g + "%, " + b + "%, " + (alpha || rgba[3] || 1) + ")"; } function hslString(hsla, alpha) { if (alpha < 1 || (hsla[3] && hsla[3] < 1)) { return hslaString(hsla, alpha); } return "hsl(" + hsla[0] + ", " + hsla[1] + "%, " + hsla[2] + "%)"; } function hslaString(hsla, alpha) { if (alpha === undefined) { alpha = (hsla[3] !== undefined ? hsla[3] : 1); } return "hsla(" + hsla[0] + ", " + hsla[1] + "%, " + hsla[2] + "%, " + alpha + ")"; } // hwb is a bit different than rgb(a) & hsl(a) since there is no alpha specific syntax // (hwb have alpha optional & 1 is default value) function hwbString(hwb, alpha) { if (alpha === undefined) { alpha = (hwb[3] !== undefined ? hwb[3] : 1); } return "hwb(" + hwb[0] + ", " + hwb[1] + "%, " + hwb[2] + "%" + (alpha !== undefined && alpha !== 1 ? ", " + alpha : "") + ")"; } function keyword(rgb) { return reverseNames[rgb.slice(0, 3)]; } // helpers function scale(num, min, max) { return Math.min(Math.max(min, num), max); } function hexDouble(num) { var str = num.toString(16).toUpperCase(); return (str.length < 2) ? "0" + str : str; } //create a list of reverse color names var reverseNames = {}; for (var name in colorNames) { reverseNames[colorNames[name]] = name; } },{"6":6}],3:[function(require,module,exports){ /* MIT license */ var convert = require(5); var string = require(2); var Color = function (obj) { if (obj instanceof Color) { return obj; } if (!(this instanceof Color)) { return new Color(obj); } this.valid = false; this.values = { rgb: [0, 0, 0], hsl: [0, 0, 0], hsv: [0, 0, 0], hwb: [0, 0, 0], cmyk: [0, 0, 0, 0], alpha: 1 }; // parse Color() argument var vals; if (typeof obj === 'string') { vals = string.getRgba(obj); if (vals) { this.setValues('rgb', vals); } else if (vals = string.getHsla(obj)) { this.setValues('hsl', vals); } else if (vals = string.getHwb(obj)) { this.setValues('hwb', vals); } } else if (typeof obj === 'object') { vals = obj; if (vals.r !== undefined || vals.red !== undefined) { this.setValues('rgb', vals); } else if (vals.l !== undefined || vals.lightness !== undefined) { this.setValues('hsl', vals); } else if (vals.v !== undefined || vals.value !== undefined) { this.setValues('hsv', vals); } else if (vals.w !== undefined || vals.whiteness !== undefined) { this.setValues('hwb', vals); } else if (vals.c !== undefined || vals.cyan !== undefined) { this.setValues('cmyk', vals); } } }; Color.prototype = { isValid: function () { return this.valid; }, rgb: function () { return this.setSpace('rgb', arguments); }, hsl: function () { return this.setSpace('hsl', arguments); }, hsv: function () { return this.setSpace('hsv', arguments); }, hwb: function () { return this.setSpace('hwb', arguments); }, cmyk: function () { return this.setSpace('cmyk', arguments); }, rgbArray: function () { return this.values.rgb; }, hslArray: function () { return this.values.hsl; }, hsvArray: function () { return this.values.hsv; }, hwbArray: function () { var values = this.values; if (values.alpha !== 1) { return values.hwb.concat([values.alpha]); } return values.hwb; }, cmykArray: function () { return this.values.cmyk; }, rgbaArray: function () { var values = this.values; return values.rgb.concat([values.alpha]); }, hslaArray: function () { var values = this.values; return values.hsl.concat([values.alpha]); }, alpha: function (val) { if (val === undefined) { return this.values.alpha; } this.setValues('alpha', val); return this; }, red: function (val) { return this.setChannel('rgb', 0, val); }, green: function (val) { return this.setChannel('rgb', 1, val); }, blue: function (val) { return this.setChannel('rgb', 2, val); }, hue: function (val) { if (val) { val %= 360; val = val < 0 ? 360 + val : val; } return this.setChannel('hsl', 0, val); }, saturation: function (val) { return this.setChannel('hsl', 1, val); }, lightness: function (val) { return this.setChannel('hsl', 2, val); }, saturationv: function (val) { return this.setChannel('hsv', 1, val); }, whiteness: function (val) { return this.setChannel('hwb', 1, val); }, blackness: function (val) { return this.setChannel('hwb', 2, val); }, value: function (val) { return this.setChannel('hsv', 2, val); }, cyan: function (val) { return this.setChannel('cmyk', 0, val); }, magenta: function (val) { return this.setChannel('cmyk', 1, val); }, yellow: function (val) { return this.setChannel('cmyk', 2, val); }, black: function (val) { return this.setChannel('cmyk', 3, val); }, hexString: function () { return string.hexString(this.values.rgb); }, rgbString: function () { return string.rgbString(this.values.rgb, this.values.alpha); }, rgbaString: function () { return string.rgbaString(this.values.rgb, this.values.alpha); }, percentString: function () { return string.percentString(this.values.rgb, this.values.alpha); }, hslString: function () { return string.hslString(this.values.hsl, this.values.alpha); }, hslaString: function () { return string.hslaString(this.values.hsl, this.values.alpha); }, hwbString: function () { return string.hwbString(this.values.hwb, this.values.alpha); }, keyword: function () { return string.keyword(this.values.rgb, this.values.alpha); }, rgbNumber: function () { var rgb = this.values.rgb; return (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]; }, luminosity: function () { // http://www.w3.org/TR/WCAG20/#relativeluminancedef var rgb = this.values.rgb; var lum = []; for (var i = 0; i < rgb.length; i++) { var chan = rgb[i] / 255; lum[i] = (chan <= 0.03928) ? chan / 12.92 : Math.pow(((chan + 0.055) / 1.055), 2.4); } return 0.2126 * lum[0] + 0.7152 * lum[1] + 0.0722 * lum[2]; }, contrast: function (color2) { // http://www.w3.org/TR/WCAG20/#contrast-ratiodef var lum1 = this.luminosity(); var lum2 = color2.luminosity(); if (lum1 > 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('<ul class="' + chart.id + '-legend">'); 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('<li><span style="background-color:' + datasets[0].backgroundColor[i] + '"></span>'); if (labels[i]) { text.push(labels[i]); } text.push('</li>'); } } text.push('</ul>'); 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('<ul class="' + chart.id + '-legend">'); 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('<li><span style="background-color:' + datasets[0].backgroundColor[i] + '"></span>'); if (labels[i]) { text.push(labels[i]); } text.push('</li>'); } } text.push('</ul>'); 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<Number>} 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 = '<div class="' + cls + '-expand" style="' + style + '">' + '<div style="' + 'position:absolute;' + 'width:' + maxSize + 'px;' + 'height:' + maxSize + 'px;' + 'left:0;' + 'top:0">' + '</div>' + '</div>' + '<div class="' + cls + '-shrink" style="' + style + '">' + '<div style="' + 'position:absolute;' + 'width:200%;' + 'height:200%;' + 'left:0; ' + 'top:0">' + '</div>' + '</div>'; 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('<ul class="' + chart.id + '-legend">'); for (var i = 0; i < chart.data.datasets.length; i++) { text.push('<li><span style="background-color:' + chart.data.datasets[i].backgroundColor + '"></span>'); if (chart.data.datasets[i].label) { text.push(chart.data.datasets[i].label); } text.push('</li>'); } text.push('</ul>'); 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<Number>} 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<Number>} 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') }} <script>$('#fa_filter, .table.searchable a').attr('tabindex', 1)</script> {{ 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 %} <h3> {% if request.args.get('id') == 'random' %} <button id="modal-random" class="btn btn-secondary" title="{{ _('Pick another') }}"> <span class="fa fa-repeat glyphicon glyphicon-repeat"></span> </button> {% endif %} <a href="{{ url_for('bookmark.details_view', id=model.id, url=request.args.url) }}">{{_gettext('View Record')}} #{{model.id}}</a> </h3> {% endblock %} {% block tail %} {{ super() }} {{ buku.details_formatting('.modal') }} {{ buku.focus('.modal-body') }} {% endblock %} ================================================ FILE: bukuserver/templates/bukuserver/bookmark_edit.html ================================================ {% extends 'admin/model/edit.html' %} {% import 'bukuserver/lib.html' as buku with context %} {% block head %} {{ super() }} {{ buku.brand_dbname() }} {% endblock %} {% block edit_form %} {{ super() }} <form method="POST" action="{{ get_url('.delete_view') }}" class="delete-form d-inline-block float-right"> <input type="hidden" name="id" value="{{ request.args.get('id') }}"/> <input type="hidden" name="url" value="{{ return_url }}"/> {% if csrf_token %} <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> {% endif %} <button class="btn btn-warning" onclick="return faHelpers.safeConfirm('{{ _gettext('Are you sure you want to delete this record?') }}');"> {{ _gettext('Delete') }} </button> </form> {% endblock %} {% block tail %} {{ super() }} {{ buku.set_lang() }} {{ buku.limit_navigation_if_popup() }} {{ buku.script('bookmark.js') }} <script>$('.submit-row').append($('.delete-form'))</script> {{ buku.horizontal_form(excluding_popups=True) }} {{ buku.focus() }} {{ buku.link_saved() }} {% endblock %} ================================================ FILE: bukuserver/templates/bukuserver/bookmark_edit_modal.html ================================================ {% extends 'admin/model/modals/edit.html' %} {% import 'bukuserver/lib.html' as buku with context %} {% block tail %} {{ super() }} {{ buku.script('bookmark.js') }} {{ buku.horizontal_form() }} {{ buku.focus('.modal-body') }} {% endblock %} ================================================ FILE: bukuserver/templates/bukuserver/bookmarklet.url ================================================ javascript:void%20function(){var%20e=location.href,t=document.title.trim()||%22%22,o=document.getSelection().toString().trim()||(document.querySelector(%22meta[name$=description%20i],%20meta[property$=description%20i]%22)||{}).content||%22%22;o.length%3E4e3%26%26(o=o.substr(0,4e3)+%22...%22,alert(%22The%20selected%20text%20is%20too%20long,%20it%20will%20be%20truncated.%22)),e=%22{{url}}%3Furl=%22+encodeURIComponent(e)+%22%26title=%22+encodeURIComponent(t)+%22%26description=%22+encodeURIComponent(o),window.open(e,%22_blank%22,%22menubar=no,%20height=700,%20width=800,%20toolbar=no,%20scrollbars=yes,%20status=no,%20dialog=1%22)}(); ================================================ FILE: bukuserver/templates/bukuserver/bookmarks_list.html ================================================ {% extends 'admin/model/list.html' %} {% import 'bukuserver/lib.html' as buku with context %} {% block head %} {{ super() }} {{ buku.close_if_popup() }} {{ buku.brand_dbname() }} <script> function promptSwap(input, rowId, maxId={{count|tojson}}) { let _id = input.value = prompt({{ _('Swap record #{} with record #')|tojson }}.replace('{}', rowId), rowId) || ""; let error = (!_id ? "" : !/^[1-9][0-9]*$/.test(_id) ? {{ _("Not a valid record index: '{}'")|tojson }}.replace('{}', _id) : _id > maxId ? {{ _('There are only {} records in total!')|tojson }}.replace('{}', maxId) : _id == `${rowId}` ? {{ _('Swapping a record with itself has no effect!')|tojson }} : null); error && alert(error); return (error == null); } </script> {% endblock %} {% block model_menu_bar_before_filters %} {{ super() }} {% if data %} {% set _random = url_for('.details_view', modal=True, id='random', url=return_url, **(request.args|flt)) %} <li class="nav-item"> <a id="random" class="nav-link" data-target="#fa_modal_window" data-toggle="modal" href="{{ _random }}">{{ _('Random') }}</a> </li> {% endif %} <li id="reorderButton" class="nav-item d-none"> <form id="reorder" class="d-none" method="POST" action="reorder"> <input type="hidden" name="filters"/> </form> <a class="nav-link" href="#" title="{{ _('Update indices to match this order') }}" onclick="reorder.confirmAndSubmit()">{{ _('Reorder') }}</a> </li> <script> reorder.confirmAndSubmit = () => confirm({{ _('Save this order in DB?') | tojson }}) && reorder.submit(); addEventListener('DOMContentLoaded', () => { let flt = JSON.parse( $('#active-filters-data').attr('data-initial') ); if ((flt.length > 0) && flt.every(x => x[1] == 'order')) { reorderButton.classList.remove('d-none'); $('#reorder [name=filters]').val(JSON.stringify(flt)); $(`.field-filters .filter, .filters .remove-filter`).on('click', () => reorderButton.classList.add('d-none')); $(`.filters select`).on('change', () => reorderButton.classList.add('d-none')); $(`.filters input`).on('input', () => reorderButton.classList.add('d-none')); } }); </script> {% endblock %} {% macro swap_rows_action(icon, row_id, step=None) %} {# based on admin/model/row_actions.delete_row() #} <form class="icon" method="POST" action="{{ get_url('.swap') }}"> {% if csrf_token %} <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> {% endif %} {% set _input = 'swap' + row_id|string %} <input type="hidden" name="url" value="{{ return_url }}"/> <input type="hidden" name="id1" value="{{ row_id }}"/> <input type="hidden" name="id2"{% if step %} value="{{ row_id + step }}"{% else %} id="{{ _input }}"{% endif %}/> <button title="{{ _('Swap with…') if not step else _('Move down') if step > 0 else _('Move up') }}" {%- if not step %} onclick="return promptSwap({{_input}}, {{row_id}})"{% endif %}> <span class="fa fa-{{icon}} glyphicon glyphicon-{{icon}}"></span> </button> </form> {% endmacro %} {% block list_row_actions scoped %} {% for action in list_row_actions %} {{ action.render_ctx(get_pk_value(row), row) }} {% endfor %} {% if request.args|flt|length == 0 %} {# only shown when filters/ordering are disabled #} <div class="swap-toolbar" style="margin-left: 9px"> {% if row.id < 2 %} <div class="d-inline-block" style="width: 14px"><!-- placeholder for the 1st row button --></div> {% else %} {{ swap_rows_action('arrow-up', row.id, -1) }} {% endif %} {{ swap_rows_action('exchange', row.id) }} {% if row.id < count %} {{ swap_rows_action('arrow-down', row.id, +1) }} {% endif %} </div> {% endif %} {% endblock %} {% block tail %} {{ buku.fix_translations('bookmarks') }} {{ super() }} {{ buku.page_size_custom() }} {{ buku.script('buku_filter.js') }} {{ buku.script('order_filter.js') }} {{ buku.focus(None) }} {{ buku.link_saved() }} <script> $(document).on('click', `#modal-random`, function() { $(`#fa_modal_window .modal-content`).load($(`#random`).attr('href')); }); </script> {% endblock %} ================================================ FILE: bukuserver/templates/bukuserver/home.html ================================================ {% extends "admin/index.html" %} {% import 'bukuserver/lib.html' as buku with context %} {% block head %} {{ super() }} {{ buku.close_if_popup() }} {{ buku.brand_dbname() }} {{ buku.focus('main form[action="/"]') }} {% endblock %} {% block menu_links %} {{ super() }} <form class="form-inline navbar-right" style="gap:.275rem" action="{{ url_for('admin.search') }}" method="POST"> <div class="d-inline-block align-middle"> <input class="form-control" id="inputKeywords" placeholder="{{ _('Search bookmark') }}" name="keyword"/> <input type="hidden" name="markers" value="true"/> <input type="hidden" name="all_keywords" value="true"/> </div> <button type="submit" class="btn btn-secondary">{{ _gettext('Search') }}</button> </form> {% endblock %} {% block body %} {{ super() }} <main class="container text-center p-4"> <h1>BUKU</h1> <p class="lead">{{ _('Bookmark manager like a text-based mini-web') }}</p> <p> <a class="btn btn-lg btn-success" href="{{ url_for('bookmark.index_view') }}" role="button">{{ _('Bookmarks') }}</a> <a class="btn btn-lg btn-success" href="{{ url_for('tag.index_view') }}" role="button">{{ _('Tags') }}</a> <a class="btn btn-lg btn-success" href="{{ url_for('statistic.index') }}" role="button">{{ _('Statistic') }}</a> </p> <div class="col-md-4 offset-md-4"> <form action="{{ url_for('admin.search') }}" method="POST"> <div class="form-group"> {{ form.keyword.label }} {{ form.keyword(class_='form-control d-inline', style='width: auto') }} </div> <div class="text-left"> {% for field in [form.all_keywords, form.markers, form.deep, form.regex] -%} <div class="form-check" title="{{ field.description }}" data-toggle="tooltip" data-placement="bottom"> {{field()}} {{field.label}} </div> {%- endfor %} </div> <button type="submit" class="btn btn-secondary">{{ _gettext('Search') }}</button> </form> </div> <div class="col-md-4 offset-md-4"> <p class="pt-4"> {{_('Bookmarklet')}}: <a title="Drag this link to your bookmarks toolbar" href="{{ buku.bookmarklet() }}"> <b>✚ {{ _('Add to Buku') }}</b> </a><br/> <em style="font-size: smaller">{{ _("Note: if you select text on the page before activating the bookmarklet, it'll be used as description instead of page metadata.") }}</em> </p> </div> <details class="col-md-6 offset-md-3"> <summary style="display: list-item; cursor: pointer"> <em><strong>{{ _('Location Bar (keyboard-only) shortcut') }}</strong></em> </summary> <dl> <dt>{{ _('in Firefox:') }}</dt> <dd>{{ _('Open the bookmarks editor and set %(buku)s in the Keyword field of the bookmarklet.', buku='<code>@buku</code>'|safe) }}</dd> <dt>{{ _('in Chrome:') }}</dt> <dd> {{ _('In %(path)s, add a new row by placing %(add_to_buku)s, %(buku)s, and the copied bookmarklet URL in respective fields).', path='<em>'|safe + _('Settings > Search engine > Manage… > Site Search')|escape + '</em>'|safe, add_to_buku='<code>✚ '|safe + _('Add to Buku') + '</code>'|safe, buku='<code>@buku</code>'|safe) }} </dd> <dt>{{ _('usage:') }}</dt> <dd> {{ _("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='<code>Ctrl+L</code>'|safe, buku='<code>@buku</code>'|safe, enter='<code>Enter</code>'|safe) }} <br/><em style="font-size: smaller">{{ _('Note: in Firefox this changes displayed URL, but you can reset it by switching back to Location Bar and hitting %(escape)s twice.', escape='<code>Esc</code>'|safe) }}</em> </dd> </dl> </details> </main> {% endblock %} {% block tail %} {{ buku.set_lang() }} <script> $(`[data-toggle="tooltip"]`).attr('data-html', 'true').each(function () { this.title = this.title.replace(/'(.*?)'/g, `'<strong><code>$1</code></strong>'`) .replace(/(?<=^|[^\p{L}]){{ _('FULL') }}(?=$|[^\p{L}])/g, `<strong><em>{{ _('FULL')|lower }}</em></strong>`); }).attr('data-container', 'body').attr('data-trigger', 'hover').tooltip(); </script> <style>.tooltip-inner {text-align: left; white-space: pre; max-width: 600px}</style> {% endblock %} ================================================ FILE: bukuserver/templates/bukuserver/lib.html ================================================ {% macro filter(name, value) %}{{ url_for('bookmark.index_view', **{'flt0_'+name: value}) }}{% endmacro %} {% macro bookmarklet() -%} {% with url = url_for('bookmarklet', _external=True) -%} {% include 'bukuserver/bookmarklet.url' %} {%- endwith %} {%- endmacro %} {% macro script(filename) %} <script src="{{ url_for('static', filename='bukuserver/js/'+filename) }}"></script> {% endmacro %} {% macro close_if_popup() %} <script>({{ g.popup|default|tojson }} || (opener && (opener !== window))) && close()</script> {% endmacro %} {% macro limit_navigation_if_popup() %} <style> .popup a.navbar-brand {pointer-events: none} .popup #admin-navbar-collapse {display: block} .popup :is(.navbar-nav, .navbar-toggler) {display: none} .popup .nav-tabs :is(:first-child, :nth-child(2)) > .nav-link:not(.active) {display: none} </style> <script> const POPUP = {{ g.popup|default|tojson }} || (opener && (opener !== window)) || ''; POPUP && (document.body.classList.add('popup'), setTimeout(() => { $(`.nav-tabs a:not([href^='javascript:'])`).each((_, e) => e.href += `&popup=${POPUP}`); $(`.submit-row .btn-danger`).on('click', () => close()); $(`.delete-form`).prepend(`<input type="hidden" name="popup" value="${POPUP}">`); })); </script> {% endmacro %} {% macro brand_dbname(nohover=False) %} <style> .dbname {font-weight: bold; font-family: monospace; cursor: help} .navbar-brand .dbname {font-size: medium} body:not(.popup) .navbar-brand {text-align: center} body.popup .navbar-brand .dbname, body:not(.popup) .popup.dbname {display: none} .popup.dbname {float: right; font-size: smaller; line-height: 2.2} {% if nohover %} body:not(.popup) .navbar-brand {padding-top: 0; padding-bottom: 0; margin-top: -.625rem; margin-bottom: -.625rem} {% else %} body:not(.popup) .navbar-brand:hover {padding-top: 0; padding-bottom: 0; margin-top: -.625rem; margin-bottom: -.625rem} body:not(.popup) .navbar-brand:not(:hover) .dbname {display: none} {% endif %} </style> <script>{ let _dbname = (cls='') => `<div class="dbname ${cls}" title="${ {{dbfile|tojson}} }">${ {{dbname|tojson}} }</div>`; addEventListener('load', () => {$('.navbar-brand').html((_, s) => s + _dbname()); $('.navbar-collapse').html((_, s) => s + _dbname('popup navbar-brand'))}); }</script> {% endmacro %} {% macro focus(location='body') %} {% if location %} <script>setTimeout(() => $('{{location|safe}} input:not([type=hidden])')[0]?.focus(), 500)</script> {% else %} <script>$(document).ready(() => document.activeElement?.blur())</script> {% endif %} {% endmacro %} {% macro fetch_checkbox(checked=True, modal=False) %} <script>{ let tooltip = {{ _('Collect missing data (+extra tags) by fetching & parsing the webpage')|tojson }} $('.admin-form [name=fetch]').remove(); $('.admin-form fieldset{% if modal %} .modal-footer{% endif %}').{% if modal %}prepend{% else %}append{% endif %}( $(`<div class="form-group {{ 'mb-0' if modal else '' }}"{% if modal %} style="flex-grow: 1"{% endif %} title="${tooltip}">` +`<label class="control-label {{ 'mb-0' if modal else '' }}">{{ _('Fetch') }}   </label>` +`<input type="checkbox" name="fetch"{% if checked %} checked{% endif %}></div>`)); }</script> {% endmacro %} {% macro horizontal_form(excluding_popups=False) %} <script> $('.admin-form .form-group').each(function () { if ($('.submit-row', this).length > 0{% if excluding_popups %} || document.body.matches('.popup'){% endif %}) { $('.submit-row', this).addClass(document.body.matches('.popup') ? 'col-md-12' : 'offset-md-2'); } else if ($('input[type=checkbox], input[type=radio]', this).length > 0) { $('label', this).addClass('form-row').css({cursor: 'pointer'}).html(`<span>${ $('label', this).html() }</span>`); $('label span', this).addClass('col-md-2 col-form-label text-right d-inline-block'); $('input', this).appendTo($('label', this)); } else { $(this).addClass('form-row'); $('input, textarea, select', this).addClass('col-md-10'); $('label', this).addClass('col-md-2 col-form-label text-right'); } }); </script> {% endmacro %} {% macro details_formatting(prefix='') %} <script> $(`.modal-header h3`).wrapInner('<h5 class="modal-title">').children(0).unwrap(); // flask-admin #2505 $(`body.popup {{prefix}} a`).attr('target', '_blank'); </script> {% endmacro %} {% macro link_saved() %} {% set backlink = request.args.get('url', request.full_path) %} {% set saved = session.pop('saved', None) %} {% if saved %} <script>{ const SUCCESS = [{{ _gettext('Record was successfully created.') | tojson }}, {{ _gettext('Record was successfully saved.') | tojson }}]; $(`.alert-success`).filter((idx, e) => SUCCESS.some(s => e.innerText.includes(s))).html((_, s) => s.replace(/(<\/button>)([^]*)$/, `$1<a href="{{ url_for('.details_view', id=saved, url=backlink, popup=g.popup) }}">$2</a>`)); }</script> {% endif %} {% endmacro %} {% macro set_lang() %} <script>document.documentElement.lang = {{ lang|tojson }}</script> {% endmacro %} {% macro fix_translations(list) %} {# invoke in *_list.html BEFORE tail.super()! #} {{ set_lang() }} {% set LISTS = {'tags': {'name': _p('tags', 'name'), 'usage_count': _p('tags', 'usage count')}, 'bookmarks': {'id': _p('bookmarks', 'index'), 'url': _p('bookmarks', 'url'), 'title': _p('bookmarks', 'title'), 'tags': _p('bookmarks', 'tags'), 'order': _p('bookmarks', 'order')}} %} <script>{ $(`button[title="Delete record"]`).attr('title', {{ _('Delete record')|tojson }}); // see flask-admin issue #1974 const FILTERS = {{ LISTS.get(list, {})|tojson }}; let watcher = new MutationObserver(xs => xs.forEach(x => x.addedNodes.forEach(e => { if (!['filter-groups-data', 'active-filters-data'].includes(e.id)) return; e.setAttribute('data-initial', e.innerHTML); let data = JSON.parse(e.innerHTML); let converted = (e.id == 'active-filters-data' ? data.map(([id, name, value]) => [id, FILTERS[name]||name, value]) : Object.fromEntries( Object.entries(data).map(([k, v]) => [FILTERS[k]||k, v]) )); e.innerHTML = JSON.stringify(converted); }))); watcher.observe(document.body, {childList: true, subtree: true}); setTimeout(() => watcher.disconnect()); // will stop observing once <body> is rendered $(document).ready(() => { $(`.field-filters .filter`).each(function () { let text = $(this).text(); $(this).text(FILTERS[text] || text); }); }) }</script> {% endmacro %} {% macro page_size_custom() %} <script> $(document).ready(function() { let pageSize = url => new URL(url || location.host).searchParams.get('page_size'); $(`.nav.nav-tabs .dropdown-menu`).each(function () { let _sizes = $(`a.dropdown-item`, this).map(function () {return pageSize(this.href)}).get(); if (_sizes.length > 2 && _sizes.length == new Set(_sizes).size) // 3+ links; each link has different pagesize $('a', this).last().clone().text({{ _('custom')|tojson }}).removeClass('active').attr('href', `#`).on('click', () => { let page = prompt({{ _('Set custom page size (empty for default)')|tojson }}, pageSize(location) || ''); if (Number(page) || (page == "")) { let search = new URL(location).searchParams; (page ? search.set('page_size', page) : search.delete('page_size')); location.search = search; } else if (page != null) alert({{ _('Invalid page size')|tojson }} + `: "${page}"`); return false; }).appendTo(this); }) }); </script> {% endmacro %} ================================================ FILE: bukuserver/templates/bukuserver/statistic.html ================================================ {% extends "bukuserver/home.html" %} {% import 'bukuserver/lib.html' as buku with context %} {% block head %} {{ super() }} {{ buku.close_if_popup() }} <script>{// realtime redrawing "Data created" datetime const UNITS = {day: 60*60*24, hour: 60*60, minute: 60, second: 1}; const AGO = {{ {'day': _('{} days ago'), 'hour': _('{} hours ago'), 'minute': _('{} minutes ago'), 'second': _('{} seconds ago')}|tojson }}; addEventListener('load', function recalcReltime() { let diff = Date.now()/1000 - created.getAttribute('data-timestamp'); let unit = (diff < 5 ? "" : Object.keys(UNITS).find(k => diff >= UNITS[k])); created.innerText = (!unit ? {{ _('just now')|tojson }} : AGO[unit].replace('{}', parseInt(diff/UNITS[unit]))); setTimeout(recalcReltime, 1000 * {second: 1, minute: 15, hour: 60, day: 15*60}[unit||'second']); }); }</script> {% endblock %} {% block body %} <div class="container mb-4"> <form class="mb-4" action="{{ url_for('statistic.index') }}" method="POST"> {{ _('Data created') }} <span id="created" rel="tooltip" title="{{ datetime }}" data-timestamp={{ datetime.timestamp() }}>{{ datetime_text }}</span> <button type="submit" class="btn btn-secondary btn-sm">{{ _('Refresh')|lower }}</button> </form> <h3>{{ _('Netloc') }}</h3> {% if netlocs %} <div class="row"> <div class="col-md-6"> <canvas id="mostCommonChart" width="500" height="500"></canvas> </div> <div class="col-md-6"> {% if netlocs.cropped %} <button type="button" class="btn btn-primary btn-sm" data-toggle="modal" data-target="#netlocModal"> {{ _('View all') }} </button> {% endif %} <table class="table"> <thead> <tr> <th>{{ _('Rank') }}</th> <th>{{ _('Netloc') }}</th> <th class="text-right">{{ _('Number') }}</th> </tr> </thead> <tbody> {% for item in netlocs %} <tr> <td>{{ loop.index }}</td> <td> <a href="{{ buku.filter('url_netloc_match', item.name) }}">{{ item.name or _('(no netloc)') }}</a> </td> <td class="text-right">{{ item.amount }}</td> </tr> {% endfor %} </tbody> </table> </div> </div> {% else %} <span>{{ _('No bookmarks found.') }}</span> {% endif %} {% if netlocs.cropped %} <div class="modal fade" id="netlocModal" tabindex="-1" role="dialog"> <div class="modal-dialog" role="document"> <div class="modal-content"> <div class="modal-header"> <h4 class="modal-title" id="myModalLabel">{{ _('Netloc ranking') }}</h4> <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> </div> <div class="modal-body"> <table class="table table-sm"> <thead class="thead-dark"> <tr> <th>{{ _('Rank') }}</th> <th>{{ _('Netloc') }}</th> <th class="text-right">{{ _('Number') }}</th> </tr> </thead> <tbody> {% for name, amount in netlocs.all %} <tr> <td>{{ loop.index }}</td> <td> <a href="{{ buku.filter('url_netloc_match', name) }}">{{ name or _('(no netloc)') }}</a> </td> <td class="text-right">{{ amount }}</td> </tr> {% endfor %} </tbody> </table> </div> </div> </div> </div> {% endif %} <h3 class="col-md-12">{{ _('Tag') }}</h3> {% if tags %} <div class="row"> <div class="col-md-6"> <canvas id="mostCommonTagChart" width="500" height="500"></canvas> </div> <div class="col-md-6"> {% if tags.cropped %} <button type="button" class="btn btn-primary btn-sm" data-toggle="modal" data-target="#tagRankModal"> {{ _('View all') }} </button> {% endif %} <table class="table"> <thead> <tr> <th>{{ _('Rank') }}</th> <th>{{ _('Tag') }}</th> <th class="text-right">{{ _('Number') }}</th> </tr> </thead> <tbody> {% for item in tags %} <tr> <td>{{ loop.index }}</td> <td> <a href="{{ buku.filter('tags_contain', item.name) }}">{{ item.name }}</a> </td> <td class="text-right">{{ item.amount }}</td> </tr> {% endfor %} </tbody> </table> </div> </div> {% else %} <span>{{ _('No tags found.') }}</span> {% endif %} {% if tags.cropped %} <div class="modal fade" id="tagRankModal" tabindex="-1" role="dialog"> <div class="modal-dialog" role="document"> <div class="modal-content"> <div class="modal-header"> <h4 class="modal-title" id="myModalLabel">{{ _('Tag ranking') }}</h4> <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> </div> <div class="modal-body"> <table class="table table-sm"> <thead class="thead-dark"> <tr> <th>{{ _('Rank') }}</th> <th>{{ _('Tag') }}</th> <th class="text-right">{{ _('Number') }}</th> </tr> </thead> <tbody> {% for name, amount in tags.all %} <tr> <td>{{ loop.index }}</td> <td> <a href="{{ buku.filter('tags_contain', name) }}">{{ name }}</a> </td> <td class="text-right">{{ amount }}</td> </tr> {% endfor %} </tbody> </table> </div> </div> </div> </div> {% endif %} <h3 class="col-md-12">{{ _('Title (common)') }}</h3> {% if titles %} <div class="row"> <div class="col-md-6"> <canvas id="mostCommonTitleChart" width="500" height="500"></canvas> </div> <div class="col-md-6"> {% if titles.cropped %} <button type="button" class="btn btn-primary btn-sm" data-toggle="modal" data-target="#titleModal"> {{ _('View all') }} </button> {% endif %} <table class="table"> <thead> <tr> <th>{{ _('Rank') }}</th> <th>{{ _('Title') }}</th> <th class="text-right">{{ _('Number') }}</th> </tr> </thead> <tbody> {% for item in titles %} <tr> <td>{{ loop.index }}</td> <td> <a href="{{ buku.filter('title_equals', item.name) }}">{{ item.name or _('(no title)')}}</a> </td> <td class="text-right">{{ item.amount }}</td> </tr> {% endfor %} </tbody> </table> </div> </div> {% else %} <span>{{ _('No common titles found.') }}</span> {% endif %} {% if titles.cropped %} <div class="modal fade" id="titleModal" tabindex="-1" role="dialog"> <div class="modal-dialog" role="document"> <div class="modal-content"> <div class="modal-header"> <h4 class="modal-title" id="myModalLabel">{{ _('Common titles ranking') }}</h4> <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> </div> <div class="modal-body"> <table class="table table-sm"> <thead class="thead-dark"> <tr> <th>{{ _('Rank') }}</th> <th>{{ _('Title') }}</th> <th class="text-right">{{ _('Number') }}</th> </tr> </thead> <tbody> {% for name, amount in titles.all %} <tr> <td>{{ loop.index }}</td> <td style="word-break:break-all;"> <a href="{{ buku.filter('title_equals', name) }}">{{ name or _('(no title)') }}</a> </td> <td class="text-right">{{ amount }}</td> </tr> {% endfor %} </tbody> </table> </div> </div> </div> </div> {% endif %} {% endblock %} {% block tail %} {{ super() }} {{ buku.set_lang() }} <script>// sticky table headers in modals $('.modal-body').each(function () { $('thead', this).css({top: `calc(-${$.css(this, 'padding-top')} - 1px)`}); // +border }); </script> {{ buku.script('Chart.js') }} <script> {% set NO_NETLOC = '\u200B' + _('(no netloc)') + '\u200B' -%} var netlocCtx = document.getElementById("mostCommonChart")?.getContext('2d'); var netlocChart = netlocCtx && new Chart(netlocCtx, { type: 'pie', data: { datasets: [{ data: [ {% for val in netlocs %} {{val.amount}}, {% endfor %} ], backgroundColor: [ {% for val in netlocs %} {{val.color|tojson}}, {% endfor %} ], }], // These labels appear in the legend and in the tooltips when hovering different arcs labels: [ {% for val in netlocs %} {{(val.name or NO_NETLOC)|tojson}}, {% endfor %} ] }, options: { onClick (evt, item) { if (!item[0]) return; var value = this.data.labels[item[0]._index].replace({{NO_NETLOC|tojson}}, ""); var form = $('<form></form>'); form.attr("method", "get"); form.attr("action", "{{url_for('bookmark.index_view')}}"); var field = $('<input></input>'); field.attr("type", "hidden"); field.attr("name", "flt0_url_netloc_match"); field.attr("value", value); form.append(field); // The form needs to be a part of the document in // order for us to be able to submit it. $(document.body).append(form); form.submit(); } } }); var tagRankCtx = document.getElementById("mostCommonTagChart")?.getContext('2d'); var tagRankChart = tagRankCtx && new Chart(tagRankCtx, { type: 'pie', data: { datasets: [{ data: [ {% for val in tags %} {{val.amount}}, {% endfor %} ], backgroundColor: [ {% for val in tags %} {{val.color|tojson}}, {% endfor %} ], }], // These labels appear in the legend and in the tooltips when hovering different arcs labels: [ {% for val in tags %} {{val.name|tojson}}, {% endfor %} ] }, options: { onClick (evt, item) { if (!item[0]) return; var tagStr = this.data.labels[item[0]._index]; var url = "{{url_for('bookmark.index_view')}}?flt0_tags_contain=" + encodeURIComponent(tagStr); window.location.href = url; } } }); {% set NO_TITLE = '\u200B' + _('(no title)') + '\u200B' -%} var titleCtx = document.getElementById("mostCommonTitleChart")?.getContext('2d'); var titleChart = titleCtx && new Chart(titleCtx, { type: 'pie', data: { datasets: [{ data: [ {% for val in titles %} {{val.amount}}, {% endfor %} ], backgroundColor: [ {% for val in titles %} {{val.color|tojson}}, {% endfor %} ], }], // These labels appear in the legend and in the tooltips when hovering different arcs labels: [ {% for val in titles %} {{(val.name|trim or NO_TITLE)|tojson}}, {% endfor %} ] }, options: { onClick (evt, item) { if (!item[0]) return; var value = this.data.labels[item[0]._index].replace({{NO_TITLE|tojson}}, ""); var form = $('<form></form>'); form.attr("method", "get"); form.attr("action", "{{url_for('bookmark.index_view')}}"); var field = $('<input></input>'); field.attr("type", "hidden"); field.attr("name", "flt0_title_equals"); field.attr("value", value); form.append(field); // The form needs to be a part of the document in // order for us to be able to submit it. $(document.body).append(form); form.submit(); } } }); </script> </div> {% endblock %} ================================================ FILE: bukuserver/templates/bukuserver/tag_edit.html ================================================ {% extends 'admin/model/edit.html' %} {% import 'bukuserver/lib.html' as buku with context %} {% block tail %} {{ super() }} {{ buku.set_lang() }} {{ buku.brand_dbname() }} {{ buku.horizontal_form() }} {{ buku.focus() }} {% endblock %} ================================================ FILE: bukuserver/templates/bukuserver/tags_list.html ================================================ {% extends 'admin/model/list.html' %} {% import 'bukuserver/lib.html' as buku with context %} {% block head %} {{ super() }} {{ buku.close_if_popup() }} {{ buku.brand_dbname() }} {% endblock %} {% block model_menu_bar_before_filters %} {{ super() }} <form id="refresh" class="d-none" method="POST" action="refresh"></form> <li class="nav-item"> <a class="nav-link" href="#" onclick="refresh.submit()">{{ _('Refresh') }}</a> </li> {% endblock %} {% block tail %} {{ buku.fix_translations('tags') }} {{ super() }} {{ buku.page_size_custom() }} {{ buku.focus(None) }} <script>$('tr:has(a[href$="/bookmark/?flt0_tags_number_equal=0"]) .list-buttons-column').html('')</script> {% 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 `<locale>/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 <EMAIL@ADDRESS>, [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 <EMAIL@ADDRESS>\n" "Language: de\n" "Language-Team: de <LL@li.org>\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 "<EMPTY TITLE>" 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 "<UNTAGGED>" 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 <EMAIL@ADDRESS>\n" "Language: fr\n" "Language-Team: fr <LL@li.org>\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 "<EMPTY TITLE>" 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 "<UNTAGGED>" 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 <EMAIL@ADDRESS>\n" "Language: ru\n" "Language-Team: ru <LL@li.org>\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 "<EMPTY TITLE>" 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 "<UNTAGGED>" 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'<img class="favicon" src="http://www.google.com/s2/favicons?domain={netloc}"/> '] title = model.title or _('<EMPTY TITLE>') 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'<span class="title" title="{model.url}">{_title}</span>'] if self.url_render_mode == 'netloc' and url_for_index_view_netloc: res += [f'<span class="netloc"> ({link(netloc, url_for_index_view_netloc)})</span>'] if not parsed_url.scheme: res += [f'<span class="link">{escape(model.url)}</span>'] elif self.url_render_mode == 'full': res += [f'<span class="link">{link(model.url, model.url, new_tab=new_tab)}</span>'] 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'<div class="tag-list">{"".join(tag_links)}</div>'] description = model.description and f'<div class="description">{escape(model.description)}</div>' 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'<div class="tag-list">{"".join(tags)}</div>') if name == 'url': res, netloc, scheme = [], buku.get_netloc(value), urlparse(value).scheme if netloc and not app_param('DISABLE_FAVICON', False): icon = f'<img class="favicon" title="netloc:{netloc}" src="http://www.google.com/s2/favicons?domain={netloc}"/>' res += [link(icon, url_for('bookmark.index_view', flt0_url_netloc_match=netloc), html=True)] elif netloc: badge = f'<span class="netloc">netloc:{escape(netloc)}</span>' 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'<div class="link">{" ".join(res)}</div>') return Markup(f'<div class="{name}">{escape(value)}</div>') 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}, _('<UNTAGGED>'))) 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'<a{cls} href="{escape(url)}"{target}>{text if html else escape(text)}</a>' 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. <details><summary><h3>Screenshots</h3></summary> ![DB selection dialog](https://github.com/Buku-dev/docs/blob/v4.9-bootstrap3/bukuserver/runner-script/db-selection.png "DB selection dialog") _DB selection dialog – shown on startup (unless no DB files were found); initially the previous DB is selected_ ![DB creation dialog](https://github.com/Buku-dev/docs/blob/v4.9-bootstrap3/bukuserver/runner-script/db-creation.png "DB creation dialog") _DB creation dialog – shown if no DB was selected (or none found)_ ![DB exists](https://github.com/Buku-dev/docs/blob/v4.9-bootstrap3/bukuserver/runner-script/existing-db-confirmation.png "DB exists") _A confirmation dialog is shown if new DB name is taken already_ ![DB naming error](https://github.com/Buku-dev/docs/blob/v4.9-bootstrap3/bukuserver/runner-script/invalid-db-error.png "DB naming error") _DB name must be a valid filename, sans the `.db` extension (invalid chars: `/` on Linux, or any of `<>:"/\|?*` on Windows)_ ![no-GUI mode](https://github.com/Buku-dev/docs/blob/v4.9-bootstrap3/bukuserver/runner-script/non-gui.png "no-GUI mode") _DB selection prompt in console shell/no-GUI mode (`BUKU_NOGUI=y`)_ </details> ## 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('<KeyPress>', self.onkeypress) self._list.bind('<Double-1>', 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 <buku> .. toctree:: :maxdepth: 2 :caption: Bukuserver Documentation bukuserver <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 <engineerarun@gmail.com> 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: "<!doctype html>\n<html>\n<head>\n <title>Example Domain\n\n \ \n \n \n \n\n\n\n\n\n\n" headers: Age: - '83814' Cache-Control: - max-age=604800 Content-Length: - '648' Content-Type: - text/html; charset=UTF-8 Date: - Sat, 07 Jan 2023 07:50:26 GMT Etag: - '"3147526947+ident+gzip"' Expires: - Sat, 14 Jan 2023 07:50:26 GMT Last-Modified: - Thu, 17 Oct 2019 07:18:26 GMT Server: - ECS (sab/56BA) Vary: - Accept-Encoding X-Cache: - HIT status: code: 200 message: OK version: 1 ================================================ FILE: tests/cassettes/test_buku/test_fetch_data_with_url[http---example.com-page1.txt-exp_res2].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: HEAD uri: http://example.com/page1.txt response: body: string: '' headers: Cache-Control: - max-age=604800 Content-Encoding: - gzip Content-Length: - '648' Content-Type: - text/html; charset=UTF-8 Date: - Sat, 07 Jan 2023 07:50:27 GMT Expires: - Sat, 14 Jan 2023 07:50:27 GMT Server: - EOS (vny/0451) Vary: - Accept-Encoding status: code: 404 message: Not Found version: 1 ================================================ FILE: tests/cassettes/test_buku/test_fetch_data_with_url[http---www.vim.org-scripts-script.php~-exp_res7].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://www.vim.org/scripts/script.php?script_id=4641 response: body: string: ' 302 Found

Found

The document has moved here.


Apache/2.4.10 (Debian) Server at www.vim.org Port 80
' headers: Content-Length: - '314' Content-Type: - text/html; charset=iso-8859-1 Date: - Sat, 07 Jan 2023 07:50:29 GMT Location: - https://www.vim.org/scripts/script.php?script_id=4641 Server: - Apache/2.4.10 (Debian) status: code: 302 message: Found - 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: https://www.vim.org/scripts/script.php?script_id=4641 response: body: string: !!binary | PCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMDEgVHJhbnNpdGlvbmFs Ly9FTiI+CjxodG1sPgoKPGhlYWQ+CiAgPGxpbmsgcmVsPSJTdHlsZXNoZWV0IiB0eXBlPSJ0ZXh0 L2NzcyIgaHJlZj0iL2Nzcy9zdHlsZS5jc3MiID4KICA8dGl0bGU+bWxlc3NuYXVfY2FzZSAtICJp bi1jYXNlIiBzZWxlY3Rpb24sIGRlbGV0aW9uIGFuZCBzdWJzdGl0dXRpb24gZm9yIHVuZGVyc2Nv cmUsIGNhbWVsLCBtaXhlZCBjYXNlIDogdmltIG9ubGluZTwvdGl0bGU+CiAgPG1ldGEgaHR0cC1l cXVpdj0iQ29udGVudC1UeXBlIiBjb250ZW50PSJ0ZXh0L2h0bWw7IGNoYXJzZXQ9SVNPLTg4NTkt MSI+CiAgPG1ldGEgbmFtZT0iS0VZV09SRFMiIGNvbnRlbnQ9IlZpbSwgVmkgSU1wcm92ZWQsIHRl eHQgZWRpdG9yLCBob21lLCBkb2N1bWVudGF0aW9uLCB0aXBzLCBzY3JpcHRzLCBuZXdzIj4KICA8 bWV0YSBuYW1lPSJ2aWV3cG9ydCIgY29udGVudD0id2lkdGg9MTAwMCwgaW5pdGlhbC1zY2FsZT0x Ij4KICA8bGluayByZWw9InNob3J0Y3V0IGljb24iIHR5cGU9ImltYWdlL3gtaWNvbiIgaHJlZj0i L2ltYWdlcy92aW1fc2hvcnRjdXQuaWNvIj4KPC9oZWFkPgoKPGJvZHkgdG9wbWFyZ2luPSIwIiBs ZWZ0bWFyZ2luPSIwIiBtYXJnaW5oZWlnaHQ9IjAiIG1hcmdpbndpZHRoPSIwIiBiZ2NvbG9yPSIj ZmZmZmZmIj4gCgo8IS0tIEhFQURFUiwgU1BPTlNPUiBJTUFHRSwgVklNIElNQUdFIEFORCBCT09L IEFEIC0tPgo8dGFibGUgd2lkdGg9IjEwMCUiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0i MCIgYm9yZGVyPSIwIj4KICA8dHI+CiAgICA8dGQgY2xhc3M9ImxpZ2h0YmciIHdpZHRoPSIyMDgi IHN0eWxlPSJiYWNrZ3JvdW5kLWNvbG9yOiAjMDA1N2I3Ij48aW1nIHNyYz0iL2ltYWdlcy9zcGFj ZXIuZ2lmIiB3aWR0aD0iMTAwIiBoZWlnaHQ9IjUiIGFsdD0iIj48L3RkPgogICAgPHRkIGNvbHNw YW49IjQiIGNsYXNzPSJsaWdodGJnIj48aW1nIHNyYz0iL2ltYWdlcy9zcGFjZXIuZ2lmIiB3aWR0 aD0iMSIgaGVpZ2h0PSI1IiBhbHQ9IiI+PC90ZD4KICAgIDx0ZCBjbGFzcz0ibGlnaHRiZyIgd2lk dGg9IjIwOCIgc3R5bGU9ImJhY2tncm91bmQtY29sb3I6ICMwMDU3YjciPjxpbWcgc3JjPSIvaW1h Z2VzL3NwYWNlci5naWYiIHdpZHRoPSIxMDAiIGhlaWdodD0iNSIgYWx0PSIiPjwvdGQ+CiAgPC90 cj4KICA8dHI+CiAgPHRkPgogICAgPHRhYmxlIHdpZHRoPSIyMDgiIGNlbGxwYWRkaW5nPSIwIiBj ZWxsc3BhY2luZz0iMCIgYm9yZGVyPSIwIj4KICAgICAgPHRyPgoJPHRkIGNsYXNzPSJsaWdodGJn IiBzdHlsZT0iYmFja2dyb3VuZC1jb2xvcjogIzAwNTdiNyI+CgkgIDxhIGhyZWY9Imh0dHBzOi8v ZXUtc29saWRhcml0eS11a3JhaW5lLmVjLmV1cm9wYS5ldS9ldS1zdGFuZHMtdWtyYWluZV9lbiI+ PGltZyBzcmM9Ii9pbWFnZXMvc3BhY2VyLmdpZiIgd2lkdGg9IjIwNCIgaGVpZ2h0PSIyMyIgYWx0 PSIiPjwvYT4KCTwvdGQ+CiAgICAgIDwvdHI+CiAgICAgIDx0cj4KCTx0ZCBjbGFzcz0ibGlnaHRi ZyIgc3R5bGU9ImJhY2tncm91bmQtY29sb3I6ICNmZmQ3MDAiPgoJICA8YSBocmVmPSJodHRwczov L2V1LXNvbGlkYXJpdHktdWtyYWluZS5lYy5ldXJvcGEuZXUvZXUtc3RhbmRzLXVrcmFpbmVfZW4i PjxpbWcgc3JjPSIvaW1hZ2VzL3NwYWNlci5naWYiIHdpZHRoPSIyMDQiIGhlaWdodD0iMjMiIGFs dD0iIj48L2E+CiAgICAgICAgPC90ZD4KICAgICAgPC90cj4KICAgIDwvdGFibGU+CiAgPC90ZD4K ICA8dGQgY2xhc3M9ImxpZ2h0YmciPiZuYnNwOyZuYnNwOyZuYnNwOzwvdGQ+CiAgPHRkIGNsYXNz PSJsaWdodGJnIiBhbGlnbj0ibGVmdCI+PGEgaHJlZj0iaHR0cHM6Ly93d3cudmltLm9yZy9zcG9u c29yL2luZGV4LnBocCI+PGltZyBzcmM9Ii9pbWFnZXMvc3BvbnNvcnZpbS5naWYiIGFsdD0ic3Bv bnNvciBWaW0gZGV2ZWxvcG1lbnQiIGJvcmRlcj0iMCI+PC9hPjwvdGQ+CiAgPHRkIGNsYXNzPSJs aWdodGJnIiBhbGlnbj0iY2VudGVyIj4KCSA8YSBocmVmPSIvIj48aW1nIHNyYz0iL2ltYWdlcy92 aW1faGVhZGVyLmdpZiIgYm9yZGVyPSIwIiBhbHQ9IlZpbSBsb2dvIiBjbGFzcz0iYWxpZ24tbWlk ZGxlIj48L2E+CgkgIDwvdGQ+CiAgPHRkIGNsYXNzPSJsaWdodGJnIiBhbGlnbj0icmlnaHQiPjxh IGhyZWY9Imh0dHA6Ly9pY2NmLWhvbGxhbmQub3JnL2NsaWNrNS5odG1sIj48aW1nIHNyYz0iL2lt YWdlcy9idXloZWxwbGVhcm4uZ2lmIiBhbHQ9IlZpbSBCb29rIEFkIiBib3JkZXI9IjAiPjwvYT48 L3RkPgogIDx0ZD4KICAgIDx0YWJsZSB3aWR0aD0iMjA4IiBjZWxscGFkZGluZz0iMCIgY2VsbHNw YWNpbmc9IjAiIGJvcmRlcj0iMCI+CiAgICAgIDx0cj4KCTx0ZCBjbGFzcz0ibGlnaHRiZyIgd2lk dGg9IjIwOCIgc3R5bGU9ImJhY2tncm91bmQtY29sb3I6ICMwMDU3YjciPgoJICA8YSBocmVmPSJo dHRwczovL2V1LXNvbGlkYXJpdHktdWtyYWluZS5lYy5ldXJvcGEuZXUvZXUtc3RhbmRzLXVrcmFp bmVfZW4iPjxpbWcgc3JjPSIvaW1hZ2VzL3NwYWNlci5naWYiIHdpZHRoPSIyMDQiIGhlaWdodD0i MjMiIGFsdD0iIj48L2E+Cgk8L3RkPgogICAgICA8L3RyPgogICAgICA8dHI+Cgk8dGQgY2xhc3M9 ImxpZ2h0YmciIHdpZHRoPSIyMDgiIHN0eWxlPSJiYWNrZ3JvdW5kLWNvbG9yOiAjZmZkNzAwIj4K CSAgPGEgaHJlZj0iaHR0cHM6Ly9ldS1zb2xpZGFyaXR5LXVrcmFpbmUuZWMuZXVyb3BhLmV1L2V1 LXN0YW5kcy11a3JhaW5lX2VuIj48aW1nIHNyYz0iL2ltYWdlcy9zcGFjZXIuZ2lmIiB3aWR0aD0i MjA0IiBoZWlnaHQ9IjIzIiBhbHQ9IiI+PC9hPgoJPC90ZD4KICAgICAgPC90cj4KICAgIDwvdGFi bGU+CiAgPC90ZD4KICA8L3RyPgogIDx0cj4KICAgIDx0ZCBjbGFzcz0ibGlnaHRiZyIgc3R5bGU9 ImJhY2tncm91bmQtY29sb3I6ICNmZmQ3MDAiPjxpbWcgc3JjPSIvaW1hZ2VzL3NwYWNlci5naWYi IHdpZHRoPSIxMDAiIGhlaWdodD0iNSIgYWx0PSIiPjwvdGQ+CiAgICA8dGQgY29sc3Bhbj0iNCIg Y2xhc3M9ImxpZ2h0YmciPjxpbWcgc3JjPSIvaW1hZ2VzL3NwYWNlci5naWYiIHdpZHRoPSIxIiBo ZWlnaHQ9IjUiIGFsdD0iIj48L3RkPgogICAgPHRkIGNsYXNzPSJsaWdodGJnIiBzdHlsZT0iYmFj a2dyb3VuZC1jb2xvcjogI2ZmZDcwMCI+PGltZyBzcmM9Ii9pbWFnZXMvc3BhY2VyLmdpZiIgd2lk dGg9IjEwMCIgaGVpZ2h0PSI1IiBhbHQ9IiI+PC90ZD4KICA8L3RyPgo8L3RhYmxlPgo8IS0tIFRI RSBQQUdFIEJPRFk6IEJFVFdFRU4gSEVBREVSIEFORCBGT09URVIgLS0+Cgo8dGFibGUgY2VsbHBh ZGRpbmc9IjAiIGNlbGxzcGFjaW5nPSIwIiBib3JkZXI9IjAiIHdpZHRoPSIxMDAlIj4KICA8Y29s IHdpZHRoPSIxODAiPgogIDxjb2wgd2lkdGg9IjEiPgoKICA8dHIgdmFsaWduPSJ0b3AiPgogICAg PHRkIGNsYXNzPSJzaWRlYmFyIj4KICAgICAgPHRhYmxlIHdpZHRoPSIxODAiIGNlbGxwYWRkaW5n PSI0IiBjZWxsc3BhY2luZz0iMCIgYm9yZGVyPSIwIj4KICAgICAgICA8dHIgdmFsaWduPSJ0b3Ai PgogICAgICAgICAgPHRkIGNsYXNzPSJzaWRlYmFyIj4KCjwhLS0gSU5DTFVERSBUSEUgUEFHRSBO QVZJR0FUSU9OIC0tPgo8dGFibGUgd2lkdGg9IjEwMCUiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3Bh Y2luZz0iMCIgYm9yZGVyPSIwIiBib3JkZXJjb2xvcj0icmVkIj4KICAgIDx0cj4KICAgICAgICA8 dGQ+PHNtYWxsPm5vdCBsb2dnZWQgaW4gKDxhIGhyZWY9Imh0dHBzOi8vd3d3LnZpbS5vcmcvbG9n aW4ucGhwIj5sb2dpbjwvYT4pPC9zbWFsbD48L3RkPgogICAgPC90cj4KICAgIDx0cj48dGQ+Cjxz bWFsbD4mbmJzcDs8L3NtYWxsPgo8Zm9ybSBhY3Rpb249Imh0dHBzOi8vd3d3Lmdvb2dsZS5jb20v Y3NlIiBpZD0iY3NlLXNlYXJjaC1ib3giPgogIDxkaXY+CiAgICA8aW5wdXQgdHlwZT0iaGlkZGVu IiBuYW1lPSJjeCIgdmFsdWU9InBhcnRuZXItcHViLTMwMDUyNTk5OTgyOTQ5NjI6YnZ5bmk1OWtq cjEiIC8+CiAgICA8aW5wdXQgdHlwZT0iaGlkZGVuIiBuYW1lPSJpZSIgdmFsdWU9IklTTy04ODU5 LTEiIC8+CiAgICA8aW5wdXQgdHlwZT0idGV4dCIgbmFtZT0icSIgc2l6ZT0iMjAiIC8+CiAgICA8 YnI+CiAgICA8aW5wdXQgdHlwZT0ic3VibWl0IiBuYW1lPSJzYSIgdmFsdWU9IlNlYXJjaCIgLz4K ICA8L2Rpdj4KPC9mb3JtPgo8c2NyaXB0IHR5cGU9InRleHQvamF2YXNjcmlwdCIgc3JjPSJodHRw czovL3d3dy5nb29nbGUuY29tL2Nvb3AvY3NlL2JyYW5kP2Zvcm09Y3NlLXNlYXJjaC1ib3gmYW1w O2xhbmc9ZW4iPjwvc2NyaXB0PgogICAgPC90ZD48L3RyPgogICAgPHRyPgogICAgICAgIDx0ZD48 aW1nIHNyYz0iL2ltYWdlcy9zcGFjZXIuZ2lmIiBhbHQ9IiIgYm9yZGVyPSIwIiB3aWR0aD0iMSIg aGVpZ2h0PSIxIj48L3RkPgogICAgPC90cj4KICAgIDx0cj4KICAgICAgICA8dGQgY2xhc3M9ImRh cmtiZyI+PGltZyBzcmM9Ii9pbWFnZXMvc3BhY2VyLmdpZiIgYWx0PScnIGJvcmRlcj0iMCIgaGVp Z2h0PSIzIj48L3RkPgogICAgPC90cj4KICAgIDx0cj4KICAgICAgICA8dGQ+PGltZyBzcmM9Ii9p bWFnZXMvc3BhY2VyLmdpZiIgYWx0PSIiIGJvcmRlcj0iMCIgd2lkdGg9IjEiIGhlaWdodD0iMiI+ PC90ZD4KICAgIDwvdHI+CiAgICAgICAgPHRyPgogICAgICAgICAgICA8dGQgY2xhc3M9InNpZGVi YXJoZWFkZXIiPjxhIGhyZWY9Imh0dHBzOi8vd3d3LnZpbS5vcmcvIj5Ib21lPC9hPjwvdGQ+CiAg ICAgICAgPC90cj4KICAgICAgICA8dHI+CiAgICAgICAgICAgIDx0ZCBjbGFzcz0ic2lkZWJhcmhl YWRlciI+PGEgaHJlZj0iaHR0cHM6Ly93d3cudmltLm9yZy9zZWFyY2gucGhwIj5BZHZhbmNlZCBz ZWFyY2g8L2E+PC90ZD4KICAgICAgICA8L3RyPgogICAgPHRyPgogICAgICAgIDx0ZD48aW1nIHNy Yz0iL2ltYWdlcy9zcGFjZXIuZ2lmIiBhbHQ9IiIgYm9yZGVyPSIwIiB3aWR0aD0iMSIgaGVpZ2h0 PSI3Ij48L3RkPgogICAgPC90cj4KICAgIDx0cj4KICAgICAgICA8dGQgY2xhc3M9ImNoZWNrZXIi PjxpbWcgc3JjPSIvaW1hZ2VzL3NwYWNlci5naWYiIGFsdD0nJyBib3JkZXI9IjAiIGhlaWdodD0i MSI+PC90ZD4KICAgIDwvdHI+CiAgICA8dHI+CiAgICAgICAgPHRkPjxpbWcgc3JjPSIvaW1hZ2Vz L3NwYWNlci5naWYiIGFsdD0iIiBib3JkZXI9IjAiIHdpZHRoPSIxIiBoZWlnaHQ9IjciPjwvdGQ+ CiAgICA8L3RyPgogICAgICAgIDx0cj4KICAgICAgICAgICAgPHRkIGNsYXNzPSJzaWRlYmFyaGVh ZGVyIj48YSBocmVmPSJodHRwczovL3d3dy52aW0ub3JnL2Fib3V0LnBocCI+QWJvdXQgVmltPC9h PjwvdGQ+CiAgICAgICAgPC90cj4KICAgICAgICA8dHI+CiAgICAgICAgICAgIDx0ZCBjbGFzcz0i c2lkZWJhcmhlYWRlciI+PGEgaHJlZj0iaHR0cHM6Ly93d3cudmltLm9yZy9jb21tdW5pdHkucGhw Ij5Db21tdW5pdHk8L2E+PC90ZD4KICAgICAgICA8L3RyPgogICAgICAgIDx0cj4KICAgICAgICAg ICAgPHRkIGNsYXNzPSJzaWRlYmFyaGVhZGVyIj48YSBocmVmPSJodHRwczovL3d3dy52aW0ub3Jn L25ld3MvbmV3cy5waHAiPk5ld3M8L2E+PC90ZD4KICAgICAgICA8L3RyPgogICAgICAgIDx0cj4K ICAgICAgICAgICAgPHRkIGNsYXNzPSJzaWRlYmFyaGVhZGVyIj48YSBocmVmPSJodHRwczovL3d3 dy52aW0ub3JnL3Nwb25zb3IvaW5kZXgucGhwIj5TcG9uc29yaW5nPC9hPjwvdGQ+CiAgICAgICAg PC90cj4KICAgICAgICA8dHI+CiAgICAgICAgICAgIDx0ZCBjbGFzcz0ic2lkZWJhcmhlYWRlciI+ PGEgaHJlZj0iaHR0cHM6Ly93d3cudmltLm9yZy90cml2aWEucGhwIj5Ucml2aWE8L2E+PC90ZD4K ICAgICAgICA8L3RyPgogICAgICAgIDx0cj4KICAgICAgICAgICAgPHRkIGNsYXNzPSJzaWRlYmFy aGVhZGVyIj48YSBocmVmPSJodHRwczovL3d3dy52aW0ub3JnL2RvY3MucGhwIj5Eb2N1bWVudGF0 aW9uPC9hPjwvdGQ+CiAgICAgICAgPC90cj4KICAgICAgICA8dHI+CiAgICAgICAgICAgIDx0ZCBj bGFzcz0ic2lkZWJhcmhlYWRlciBkb3dubG9hZCI+PGEgaHJlZj0iaHR0cHM6Ly93d3cudmltLm9y Zy9kb3dubG9hZC5waHAiPkRvd25sb2FkPC9hPjwvdGQ+CiAgICAgICAgPC90cj4KICAgIDx0cj4K ICAgICAgICA8dGQ+PGltZyBzcmM9Ii9pbWFnZXMvc3BhY2VyLmdpZiIgYWx0PSIiIGJvcmRlcj0i MCIgd2lkdGg9IjEiIGhlaWdodD0iNyI+PC90ZD4KICAgIDwvdHI+CiAgICA8dHI+CiAgICAgICAg PHRkIGNsYXNzPSJjaGVja2VyIj48aW1nIHNyYz0iL2ltYWdlcy9zcGFjZXIuZ2lmIiBhbHQ9Jycg Ym9yZGVyPSIwIiBoZWlnaHQ9IjEiPjwvdGQ+CiAgICA8L3RyPgogICAgPHRyPgogICAgICAgIDx0 ZD48aW1nIHNyYz0iL2ltYWdlcy9zcGFjZXIuZ2lmIiBhbHQ9IiIgYm9yZGVyPSIwIiB3aWR0aD0i MSIgaGVpZ2h0PSI3Ij48L3RkPgogICAgPC90cj4KICAgICAgICA8dHI+CiAgICAgICAgICAgIDx0 ZCBjbGFzcz0ic2lkZWJhcmhlYWRlciI+PGEgaHJlZj0iaHR0cHM6Ly93d3cudmltLm9yZy9zY3Jp cHRzL2luZGV4LnBocCI+U2NyaXB0czwvYT48L3RkPgogICAgICAgIDwvdHI+CiAgICAgICAgPHRy PgogICAgICAgICAgICA8dGQgY2xhc3M9InNpZGViYXJoZWFkZXIiPjxhIGhyZWY9Imh0dHBzOi8v d3d3LnZpbS5vcmcvdGlwcy9pbmRleC5waHAiPlRpcHM8L2E+PC90ZD4KICAgICAgICA8L3RyPgog ICAgICAgIDx0cj4KICAgICAgICAgICAgPHRkIGNsYXNzPSJzaWRlYmFyaGVhZGVyIj48YSBocmVm PSJodHRwczovL3d3dy52aW0ub3JnL2FjY291bnQvaW5kZXgucGhwIj5NeSBBY2NvdW50PC9hPjwv dGQ+CiAgICAgICAgPC90cj4KICAgIDx0cj4KICAgICAgICA8dGQ+PGltZyBzcmM9Ii9pbWFnZXMv c3BhY2VyLmdpZiIgYWx0PSIiIGJvcmRlcj0iMCIgd2lkdGg9IjEiIGhlaWdodD0iNyI+PC90ZD4K ICAgIDwvdHI+CiAgICA8dHI+CiAgICAgICAgPHRkIGNsYXNzPSJjaGVja2VyIj48aW1nIHNyYz0i L2ltYWdlcy9zcGFjZXIuZ2lmIiBhbHQ9JycgYm9yZGVyPSIwIiBoZWlnaHQ9IjEiPjwvdGQ+CiAg ICA8L3RyPgogICAgPHRyPgogICAgICAgIDx0ZD48aW1nIHNyYz0iL2ltYWdlcy9zcGFjZXIuZ2lm IiBhbHQ9IiIgYm9yZGVyPSIwIiB3aWR0aD0iMSIgaGVpZ2h0PSI3Ij48L3RkPgogICAgPC90cj4K ICAgICAgICA8dHI+CiAgICAgICAgICAgIDx0ZCBjbGFzcz0ic2lkZWJhcmhlYWRlciI+PGEgaHJl Zj0iaHR0cHM6Ly93d3cudmltLm9yZy9odWgucGhwIj5TaXRlIEhlbHA8L2E+PC90ZD4KICAgICAg ICA8L3RyPgo8L3RhYmxlPgo8YnI+CjxnOnBsdXNvbmU+PC9nOnBsdXNvbmU+CgogICAgICAgICAg ICA8dGFibGUgd2lkdGg9IjE3MiIgY2VsbHBhZGRpbmc9IjAiIGNlbGxzcGFjaW5nPSIwIiBib3Jk ZXI9IjAiPgogICAgICAgICAgICAgIDx0cj48dGQ+PGltZyBzcmM9Ii9pbWFnZXMvc3BhY2VyLmdp ZiIgYWx0PSIiIGJvcmRlcj0iMCIgd2lkdGg9IjEiIGhlaWdodD0iOCI+PC90ZD48L3RyPgogICAg ICAgICAgICAgIDx0cj48dGQgY2xhc3M9ImRhcmtiZyI+PGltZyBzcmM9Ii9pbWFnZXMvc3BhY2Vy LmdpZiIgd2lkdGg9IjEiIGhlaWdodD0iMyIgYWx0PSIiPjwvdGQ+PC90cj4KICAgICAgICAgICAg PC90YWJsZT4KICAgICAgICAgICAgPGJyPgoKPCEtLSBJTkNMVURFIFRIRSBQQUdFIFNJREVCQVIg VEVYVCAtLT4KJm5ic3A7CgogICAgICAgICAgPC90ZD4KICAgICAgICA8L3RyPgogICAgICA8L3Rh YmxlPgogICAgPC90ZD4KCiAgICA8dGQgY2xhc3M9ImRhcmtiZyI+PGltZyBzcmM9Ii9pbWFnZXMv c3BhY2VyLmdpZiIgd2lkdGg9IjEiIGhlaWdodD0iMSIgYm9yZGVyPSIwIiBhbHQ9IiI+PGJyPjwv dGQ+CiAgICA8dGQ+CiAgICAgIDx0YWJsZSB3aWR0aD0iMTAwJSIgY2VsbHBhZGRpbmc9IjEwIiBj ZWxsc3BhY2luZz0iMCIgYm9yZGVyPSIwIiBib3JkZXJjb2xvcj0icmVkIj4KICAgICAgICA8dHI+ CiAgICAgICAgICA8dGQgdmFsaWduPSJ0b3AiPgoKPHNwYW4gY2xhc3M9InR4dGgxIj5tbGVzc25h dV9jYXNlIDogJnF1b3Q7aW4tY2FzZSZxdW90OyBzZWxlY3Rpb24sIGRlbGV0aW9uIGFuZCBzdWJz dGl0dXRpb24gZm9yIHVuZGVyc2NvcmUsIGNhbWVsLCBtaXhlZCBjYXNlPC9zcGFuPiAKCjxicj4K PGJyPgoKPCEtLSBrYXJtYSB0YWJsZSAtLT4KPHRhYmxlIGNlbGxwYWRkaW5nPSI0IiBjZWxsc3Bh Y2luZz0iMCIgYm9yZGVyPSIxIiBib3JkZXJjb2xvcj0iIzAwMDA2NiI+Cjx0cj4KICA8dGQgY2xh c3M9ImxpZ2h0YmciPjxiPiZuYnNwO3NjcmlwdCBrYXJtYSZuYnNwOzwvYj48L3RkPgogIDx0ZD4K ICAgIFJhdGluZyA8Yj4tMS8xPC9iPiwKICAgIERvd25sb2FkZWQgYnkgMTYzNCAgPC90ZD4KICA8 dGQgY2xhc3M9ImxpZ2h0YmciPgogIDxiPiZuYnNwO0NvbW1lbnRzLCBidWdzLCBpbXByb3ZlbWVu dHMmbmJzcDs8L2I+CiAgPC90ZD4KICA8dGQ+CiAgICA8YSBocmVmPSJodHRwOi8vdmltLndpa2lh LmNvbS93aWtpL1NjcmlwdDo0NjQxIj5WaW0gd2lraTwvYT4KICA8L3RkPiAgCjwvdHI+CjwvdGFi bGU+CjxwPgoKPHRhYmxlIGNlbGxzcGFjaW5nPSIwIiBjZWxscGFkZGluZz0iMCIgYm9yZGVyPSIw Ij4KPHRyPjx0ZCBjbGFzcz0icHJvbXB0Ij5jcmVhdGVkIGJ5PC90ZD48L3RyPgo8dHI+PHRkPjxh IGhyZWY9Ii9hY2NvdW50L3Byb2ZpbGUucGhwP3VzZXJfaWQ9NjY3MzkiPk1pY2hhZWwgTGXfbmF1 PC9hPjwvdGQ+PC90cj4KPHRyPjx0ZD4mbmJzcDs8L3RkPjwvdHI+Cjx0cj48dGQgY2xhc3M9InBy b21wdCI+c2NyaXB0IHR5cGU8L3RkPjwvdHI+Cjx0cj48dGQ+dXRpbGl0eTwvdGQ+PC90cj4KPHRy Pjx0ZD4mbmJzcDs8L3RkPjwvdHI+Cjx0cj48dGQgY2xhc3M9InByb21wdCI+ZGVzY3JpcHRpb248 L3RkPjwvdHI+Cjx0cj48dGQ+VGhpcyBwbHVnaW4gaW50cm9kdWNlcyAzIG5ldyBWaW0gY29tbWFu ZHM6DTxicj4NPGJyPjEuIHZpYyAoc2VsZWN0IGluLWNhc2UpDTxicj4yLiBkaWMgKGRlbGV0ZSBp bi1jYXNlKSAtLSB0aGlzIG1hcHBpbmcgaXMgZGlzYWJsZWQgYXMgb2YgdmVyc2lvbiAwLjIgKHlv dSBjYW4gZGVmaW5lIHlvdXIgb3duIGlmIHlvdSBuZWVkIHRvKQ08YnI+My4gY2ljIChjaGFuZ2Uv c3Vic3RpdHV0ZSBpbi1jYXNlKQ08YnI+DTxicj5TdXBwb3J0ZWQgaWRlbnRpZmllciBjYXNlcyBh cmUgJnF1b3Q7Y2FtZWwgY2FzZSZxdW90OyAoY29vbENhdCwgQ29vbENhdCksICZxdW90O3NuYWtl IGNhc2UmcXVvdDsgKGdvb2RfZG9nLCBHT09EX0RPRykgYW5kIGEgbWl4IG9mIGJvdGggKF9jb29s RG9nLCBHb29kX0NhdCkuDTxicj4NPGJyPkJ5IHVzaW5nIHRoZSBhYm92ZSBjb21tYW5kcyB5b3Ug Y2FuIHF1aWNrbHkgc2VsZWN0LCBkZWxldGUgb3IgY2hhbmdlIHNlZ21lbnRzIG9mIGNhc2VkIGlk ZW50aWZpZXJzIGRlcGVuZGluZyBvbiB3aGljaCBzZWdtZW50IHRoZSBjdXJzb3IgaXMgcG9pbnRp bmcgYXQuIEZvciBleGFtcGxlIChbeF0gZGVub3RlcyB0aGUgY3Vyc29yIHBvc2l0aW9uLCBvciBz ZWxlY3Rpb24pOg08YnI+DTxicj52aXc6IEZvW29dQmFyIC0mZ3Q7IFtGb29dQmFyICgtJmd0OyB2 aXN1YWwgbW9kZSkNPGJyPmNpdzogZm9vX1tCXWFyQmF6IC0mZ3Q7IGZvb19bXUJheiAoLSZndDsg aW5zZXJ0IG1vZGUpDTxicj4NPGJyPmluIG9yZGVyIHRvIHVzZSBpbi1jYXNlIGRlbGV0aW9uIHlv dSBtYXkgbWFwIHRoZSBjYWxsIHRvIERlbGV0ZUluQ2FzZSgpIGFjY29yZGluZ2x5LjwvdGQ+PC90 cj4KPHRyPjx0ZD4mbmJzcDs8L3RkPjwvdHI+Cjx0cj48dGQgY2xhc3M9InByb21wdCI+aW5zdGFs bCBkZXRhaWxzPC90ZD48L3RyPgo8dHI+PHRkPjEuIENvcHkgdGhlIGZpbGUgaW4geW91ciBwbHVn aW4gZm9sZGVyIChpLmUuIH4vLnZpbS9wbHVnaW4pDTxicj4yLiBEb25lDTxicj4NPGJyPllvdSds bCBuZWVkIHRvIHJlc3RhcnQgcnVubmluZyBWaW0gaW5zdGFuY2VzIGluIG9yZGVyIHRvIGJlIGFi bGUgdG8gdXNlIG9mIHRoZSBwbHVnaW4uDTxicj48L3RkPjwvdHI+Cjx0cj48dGQ+Jm5ic3A7PC90 ZD48L3RyPgo8L3RhYmxlPgoKPCEtLSByYXRpbmcgdGFibGUgLS0+Cjxmb3JtIG5hbWU9InJhdGlu ZyIgbWV0aG9kPSJwb3N0Ij4KPGlucHV0IHR5cGU9ImhpZGRlbiIgbmFtZT0ic2NyaXB0X2lkIiB2 YWx1ZT0iNDY0MSI+Cjx0YWJsZSBjZWxscGFkZGluZz0iNCIgY2VsbHNwYWNpbmc9IjAiIGJvcmRl cj0iMSIgYm9yZGVyY29sb3I9IiMwMDAwNjYiPgo8dHI+CiAgPHRkIGNsYXNzPSJsaWdodGJnIj48 Yj5yYXRlIHRoaXMgc2NyaXB0PC9iPjwvdGQ+CiAgPHRkIHZhbGlnbj0ibWlkZGxlIj4KICAgIDxp bnB1dCB0eXBlPSJyYWRpbyIgbmFtZT0icmF0aW5nIiB2YWx1ZT0ibGlmZV9jaGFuZ2luZyI+TGlm ZSBDaGFuZ2luZwogICAgPGlucHV0IHR5cGU9InJhZGlvIiBuYW1lPSJyYXRpbmciIHZhbHVlPSJo ZWxwZnVsIj5IZWxwZnVsCiAgICA8aW5wdXQgdHlwZT0icmFkaW8iIG5hbWU9InJhdGluZyIgdmFs dWU9InVuZnVsZmlsbGluZyI+VW5mdWxmaWxsaW5nJm5ic3A7CiAgICA8aW5wdXQgdHlwZT0ic3Vi bWl0IiB2YWx1ZT0icmF0ZSI+CiAgPC90ZD4KPC90cj4KPC90YWJsZT4KPC9mb3JtPgo8c3BhbiBj bGFzcz0idHh0aDIiPnNjcmlwdCB2ZXJzaW9uczwvc3Bhbj4gKDxhIGhyZWY9ImFkZF9zY3JpcHRf dmVyc2lvbi5waHA/c2NyaXB0X2lkPTQ2NDEiPnVwbG9hZCBuZXcgdmVyc2lvbjwvYT4pCjxwPgpD bGljayBvbiB0aGUgcGFja2FnZSB0byBkb3dubG9hZC4KPHA+Cgo8dGFibGUgY2VsbHNwYWNpbmc9 IjIiIGNlbGxwYWRkaW5nPSI0IiBib3JkZXI9IjAiIHdpZHRoPSIxMDAlIj4KPHRyIGNsYXNzPSd0 YWJsZWhlYWRlcic+CiAgICAgICAgPHRoIHZhbGlnbj0idG9wIj5wYWNrYWdlPC90aD4KICAgIDx0 aCB2YWxpZ249InRvcCI+c2NyaXB0IHZlcnNpb248L3RoPgogICAgPHRoIHZhbGlnbj0idG9wIj5k YXRlPC90aD4KICAgIDx0aCB2YWxpZ249InRvcCI+VmltIHZlcnNpb248L3RoPgogICAgPHRoIHZh bGlnbj0idG9wIj51c2VyPC90aD4KICAgIDx0aCB2YWxpZ249InRvcCI+cmVsZWFzZSBub3Rlczwv dGg+CjwvdHI+Cjx0cj4KICAgICAgICA8dGQgY2xhc3M9InJvd29kZCIgdmFsaWduPSJ0b3AiIG5v d3JhcD48YSBocmVmPSJkb3dubG9hZF9zY3JpcHQucGhwP3NyY19pZD0yMDUwNyI+bWxlc3NuYXVf Y2FzZS52aW08L2E+PC90ZD4KICAgIDx0ZCBjbGFzcz0icm93b2RkIiB2YWxpZ249InRvcCIgbm93 cmFwPjxiPjAuMjwvYj48L3RkPgogICAgPHRkIGNsYXNzPSJyb3dvZGQiIHZhbGlnbj0idG9wIiBu b3dyYXA+PGk+MjAxMy0wNy0xMTwvaT48L3RkPgogICAgPHRkIGNsYXNzPSJyb3dvZGQiIHZhbGln bj0idG9wIiBub3dyYXA+Ny4wPC90ZD4KICAgIDx0ZCBjbGFzcz0icm93b2RkIiB2YWxpZ249InRv cCI+PGk+PGEgaHJlZj0iL2FjY291bnQvcHJvZmlsZS5waHA/dXNlcl9pZD02NjczOSI+TWljaGFl bCBMZd9uYXU8L2E+PC9pPjwvdGQ+CiAgICA8dGQgY2xhc3M9InJvd29kZCIgdmFsaWduPSJ0b3Ai IHdpZHRoPSIyMDAwIj5EaXNhYmxlcyBzaG9ydGN1dCAmcXVvdDtkaWMmcXVvdDsgZm9yIGluLWNh c2UgZGVsZXRpb24gYXMgaXQgYXBwZWFyZWQgdG8gY29uZmxpY3Qgd2l0aCAmcXVvdDtkZCZxdW90 OzwvdGQ+CjwvdHI+Cjx0cj4KICAgICAgICA8dGQgY2xhc3M9InJvd2V2ZW4iIHZhbGlnbj0idG9w IiBub3dyYXA+PGEgaHJlZj0iZG93bmxvYWRfc2NyaXB0LnBocD9zcmNfaWQ9MjA0NjAiPm1sZXNz bmF1X2Nhc2UudmltPC9hPjwvdGQ+CiAgICA8dGQgY2xhc3M9InJvd2V2ZW4iIHZhbGlnbj0idG9w IiBub3dyYXA+PGI+MC4xPC9iPjwvdGQ+CiAgICA8dGQgY2xhc3M9InJvd2V2ZW4iIHZhbGlnbj0i dG9wIiBub3dyYXA+PGk+MjAxMy0wNi0zMDwvaT48L3RkPgogICAgPHRkIGNsYXNzPSJyb3dldmVu IiB2YWxpZ249InRvcCIgbm93cmFwPjcuMDwvdGQ+CiAgICA8dGQgY2xhc3M9InJvd2V2ZW4iIHZh bGlnbj0idG9wIj48aT48YSBocmVmPSIvYWNjb3VudC9wcm9maWxlLnBocD91c2VyX2lkPTY2NzM5 Ij5NaWNoYWVsIExl325hdTwvYT48L2k+PC90ZD4KICAgIDx0ZCBjbGFzcz0icm93ZXZlbiIgdmFs aWduPSJ0b3AiIHdpZHRoPSIyMDAwIj5Jbml0aWFsIHVwbG9hZDwvdGQ+CjwvdHI+CjwvdGFibGU+ CjxzbWFsbD5pcCB1c2VkIGZvciByYXRpbmc6IDM2Ljc0LjI0Ni4xNjc8L3NtYWxsPgo8IS0tIGZp bmlzaCBvZmYgdGhlIGZyYW1ld29yayAtLT4KICAgICAgICAgIDwvdGQ+CiAgICAgICAgPC90cj4K ICAgICAgPC90YWJsZT4KICAgIDwvdGQ+CgogIDwvdHI+CjwvdGFibGU+Cgo8IS0tIEVORCBPRiBU SEUgUEFHRSBCT0RZOiBCRVRXRUVOIEhFQURFUiBBTkQgRk9PVEVSIC0tPgoKPHRhYmxlIHdpZHRo PSIxMDAlIiBjZWxscGFkZGluZz0iMCIgY2VsbHNwYWNpbmc9IjAiIGJvcmRlcj0iMCIgYm9yZGVy Y29sb3I9InJlZCI+CiAgPHRyPjx0ZCBjb2xzcGFuPSI0Ij48aW1nIHNyYz0iL2ltYWdlcy9zcGFj ZXIuZ2lmIiB3aWR0aD0iMSIgaGVpZ2h0PSI1IiBhbHQ9IiI+PC90ZD48L3RyPgogIDx0cj48dGQg Y29sc3Bhbj0iNCIgYmdjb2xvcj0iIzAwMDAwMCI+PGltZyBzcmM9Ii9pbWFnZXMvc3BhY2VyLmdp ZiIgaGVpZ2h0PSIyIiB3aWR0aD0iMSIgYWx0PSIiPjwvdGQ+PC90cj4KICA8dHI+PHRkIGNvbHNw YW49IjQiPjxpbWcgc3JjPSIvaW1hZ2VzL3NwYWNlci5naWYiIHdpZHRoPSIxIiBoZWlnaHQ9IjUi IGFsdD0iIj48L3RkPjwvdHI+CiAgPHRyPgogICAgPHRkPjxpbWcgc3JjPSIvaW1hZ2VzL3NwYWNl ci5naWYiIHdpZHRoPSI1IiBoZWlnaHQ9IjEiIGFsdD0iIj48L3RkPgoKICAgIDx0ZCBhbGlnbj0i bGVmdCIgdmFsaWduPSJ0b3AiPjxzbWFsbD4KICAgICAgSWYgeW91IGhhdmUgcXVlc3Rpb25zIG9y IHJlbWFya3MgYWJvdXQgdGhpcyBzaXRlLCB2aXNpdCB0aGUKICAgICAgPGEgaHJlZj0iaHR0cDov L3ZpbW9ubGluZS5zZi5uZXQiPnZpbW9ubGluZSBkZXZlbG9wbWVudDwvYT4gcGFnZXMuCiAgICAg IFBsZWFzZSB1c2UgdGhpcyBzaXRlIHJlc3BvbnNpYmx5LgogICAgICA8YnI+IAogICAgICAKICAg ICAgUXVlc3Rpb25zIGFib3V0IDxhIGhyZWY9Imh0dHA6Ly93d3cudmltLm9yZy9hYm91dC5waHAi PlZpbTwvYT4gc2hvdWxkIGdvCiAgICAgIHRvIHRoZSA8YSBocmVmPSJodHRwOi8vd3d3LnZpbS5v cmcvbWFpbGxpc3QucGhwIj5tYWlsbGlzdDwvYT4uCiAgICAgIEhlbHAgQnJhbSA8YSBocmVmPSJo dHRwOi8vaWNjZi1ob2xsYW5kLm9yZy8iPmhlbHAgVWdhbmRhPC9hPi4KICAgICAgPC9zbWFsbD4K CSZuYnNwOwoJJm5ic3A7CgogICAgPC90ZD4KCiAgICA8dGQgYWxpZ249InJpZ2h0IiB2YWxpZ249 InRvcCI+CiAgICAgIAk8YSBocmVmPSJodHRwczovL29zZG4ubmV0L3Byb2plY3RzL3ZpbSIgcmVs PSJub2ZvbGxvdyI+T1NETjwvYT4KICAgIDwvdGQ+CgogICAgPHRkPjxpbWcgc3JjPSIvaW1hZ2Vz L3NwYWNlci5naWYiIHdpZHRoPSI1IiBoZWlnaHQ9IjEiIGFsdD0iIj48L3RkPgogIDwvdHI+Cgog ICAgCiAgPHRyPjx0ZCBjb2xzcGFuPSI0Ij48aW1nIHNyYz0iL2ltYWdlcy9zcGFjZXIuZ2lmIiB3 aWR0aD0iMSIgaGVpZ2h0PSI1IiBhbHQ9IiI+PC90ZD4KICAKICA8L3RyPgo8L3RhYmxlPgoKPC9i b2R5Pgo8L2h0bWw+Cgo= headers: Content-Language: - ja Content-Type: - text/html Date: - Sat, 07 Jan 2023 07:50:30 GMT Server: - Apache/2.4.10 (Debian) Transfer-Encoding: - chunked status: code: 200 message: OK version: 1 ================================================ FILE: tests/cassettes/test_buku/test_fetch_data_with_url[https---www.google.ru-search~-exp_res6].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: https://www.google.ru/search?newwindow=1&safe=off&q=xkbcomp+alt+gr&oq=xkbcomp+alt+gr&gs_l=serp.3..33i21.28976559.28977886.0.28978017.6.6.0.0.0.0.167.668.0j5.5.0....0...1c.1.64.serp..1.2.311.06cSKPTLo18 response: body: string: "xkbcomp alt gr - Penelusuran Google(function(){var b=window.addEventListener;window.addEventListener=function(a,c,d){\"\ unload\"!==a&&b(a,c,d)};}).call(this);(function(){window.google={kEI:'QyS5Y_SSL5HO5OUPp_mggAY',kEXPI:'31',kBL:'5roH'};google.sn='web';google.kHL='id';})();(function(){\n\ var f=this||self;var h,k=[];function l(a){for(var b;a&&(!a.getAttribute||!(b=a.getAttribute(\"\ eid\")));)a=a.parentNode;return b||h}function m(a){for(var b=null;a&&(!a.getAttribute||!(b=a.getAttribute(\"\ leid\")));)a=a.parentNode;return b}\nfunction n(a,b,c,d,g){var e=\"\";c||-1!==b.search(\"\ &ei=\")||(e=\"&ei=\"+l(d),-1===b.search(\"&lei=\")&&(d=m(d))&&(e+=\"&lei=\"\ +d));d=\"\";!c&&f._cshid&&-1===b.search(\"&cshid=\")&&\"slh\"!==a&&(d=\"&cshid=\"\ +f._cshid);c=c||\"/\"+(g||\"gen_204\")+\"?atyp=i&ct=\"+a+\"&cad=\"+b+e+\"\ &zx=\"+Date.now()+d;/^http:/i.test(c)&&\"https:\"===window.location.protocol&&(google.ml&&google.ml(Error(\"\ a\"),!1,{src:c,glmm:1}),c=\"\");return c};h=google.kEI;google.getEI=l;google.getLEI=m;google.ml=function(){return\ \ null};google.log=function(a,b,c,d,g){if(c=n(a,b,c,d,g)){a=new Image;var\ \ e=k.length;k[e]=a;a.onerror=a.onload=a.onabort=function(){delete k[e]};a.src=c}};google.logUrl=n;}).call(this);(function(){google.y={};google.sy=[];google.x=function(a,b){if(a)var\ \ c=a.id;else{do c=Math.random();while(google.y[c])}google.y[c]=[a,b];return!1};google.sx=function(a){google.sy.push(a)};google.lm=[];google.plm=function(a){google.lm.push.apply(google.lm,a)};google.lq=[];google.load=function(a,b,c){google.lq.push([[a],b,c])};google.loadAll=function(a,b){google.lq.push([a,b])};google.bx=!1;google.lx=function(){};}).call(this);google.f={};(function(){\n\ document.documentElement.addEventListener(\"submit\",function(b){var a;if(a=b.target){var\ \ c=a.getAttribute(\"data-submitfalse\");a=\"1\"===c||\"q\"===c&&!a.elements.q.value?!0:!1}else\ \ a=!1;a&&(b.preventDefault(),b.stopPropagation())},!0);document.documentElement.addEventListener(\"\ click\",function(b){var a;a:{for(a=b.target;a&&a!==document.documentElement;a=a.parentElement)if(\"\ A\"===a.tagName){a=\"1\"===a.getAttribute(\"data-nohref\");break a}a=!1}a&&b.preventDefault()},!0);}).call(this);(function(){google.hs={h:true,nhs:false,sie:false};})();(function(){google.c={ataf:false,btfi:false,cap:2000,frt:true,gecoh:true,gl:false,lhc:false,llt:false,lrt:false,raf:false,sxs:false,taf:true,taff:false,timl:false};})();(function(){\n\ var f=this||self;var g=window.performance;function h(a,b,d,c){a.addEventListener?a.addEventListener(b,d,c||!1):a.attachEvent&&a.attachEvent(\"\ on\"+b,d)}function k(a,b,d,c){\"addEventListener\"in a?a.removeEventListener(b,d,c||!1):a.attachEvent&&a.detachEvent(\"\ on\"+b,d)};google.c.iim=google.c.iim||{};function l(a){a&&f.google.aft(a.target)}var\ \ m;function n(){k(document.documentElement,\"load\",m,!0);k(document.documentElement,\"\ error\",m,!0)};google.timers={};google.startTick=function(a){google.timers[a]={t:{start:Date.now()},e:{},m:{}}};google.tick=function(a,b,d){google.timers[a]||google.startTick(a);d=void\ \ 0!==d?d:Date.now();b instanceof Array||(b=[b]);for(var c=0,e;e=b[c++];)google.timers[a].t[e]=d};google.c.e=function(a,b,d){google.timers[a].e[b]=d};google.c.b=function(a,b){b=google.timers[b||\"\ load\"].m;b[a]&&google.ml(Error(\"a\"),!1,{m:a});b[a]=!0};google.c.u=function(a,b){var\ \ d=google.timers[b||\"load\"],c=d.m;if(c[a]){c[a]=!1;for(a in c)if(c[a])return;google.csiReport(d,\"\ load2\"===b?\"all2\":\"all\")}else{b=\"\";for(var e in c)b+=e+\":\"+c[e]+\"\ ;\";google.ml(Error(\"b\"),!1,{m:a,b:!1===c[a],s:b})}};google.rll=function(a,b,d){function\ \ c(e){d(e);k(a,\"load\",c);k(a,\"error\",c)}h(a,\"load\",c);b&&h(a,\"error\"\ ,c)};f.google.aft=function(a){a.setAttribute(\"data-iml\",String(Date.now()))};google.startTick(\"\ load\");var p=google.timers.load;a:{var q=p.t;if(g){var r=g.timing;if(r){var\ \ t=r.navigationStart,u=r.responseStart;if(u>t&&u<=q.start){q.start=u;p.wsrt=u-t;break\ \ a}}g.now&&(p.wsrt=Math.floor(g.now()))}}google.c.b(\"pr\",\"load\");google.c.b(\"\ xe\",\"load\");function v(a){if(\"hidden\"===document.visibilityState){google.c.fh=a;var\ \ b;window.performance&&window.performance.timing&&(b=Math.floor(window.performance.timing.navigationStart+a));google.tick(\"\ load\",\"fht\",b);return!0}return!1}\nfunction w(a){v(a.timeStamp)&&k(document,\"\ visibilitychange\",w,!0)}google.c.fh=Infinity;h(document,\"visibilitychange\"\ ,w,!0);v(0);google.c.gl&&(m=l,h(document.documentElement,\"load\",m,!0),google.c.glu=n);}).call(this);(function(){function\ \ k(a){try{a()}catch(b){google.ml(b,!1)}}google.caft=function(a,b){null===google.aftq?k(a):(google.aftq=google.aftq||[],google.aftq.push(a),b&&window.setTimeout(function(){google.aftq&&(google.aftq=google.aftq.filter(function(c){return\ \ a!==c}),k(a))},b))};function l(){return window.performance&&window.performance.navigation&&window.performance.navigation.type};function\ \ p(a,b,c){if(!a||r(a))return 0;if(!a.getBoundingClientRect)return 1;var d=function(e){return\ \ e.getBoundingClientRect()};return t(a,b,d,c)?0:u(a,b,d)}function t(a,b,c,d){a:{for(var\ \ e=a;e&&e!==b;e=e.parentElement)if(\"hidden\"===e.style.overflow||d&&\"G-EXPANDABLE-CONTENT\"\ ===e.tagName&&\"hidden\"===getComputedStyle(e).getPropertyValue(\"overflow\"\ )){b=e;break a}b=null}if(!b)return!1;a=c(a);c=c(b);return a.bottom=c.bottom||a.right=c.right}\n\ function r(a){return\"none\"===a.style.display?!0:document.defaultView&&document.defaultView.getComputedStyle?(a=document.defaultView.getComputedStyle(a),!!a&&(\"\ hidden\"===a.visibility||\"0px\"===a.height&&\"0px\"===a.width)):!1}\nfunction\ \ u(a,b,c){var d=c(a),e=d.left+window.pageXOffset,g=d.top+window.pageYOffset,n=d.width,m=d.height,f=0;if(0>=m&&0>=n)return\ \ f;var q=window.innerHeight||document.documentElement.clientHeight;0>g+m?f=2:g>=q&&(f=4);if(0>e+n||e>=(window.innerWidth||document.documentElement.clientWidth))f|=8;else\ \ if(b){for(d=d.left;a&&a!==b;a=a.parentElement)d+=a.scrollLeft;b=c(b);if(d+n=b.right)f|=8}f||(f=1,g+m>q&&(f|=4));return\ \ f};var v=window.location,w=\"aft afti aftr afts cbs cbt fht frt hct prt\ \ sct\".split(\" \");function x(a){return(a=v.search.match(new RegExp(\"[?&]\"\ +a+\"=(\\\\d+)\")))?Number(a[1]):-1}\nfunction y(a,b){var c=google.timers[b||\"\ load\"];b=c.m;if(!b||!b.prs){var d=l()?0:x(\"qsubts\");0c||Qc||c>=D))&&(O=D);var\ \ R=a.src;google.rll(a,!0,function(){(n||m)&&R&&R===a.src?google.rll(a,!0,function(){U(a,f,Date.now())}):U(a,f,Date.now())})}}return\ \ d};google.c.ubr=function(a,b,c,d){google.c.taf&&OO&&(c&&(O=c),google.c.btfi&&T(\"\ aft\",b));a||T(\"afts\",b,!0);d||(T(\"aft\",b,!0),M&&!google.c.frt&&(M=!1,V()),a&&N&&(T(\"\ prt\",b),google.c.timl&&T(\"iml\",b,!0),N=!1,W(),google.c.setup=function(){return\ \ 0},google.c.ubr=function(){}))};}).call(this);(function(){var b=[function(){google.tick&&google.tick(\"\ load\",\"dcl\")}];google.dclc=function(a){b.length?b.push(a):a()};function\ \ c(){for(var a=b.shift();a;)a(),a=b.shift()}window.addEventListener?(document.addEventListener(\"\ DOMContentLoaded\",c,!1),window.addEventListener(\"load\",c,!1)):window.attachEvent&&window.attachEvent(\"\ onload\",c);}).call(this);(function(){var b=[];google.jsc={xx:b,x:function(a){b.push(a)},mm:[],m:function(a){google.jsc.mm.length||(google.jsc.mm=a)}};}).call(this);(function(){\n\ var e=this||self;\nvar f={};function w(a,c){if(null===c)return!1;if(\"contains\"\ in a&&1==c.nodeType)return a.contains(c);if(\"compareDocumentPosition\"in\ \ a)return a==c||!!(a.compareDocumentPosition(c)&16);for(;c&&a!=c;)c=c.parentNode;return\ \ c==a};\nvar y=function(a,c){return function(d){d||(d=window.event);return\ \ c.call(a,d)}},z=\"undefined\"!=typeof navigator&&/Macintosh/.test(navigator.userAgent),E=function(){this._mouseEventsPrevented=!0};var\ \ F=function(a){this.g=a;this.h=[]},G=function(a){for(var c=0;cv){window.console&&console.error(a,d);if(-2===v)throw\ \ a;b=!1}else b=!a||!a.message||\"Error loading script\"===a.message||q>=l&&!m?!1:!0;if(!b)return\ \ null;q++;d=d||{};b=encodeURIComponent;var c=\"/gen_204?atyp=i&ei=\"+b(google.kEI);google.kEXPI&&(c+=\"\ &jexpid=\"+b(google.kEXPI));c+=\"&srcpg=\"+b(google.sn)+\"&jsr=\"+b(t.jsr)+\"\ &bver=\"+b(t.bv);var f=a.lineNumber;void 0!==f&&(c+=\"&line=\"+f);var g=\n\ a.fileName;g&&(0=l&&(window.onerror=null)};})();var h=\"function\"\ ==typeof Object.defineProperties?Object.defineProperty:function(a,b,c){if(a==Array.prototype||a==Object.prototype)return\ \ a;a[b]=c.value;return a},k=function(a){a=[\"object\"==typeof globalThis&&globalThis,a,\"\ object\"==typeof window&&window,\"object\"==typeof self&&self,\"object\"==typeof\ \ global&&global];for(var b=0;b=g}});google.arwt=function(a){a.href=document.getElementById(a.id.substring(a.id.startsWith(\"\ vcs\")?3:1)).href;return!0};(function(){\nvar f=this||self;var g=function(a){var\ \ b=a.indexOf(\"#\");0>b&&(b=a.length);var c=a.indexOf(\"?\");if(0>c||c>b){c=b;var\ \ d=\"\"}else d=a.substring(c+1,b);return[a.slice(0,c),d,a.slice(b)]},h=function(a,b){return\ \ b?a?a+\"&\"+b:b:a},l=function(a,b,c){if(Array.isArray(b))for(var d=0;d(function(){var l='400';var font='Google\ \ Sans';(function(){if(document.fonts&&document.fonts.load)for(var d=l.split(\"\ ,\"),b={},c=0,a=void 0;a=d[c];++c)b[a]||(b[a]=!0,document.fonts.load(a+\"\ \ 10pt \"+font).catch(function(){}))})();})();

Link Aksesibilitas

\"Google\"
Tekan / untuk langsung\ \ ke kotak penelusuran
\"Penelusuran
\ \
  • Hapus
  • Laporkan prediksi yang tidak pantas
    \ \
    (function(){google.tick(\"load\",\"sct\"\ );}).call(this);

    Mode Penelusuran

    Lainnya
    Alat
      Sekitar 15.600 hasil (0,27 detik) 
      \ \

      Hasil Telusur

      20 Mei 2015 \xB7 3 jawaban
      This is easy to do with xmodmap . You can use a keysym directive to change\ \ the keysyms associated with a key. This affect all the keys that\_...
      25 Feb 2012 \xB7 1 jawaban
      Thanks to the suggestions of the Xorg community I found out the correct setxkbmap\ \ command: setxkbmap -option ctrl:ralt_rctrl.
      3 jawaban
      12 Des 2013
      14 Des 2011
      6 jawaban
      18 Okt 2018
      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,\_...
      11 Apr 2018\ \ \u2014 Bug 508434 (altgr) - iso-level-3 / alt-gr\ \ broken in rawhide ... Output of 'xkbcomp -xkb :0' (56.90\ \ KB, text/plain) 2009-07-10 12:00 UTC,\_...
      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).
      https://altgr-weur.eu/map. Next, compile\ \ and activate the new map for the current display ($DISPLAY):. xkbcomp\ \ -w 0 -I$HOME/.config/xkb\_...
      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)\_...
      22 Des 2009 \xB7 6 postingan \xB7 2 penulis
      Errors\ \ from xkbcomp are not fatal to the X server (EE) Logitech Logitech\ \ Illuminated Keyboard: failed to initialize for relative axes.

      Navigasi Halaman

      12345678910Berikutnya
      (function(){(function(){var d=Date.now(),a=google.c.sxs?\"\ load2\":\"load\";if(google.timers&&google.timers[a].t){for(var b=document.getElementsByTagName(\"\ img\"),e=0,c=void 0;c=b[e++];)google.c.setup(c,!1,-1);google.c.frt=!1;google.c.e(a,\"\ imn\",String(b.length));google.c.ubr(!0,d);google.c.glu&&google.c.glu();google.rll(window,!1,function(){google.tick(a,\"\ ol\");google.c.u(\"pr\",a)})}})();}).call(this);(function(){google.xjs={ck:'xjs.s.Um5rIh7eQx4.L.F4.O',cs:'ACT90oGcqZypAIpN_wO6mRlGU3IQxLCmRg',excm:['gVl0O','ZrXR8b','AOTkuc','Mvtsf','NmR9jd','QFbVC','SKZSKc','jkRPje','y6Ihab']};})();
      (function(){google.lliod=false;google.llirm='400px';google.ldi={};google.pim={};(function(){var\ \ a=google.ldi||{},b;for(b in a)if(a.hasOwnProperty(b)){var c=document.getElementById(b)||document.documentElement.querySelector('img[data-iid=\"\ '+b+'\"]');c&&Number(c.getAttribute(\"data-atf\"))&1&&(c.setAttribute(\"data-deferred\"\ ,\"2\"),c.src=a[b])};}).call(this);})(); \ \
      " headers: Alt-Svc: - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; ma=2592000; v="46,43" Cache-Control: - private, max-age=0 Content-Security-Policy: - 'object-src ''none'';base-uri ''self'';script-src ''nonce-fiRsZQKlRm7AeAysGAgLGA'' ''strict-dynamic'' ''report-sample'' ''unsafe-eval'' ''unsafe-inline'' https: http:;report-uri https://csp.withgoogle.com/csp/gws/fff' Content-Type: - text/html; charset=UTF-8 Cross-Origin-Opener-Policy-Report-Only: - same-origin-allow-popups; report-to="gws" Date: - Sat, 07 Jan 2023 07:50:27 GMT Expires: - '-1' P3P: - CP="This is not a P3P policy! See g.co/p3phelp for more info." Report-To: - '{"group":"gws","max_age":2592000,"endpoints":[{"url":"https://csp.withgoogle.com/csp/report-to/gws/fff"}]}' Server: - gws Set-Cookie: - 1P_JAR=2023-01-07-07; expires=Mon, 06-Feb-2023 07:50:27 GMT; path=/; domain=.google.ru; Secure; SameSite=none - AEC=ARSKqsKQNboYinipllm8z9W-j7W4wEQcQ8U1KijhcwNL4qQSJCmWe5aLdA; expires=Thu, 06-Jul-2023 07:50:27 GMT; path=/; domain=.google.ru; Secure; HttpOnly; SameSite=lax - NID=511=TrY4zPK8E2AgNqCwDKQ0JIX0J6gBLfbfbtQqbiMj5cOpKzRzHqIXLszjmk4ieklqVmh02oNjjJbGSX_tj_XnZeiYvVrOQ5zYPoX2in5AgFRz-WpdGhch0FzkgBpVyq7cNznHvbx-uHPuD4t8UOQQreMMiCsZiLM_WeNpkQWhljs; expires=Sun, 09-Jul-2023 07:50:27 GMT; path=/; domain=.google.ru; Secure; HttpOnly; SameSite=none Strict-Transport-Security: - max-age=31536000 Transfer-Encoding: - chunked X-Frame-Options: - SAMEORIGIN X-XSS-Protection: - '0' status: code: 200 message: OK version: 1 ================================================ FILE: tests/genbm.sh ================================================ #!/bin/bash # Scriptlet to auto-generate buku bookmarks # Usage: genbm.sh n # where, n = number of records to generate # # Author: Arun Prakash Jana (engineerarun@gmail.com) if [ "$#" -ne 1 ]; then echo "usage: genbm n" exit 1 fi count=0 while [ $count -lt "$1" ]; do url=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 | head -n 1) buku -a https://www.$url.com --title Dummy bookmark for testing. --tag auto-generated, dummy bookmark --comment Generated from the test script $count. let count=count+1 done ================================================ FILE: tests/pytest.ini ================================================ [pytest] timeout = 10 timeout_method = thread markers = non_tox: not run on tox slow: slow tests gui: GUI (functional) tests ================================================ FILE: tests/test_BukuCrypt.py ================================================ """test module.""" import os import random import pytest def test_get_filehash(tmpdir): """test method.""" exp_res = b'\x9f\x86\xd0\x81\x88L}e\x9a/\xea\xa0\xc5Z\xd0\x15\xa3\xbfO\x1b+\x0b\x82,\xd1]l\x15\xb0\xf0\n\x08' # NOQA test_file = os.path.join(tmpdir.strpath, 'my_test_file.txt') with open(test_file, 'w', encoding="utf8", errors="surrogateescape") as f: f.write('test') from buku import BukuCrypt res = BukuCrypt.get_filehash(test_file) assert res == exp_res @pytest.mark.slow @pytest.mark.parametrize( 'filesize', list(range(0, 17)) + [511, 512, 513, 1023, 1024, 1025, 524288, 524289, 1000000, 1048576, 2097152, 4194304] ) def test_encrypt_decrypt(tmpdir, filesize): """test method.""" dbfile = os.path.join(tmpdir.strpath, 'test_encrypt_decrypt_dbfile') content = bytes(random.getrandbits(8) for _ in range(filesize)) with open(dbfile, 'wb') as fp: fp.write(content) assert os.stat(dbfile).st_size == filesize from buku import BukuCrypt BukuCrypt.encrypt_file(1, dbfile=dbfile, password='password') BukuCrypt.decrypt_file(1, dbfile=dbfile, password='password') assert os.path.exists(dbfile) with open(dbfile, 'rb') as fp: roundtrip_content = fp.read() assert roundtrip_content == content ================================================ FILE: tests/test_ExtendedArgumentParser.py ================================================ """test module.""" from itertools import product from unittest import mock import pytest @pytest.mark.parametrize("platform, file", product(['win32', 'linux'], [None, mock.Mock()])) def test_program_info(platform, file): """test method.""" with mock.patch('buku.sys') as m_sys: import buku file = mock.Mock() if file is None: buku.ExtendedArgumentParser.program_info() else: buku.ExtendedArgumentParser.program_info(file) if platform == 'win32' and file == m_sys.stdout: assert len(m_sys.stderr.write.mock_calls) == 1 else: assert len(file.write.mock_calls) == 1 def test_prompt_help(): """test method.""" file = mock.Mock() import buku buku.ExtendedArgumentParser.prompt_help(file) assert len(file.write.mock_calls) == 1 def test_print_help(): """test method.""" file = mock.Mock() import buku obj = buku.ExtendedArgumentParser() obj.program_info = mock.Mock() obj.print_help(file) obj.program_info.assert_called_once_with(file) ================================================ FILE: tests/test_buku.py ================================================ """test module.""" import json import logging import os import signal import unittest from itertools import product from textwrap import dedent from configparser import ConfigParser from unittest import mock from urllib.parse import urlparse import pytest from buku import DELIM, FIELD_FILTER, ALL_FIELDS, SortKey, FetchResult, is_int, prep_tag_search, \ print_rec_with_filter, get_netloc, extract_auth, parse_range, split_by_marker def check_import_html_results_contains(result, expected_result): count = 0 for r in result: for idx, exp_r in enumerate(expected_result): if r == exp_r: count += idx n = len(expected_result) - 1 return count == n * (n + 1) / 2 @pytest.mark.parametrize('url, result', [ ('http://user:password@hostname:1234/path?query#hash', 'user:password'), ('http://:password@hostname:1234/path?query#hash', ':password'), ('http://user:@hostname:1234/path?query#hash', 'user:'), ('http://user@hostname:1234/path?query#hash', 'user'), ('http://@hostname:1234/path?query#hash', ''), ('http://hostname:1234/path?query#hash', None), ('//[', ValueError('Invalid IPv6 URL')), ('//⁈', ValueError("netloc '⁈' contains invalid characters under NFKC normalization")), ]) def test_extract_auth(url, result): if not isinstance(result, Exception): assert extract_auth(url) == (result, 'http://hostname:1234/path?query#hash') else: try: extract_auth(url) except Exception as e: assert repr(e) == repr(result) else: assert False, f'expected {repr(result)} to be raised' @pytest.mark.parametrize('url, netloc', [ ['http://example.com', 'example.com'], ['example.com/#foo/bar', 'example.com'], ['ftp://ftp.somedomain.org', 'ftp.somedomain.org'], ['about:newtab', None], ['chrome://version/', 'version'], ['javascript:void(0.0)', None], ['data:,text.with.dots', None], ['http://[', None], # parsing error ['http://⁈', None], # parsing error ]) def test_get_netloc(url, netloc): assert get_netloc(url) == netloc @pytest.mark.parametrize('url, exp_res', [ ['http://example.com', False], ['example.com/#foo/bar', False], ['ftp://ftp.somedomain.org', False], ['http://examplecom.', True], # ends with a '.' ['http://.example.com', True], # starts with a '.' ['http://example.com.', True], # ends with a '.' ['about:newtab', True], ['chrome://version/', True], # contains no '.' ['javascript:void(0.0)', True], ['data:,text.with.dots', True], ['http://[', True], # parsing error ['http://⁈', True], # parsing error ]) def test_is_bad_url(url, exp_res): import buku res = buku.is_bad_url(url) assert res == exp_res @pytest.mark.parametrize( "url, exp_res", [ ("http://example.com/file.pdf", True), ("http://example.com/file.txt", True), ("http://example.com/file.jpg", False), ], ) def test_is_ignored_mime(url, exp_res): """test func.""" import buku assert exp_res == buku.is_ignored_mime(url) def test_gen_headers(): """test func.""" import buku exp_myheaders = { 'Accept-Encoding': 'gzip,deflate', 'User-Agent': buku.USER_AGENT, 'Sec-Fetch-Mode': 'navigate', 'Accept': '*/*', 'Cookie': '', 'DNT': '1', } buku.gen_headers() assert buku.MYPROXY is None assert buku.MYHEADERS == exp_myheaders @pytest.mark.parametrize("m_myproxy", [None, mock.Mock()]) def test_get_PoolManager(m_myproxy): """test func.""" with mock.patch("buku.urllib3"): import buku buku.myproxy = m_myproxy assert buku.get_PoolManager() @pytest.mark.parametrize( "keywords, exp_res", [ ([""], DELIM), ([","], DELIM), (["tag1, tag2"], ",tag1,tag2,"), ([" a tag , , , ,\t,\n,\r,\x0b,\x0c"], ",a tag,"), # whitespaces ([",,,,,"], ","), # empty tags (["\"tag\",'tag',tag"], ",\"tag\",'tag',tag,"), # escaping quotes (["tag,tag, tag, tag,tag , tag "], ",tag,"), # duplicates, excessive spaces (["tag1", "tag2", "tag3"], ",tag1 tag2 tag3,"), (["tag1", "tag2"], ",tag1 tag2,"), (["tag1"], ",tag1,"), (["tag1,tag2", "tag3"], ",tag1,tag2 tag3,"), (["tag1,tag2", "tag3,tag4"], ",tag1,tag2 tag3,tag4,"), (["tag1,tag2"], ",tag1,tag2,"), (["z_tag,a_tag,n_tag"], ",a_tag,n_tag,z_tag,"), # sorting tags ([" "], ","), ([""], ","), ([","], ","), ([], ","), # call with empty list ([None], ","), (None, None), # call with None # combo ( [',,z_tag, a tag ,\t,,, ,n_tag ,n_tag, a_tag, \na tag ,\r, "a_tag"'], ',"a_tag",a tag,a_tag,n_tag,z_tag,', ), ], ) @pytest.mark.parametrize('prefix', [None, '', '+', '-']) def test_parse_tags(prefix, keywords, exp_res): """test func.""" import buku edit_input = prefix is not None if keywords is None: assert buku.parse_tags(keywords, edit_input=edit_input) is None else: _keywords = ([] if not prefix else [prefix]) + keywords assert buku.parse_tags(_keywords, edit_input=edit_input) == (prefix or '') + exp_res def test_parse_tags_no_args(): import buku assert buku.parse_tags() == DELIM @pytest.mark.parametrize("field_filter, exp_res", [ (0, ["1. title1\n > http://url1.com\n + desc1\n # tag1\n", "2. title2\n > http://url2.com\n + desc2\n # tag1,tag2\n"]), (1, ["1\thttp://url1.com", "2\thttp://url2.com"]), (2, ["1\thttp://url1.com\ttag1", "2\thttp://url2.com\ttag1,tag2"]), (3, ["1\ttitle1", "2\ttitle2"]), (4, ["1\thttp://url1.com\ttitle1\ttag1", "2\thttp://url2.com\ttitle2\ttag1,tag2"]), (5, ["1\ttitle1\ttag1", "2\ttitle2\ttag1,tag2"]), (10, ["http://url1.com", "http://url2.com"]), (20, ["http://url1.com\ttag1", "http://url2.com\ttag1,tag2"]), (30, ["title1", "title2"]), (40, ["http://url1.com\ttitle1\ttag1", "http://url2.com\ttitle2\ttag1,tag2"]), (50, ["title1\ttag1", "title2\ttag1,tag2"]), ]) def test_print_rec_with_filter(capfd, field_filter, exp_res): records = [(1, "http://url1.com", "title1", ",tag1,", "desc1"), (2, "http://url2.com", "title2", ",tag1,tag2,", "desc2")] print_rec_with_filter(records, field_filter) assert capfd.readouterr().out == ''.join(f'{s}\n' for s in exp_res) @pytest.mark.parametrize( "taglist, exp_res", [ ["tag1, tag2+3", ([",tag1,", ",tag2+3,"], "OR", None)], ["tag1 + tag2-3 + tag4", ([",tag1,", ",tag2-3,", ",tag4,"], "AND", None)], ["tag1, tag2-3 - tag4, tag5", ([",tag1,", ",tag2-3,"], "OR", ",tag4,|,tag5,")], ], ) def test_prep_tag_search(taglist, exp_res): """test prep_tag_search helper function""" results = prep_tag_search(taglist) assert results == exp_res @pytest.mark.parametrize( "nav, is_editor_valid_retval, edit_rec_retval", product( ["w", [None, None, 1], [None, None, "string"]], [True, False], [[mock.Mock(), mock.Mock(), mock.Mock(), mock.Mock()], None], ), ) def test_edit_at_prompt(nav, is_editor_valid_retval, edit_rec_retval): """test func.""" obj = mock.Mock() editor = mock.Mock() with mock.patch("buku.get_system_editor", return_value=editor), mock.patch( "buku.is_editor_valid", return_value=is_editor_valid_retval ), mock.patch("buku.edit_rec", return_value=edit_rec_retval) as m_edit_rec: import buku buku.edit_at_prompt(obj, nav) # test if nav == "w" and not is_editor_valid_retval: return if nav == "w": m_edit_rec.assert_called_once_with(editor, "", None, buku.DELIM, None) elif buku.is_int(nav[2:]): obj.edit_update_rec.assert_called_once_with(int(nav[2:])) return else: editor = nav[2:] m_edit_rec.assert_called_once_with(editor, "", None, buku.DELIM, None) if edit_rec_retval is not None: obj.add_rec(*edit_rec_retval) @pytest.mark.parametrize('single_record', [True, False]) @pytest.mark.parametrize('field_filter', [0, 1, 2, 3, 4, 5, 10, 20, 30, 40, 50]) def test_format_json(field_filter, single_record): resultset = [[f'' for x in range(5)]] fields = FIELD_FILTER.get(field_filter, ALL_FIELDS) marks = {} if 'id' in fields: marks['index'] = '' if 'url' in fields: marks['uri'] = '' if 'title' in fields: marks['title'] = '' if 'tags' in fields: marks['tags'] = 'row3' if 'desc' in fields: marks['description'] = '' if not single_record: marks = [marks] with mock.patch("buku.json") as m_json: import buku res = buku.format_json(resultset, single_record, field_filter) m_json.dumps.assert_called_once_with(marks, sort_keys=True, indent=4) assert res == m_json.dumps.return_value @pytest.mark.parametrize( "string, exp_res", [ ("string", False), ("12", True), ("12.1", False), ], ) def test_is_int(string, exp_res): """test func.""" import buku assert exp_res == buku.is_int(string) @pytest.mark.parametrize( "url, opened_url, platform", [ ["http://example.com", "http://example.com", "linux"], ["example.com", "http://example.com", "linux"], ["http://example.com", "http://example.com", "win32"], ], ) def test_browse(url, opened_url, platform): """test func.""" with mock.patch("buku.webbrowser") as m_webbrowser, mock.patch("buku.sys") as m_sys, mock.patch("buku.os"): m_sys.platform = platform get_func_retval = mock.Mock() m_webbrowser.get.return_value = get_func_retval import buku buku.browse.suppress_browser_output = True buku.browse.override_text_browser = False buku.browse(url) if platform == "win32": m_webbrowser.open.assert_called_once_with(opened_url, new=2) else: get_func_retval.open.assert_called_once_with(opened_url, new=2) @pytest.mark.parametrize("status_code, latest_release", product([200, 404], [True, False])) def test_check_upstream_release(status_code, latest_release): """test func.""" resp = mock.Mock() resp.status = status_code m_manager = mock.Mock() m_manager.request.return_value = resp with mock.patch("buku.urllib3") as m_urllib3, mock.patch("buku.print") as m_print: import buku if latest_release: latest_version = "v{}".format(buku.__version__) else: latest_version = "v0" m_urllib3.PoolManager.return_value = m_manager resp.data.decode.return_value = json.dumps([{"tag_name": latest_version}]) buku.check_upstream_release() if status_code != 200: return len(m_print.mock_calls) == 1 @pytest.mark.parametrize( "exp, item, exp_res", [ ("cat.y", "catty", True), ("cat.y", "caty", False), ], ) def test_regexp(exp, item, exp_res): """test func.""" import buku res = buku.regexp(exp, item) assert res == exp_res @pytest.mark.parametrize("token, exp_res", [("text", ",text,")]) def test_delim_wrap(token, exp_res): """test func.""" import buku res = buku.delim_wrap(token) assert res == exp_res def test_read_in(): """test func.""" message = mock.Mock() with mock.patch("buku.disable_sigint_handler"), mock.patch("buku.enable_sigint_handler"), mock.patch( "buku.input", return_value=message ): import buku res = buku.read_in(msg=mock.Mock()) assert res == message def test_sigint_handler_with_mock(): """test func.""" with mock.patch("buku.os") as m_os: import buku buku.sigint_handler(mock.Mock(), mock.Mock()) m_os._exit.assert_called_once_with(1) def test_get_system_editor(): """test func.""" with mock.patch("buku.os") as m_os: import buku res = buku.get_system_editor() assert res == m_os.environ.get.return_value m_os.environ.get.assert_called_once_with("EDITOR", "none") @pytest.mark.parametrize( "editor, exp_res", [ ("none", False), ("0", False), ("random_editor", True), ], ) def test_is_editor_valid(editor, exp_res): """test func.""" import buku assert buku.is_editor_valid(editor) == exp_res @pytest.mark.parametrize( "url, title_in, tags_in, desc", product( [None, "example.com"], [None, "", "title"], [None, "", "-", "tag1,tag2", ",tag1,tag2,", ",,,,,"], [None, "", "-", "description"], ), ) def test_to_temp_file_content(url, title_in, tags_in, desc): """test func.""" import buku if desc is None: desc_text = "\n" elif desc == "": desc_text = "-" else: desc_text = desc if title_in is None: title_text = "" elif title_in == "": title_text = "-" else: title_text = title_in res = buku.to_temp_file_content(url, title_in, tags_in, desc) lines = """# Lines beginning with "#" will be stripped. # Add URL in next line (single line).{} # Add TITLE in next line (single line). Leave blank to web fetch, "-" for no title.{} # Add comma-separated TAGS in next line (single line).{} # Add COMMENTS in next line(s). Leave blank to web fetch, "-" for no comments.{}""".format( "".join(["\n", url]) if url is not None else "", "".join(["\n", title_text]), "".join(["\n", ",".join([x for x in tags_in.split(",") if x])]) if tags_in else "\n", "".join(["\n", desc_text]), ) assert res == lines @pytest.mark.parametrize( "content, exp_res", [ ("", None), ("#line1\n#line2", None), ( "\n".join( [ "example.com", "title", "tags", "desc", ] ), ("example.com", "title", ",tags,", "desc"), ), ], ) def test_parse_temp_file_content(content, exp_res): """test func.""" import buku res = buku.parse_temp_file_content(content) assert res == exp_res @pytest.mark.skip(reason="can't patch subprocess") def test_edit_rec(): """test func.""" editor = "nanoe" args = ("url", "title_in", "tags_in", "desc") with mock.patch("buku.to_temp_file_content"), mock.patch("buku.os"), mock.patch("buku.open"), mock.patch( "buku.parse_temp_file_content" ) as m_ptfc: import buku res = buku.edit_rec(editor, *args) assert res == m_ptfc.return_value @pytest.mark.parametrize("argv, pipeargs, isatty", product(["argv"], [None, []], [True, False])) def test_piped_input(argv, pipeargs, isatty): """test func.""" with mock.patch("buku.sys") as m_sys: m_sys.stdin.isatty.return_value = isatty m_sys.stdin.readlines.return_value = "arg1\narg2" import buku if pipeargs is None and not isatty: with pytest.raises(TypeError): buku.piped_input(argv, pipeargs) return buku.piped_input(argv, pipeargs) class TestHelpers(unittest.TestCase): # @unittest.skip('skipping') # @unittest.skip('skipping') def test_is_int(self): self.assertTrue(is_int("0")) self.assertTrue(is_int("1")) self.assertTrue(is_int("-1")) self.assertFalse(is_int("")) self.assertFalse(is_int("one")) # This test fails because we use os._exit() now @unittest.skip("skipping") def test_sigint_handler(capsys): try: # sending SIGINT to self os.kill(os.getpid(), signal.SIGINT) except SystemExit as error: out, err = capsys.readouterr() # assert exited with 1 assert error.args[0] == 1 # assert proper error message assert out == "" assert err == "\nInterrupted.\n" @pytest.mark.vcr("tests/vcr_cassettes/test_fetch_data_with_url.yaml") @pytest.mark.parametrize( "url, exp_res", [ ["http://example.com.", {'bad': True}], ["http://example.com", {'title': 'Example Domain', 'fetch_status': 200}], ["http://example.com/page1.txt", {'mime': True, 'fetch_status': 404}], ["about:new_page", {'bad': True}], ["chrome://version/", {'bad': True}], ["chrome://version/", {'bad': True}], # [ # 'http://4pda.ru/forum/index.php?showtopic=182463&st=1640#entry6044923', # {'title': 'Samsung GT-I5800 Galaxy 580 - Обсуждение - 4PDA', # 'desc': 'Samsung GT-I5800 Galaxy 580 - Обсуждение - 4PDA', # 'fetch_status': 200}, # ], [ "https://www.google.ru/search?" "newwindow=1&safe=off&q=xkbcomp+alt+gr&" "oq=xkbcomp+alt+gr&" "gs_l=serp.3..33i21.28976559.28977886.0." "28978017.6.6.0.0.0.0.167.668.0j5.5.0....0...1c.1.64." "serp..1.2.311.06cSKPTLo18", {'title': 'xkbcomp alt gr', 'fetch_status': 200}, ], [ "http://www.vim.org/scripts/script.php?script_id=4641", {'title': 'mlessnau_case - "in-case" selection, deletion and substitution for underscore, camel, mixed case : vim online', 'fetch_status': 200}, ], ], ids=lambda s: (s.split('?')[0] + '~' if isinstance(s, str) and '?' in s else None), ) def test_fetch_data_with_url(url, exp_res): """test func.""" import urllib3 import buku buku.urllib3 = urllib3 buku.myproxy = None res = buku.fetch_data(url) if urlparse(url).netloc == "www.google.ru": res = res._replace(title=res.title.split(' - ')[0]) assert res == FetchResult(url, **exp_res) @pytest.mark.parametrize( "url, exp_res", [ ("http://example.com", False), ("apt:package1,package2,package3", True), ("apt://firefox", True), ("file:///tmp/vim-markdown-preview.html", True), ("place:sort=8&maxResults=10", True), ], ) def test_is_nongeneric_url(url, exp_res): import buku res = buku.is_nongeneric_url(url) assert res == exp_res @pytest.mark.parametrize('tags, newtag, exp_tags', [ ('', None, ','), ('', 'new tag', ',new tag,'), ('foo, bar, baz', None, ',bar,baz,foo,'), ('foo, bar, baz', 'new tag', ',bar,baz,foo,new tag,'), ]) @pytest.mark.parametrize('title', ['Bookmark title', '', None]) @pytest.mark.parametrize('url', ['http://example.com', 'javascript:void(0)', '']) def test_import_md(tmpdir, url, title, tags, newtag, exp_tags): from buku import import_md p = tmpdir.mkdir("importmd").join("test.md") print(line := (f'<{url}>' if title is None else f'[{title}]({url})') + ('' if not tags else f' ')) p.write(line) res = list(import_md(p.strpath, newtag)) assert res == ([] if not url else # `<>` and `[title]()` are not valid [(url, title or '', exp_tags, None, 0, True, False)]) @pytest.mark.parametrize('newtag, exp_res', [ (None, ('http://example.com', 'text1', ',', None, 0, True, False)), ('tag1', ('http://example.com', 'text1', ',tag1,', None, 0, True, False)), ]) @pytest.mark.parametrize('extension', ['.rss', '.atom']) def test_import_rss(tmpdir, extension, newtag, exp_res): from buku import import_rss p = tmpdir.mkdir('importrss').join('test' + extension) p.write( '\n' ' Bookmarks\n' ' buku\n' ' \n' ' text1\n' ' \n' ' \n' '\n') res = list(import_rss(p.strpath, newtag)) assert res[0] == exp_res @pytest.mark.parametrize('tags, newtag, exp_tags', [ ('', None, ','), ('', 'new tag', ',new tag,'), ('tag1: ::tag2:tag::3:tag4:: :tag:::5: ta g::6:: ', None, ',tag1,:tag2,tag:3,tag4:,tag::5,ta g:6:,'), ('tag1: ::tag2:tag::3:tag4:: :tag:::5: ta g::6:: ', 'new tag', ',new tag,tag1,:tag2,tag:3,tag4:,tag::5,ta g:6:,'), ]) @pytest.mark.parametrize('title', ['Bookmark title', '', None]) @pytest.mark.parametrize('url', ['http://example.com', 'javascript:void(0)', '']) def test_import_org(tmpdir, url, title, tags, newtag, exp_tags): from buku import import_org p = tmpdir.mkdir("importorg").join("test.org") print(line := (f'[[{url}]]' if title is None else f'[[{url}][{title}]]') + ('' if not tags else f' :{tags}:')) p.write(line) res = list(import_org(p.strpath, newtag)) assert res == ([] if not url or title == '' else # `[[]]`, `[[][title]]` and `[[url][]]` are not valid [(url, title or '', exp_tags, None, 0, True, False)]) @pytest.mark.parametrize( "html_text, exp_res", [ ( """
      GitHub
      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 = """

      1s (blah,blah)

      """ exp_res = ("http://example.com/", None, ",1s (blah blah),", None, 0, True, False) html_soup = BeautifulSoup(html_text, "html.parser") res = list(import_html(html_soup, True, None)) assert res[0] == exp_res @pytest.mark.parametrize( "add_all_parent, exp_res", [ ( True, [ ("http://example11.com", None, ",folder11,", None, 0, True, False), ( "http://example12.com", None, ",folder11,folder12,", None, 0, True, False, ), ( "http://example13.com", None, ",folder11,folder12,folder13 (blah blah),tag3,tag4,", None, 0, True, False, ), ( "http://example121.com", None, ",folder11,folder12,folder121,", None, 0, True, False, ), ], ), ( False, [ ("http://example11.com", None, ",folder11,", None, 0, True, False), ("http://example121.com", None, ",folder121,", None, 0, True, False), ("http://example12.com", None, ",folder12,", None, 0, True, False), ("http://example13.com", None, ",folder13 (blah blah),tag3,tag4,", None, 0, True, False), ], ), ], ) def test_import_html_and_add_all_parent(add_all_parent, exp_res): from bs4 import BeautifulSoup from buku import import_html html_text = """

      Folder01

      Folder02

      Folder03

      Folder11

      Folder12

      Folder121

      Folder13 (blah,blah)

      """ html_soup = BeautifulSoup(html_text, "html.parser") res = list(import_html(html_soup, True, None, add_all_parent)) # pylint: disable=E1121 assert check_import_html_results_contains(res, exp_res) def test_import_html_and_new_tag(): from bs4 import BeautifulSoup from buku import import_html html_text = """
      GitHub
      comment for the bookmark here""" exp_res = ( "https://github.com/j", "GitHub", ",tag1,tag2,tag3,", "comment for the bookmark here", 0, True, False, ) html_soup = BeautifulSoup(html_text, "html.parser") res = list(import_html(html_soup, False, "tag3")) assert res[0] == exp_res @pytest.mark.parametrize('profiles, expected', [ (dedent(''' [Profile3] Name=ABCD IsRelative=0 Path=/path/to/removable/drive/ABCD Default=1 [Install4F96D1932A9F858E] Default=/path/to/custom/path/my-main-profile Locked=1 [Profile1] Name=Main IsRelative=0 Path=/path/to/custom/path/main-profile [Profile0] Name=default IsRelative=1 Path=zsq8tck1.default-release [InstallD087BC9767A4CB84] Default=1koqf71l.default-nightly Locked=1 [General] StartWithLastProfile=1 Version=2 '''), ['/path/to/custom/path/my-main-profile', '1koqf71l.default-nightly']), (dedent(''' [Profile3] Name=ABCD IsRelative=0 Path=/path/to/removable/drive/ABCD Default=1 [Profile1] Name=Main IsRelative=0 Path=/path/to/custom/path/my-main-profile [Profile0] Name=default IsRelative=1 Path=zsq8tck1.default-release [General] StartWithLastProfile=1 Version=2 '''), ['/path/to/removable/drive/ABCD', 'zsq8tck1.default-release']), ('', []), (None, []), ]) @mock.patch('os.path.exists') def test_get_firefox_profile_names(_os_path_exists, profiles, expected): _os_path_exists.return_value = profiles is not None with mock.patch.object(ConfigParser, 'read', lambda self, _: self.read_string(profiles)): import buku assert buku.get_firefox_profile_names('') == expected @pytest.mark.parametrize('profiles, specified, expected', [ (['foo', '/bar/baz'], None, { 'foo': os.path.join('~/profiles', 'foo', 'places.sqlite'), '/bar/baz': os.path.join('/bar/baz', 'places.sqlite'), }), (['foo', '/bar/baz'], 'qux', {'qux': os.path.join('~/profiles', 'qux', 'places.sqlite')}), ([], '/grue/xyzzy', {'/grue/xyzzy': os.path.join('/grue/xyzzy', 'places.sqlite')}), ]) def test_get_firefox_db_paths(profiles, specified, expected): with mock.patch('buku.get_firefox_profile_names', return_value=profiles): import buku assert buku.get_firefox_db_paths('~/profiles', specified) == expected @pytest.mark.parametrize( "platform, params", [ ["linux", ["xsel", "-b", "-i"]], ["freebsd", ["xsel", "-b", "-i"]], ["openbsd", ["xsel", "-b", "-i"]], ["darwin", ["pbcopy"]], ["win32", ["clip"]], ["random", None], ], ) def test_copy_to_clipboard(platform, params): # m_popen = mock.Mock() content = mock.Mock() m_popen_retval = mock.Mock() platform_recognized = platform.startswith(("linux", "freebsd", "openbsd")) or platform in ("darwin", "win32") with mock.patch("buku.sys") as m_sys, mock.patch("buku.Popen", return_value=m_popen_retval) as m_popen, mock.patch( "buku.shutil.which", return_value=True ): m_sys.platform = platform import subprocess from buku import copy_to_clipboard copy_to_clipboard(content) if platform_recognized: m_popen.assert_called_once_with( params, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) m_popen_retval.communicate.assert_called_once_with(content) else: logging.info("popen is called {} on unrecognized platform".format(m_popen.call_count)) @pytest.mark.parametrize( "export_type, exp_res", [ ["random", None], [ 'html', '\n\n' '\n' 'Bookmarks\n

      Bookmarks

      \n\n

      \n' '

      buku bookmarks

      \n' '

      \n' '

      \n' '
      \n' '
      Google\n' '

      \n

      ', ], [ 'org', '* [[http://example.com]]\n' '* [[http://example.org]] :bar:baz:foo:\n' '* [[http://google.com][Google]] :bar:baz:foo:\n', ], [ 'markdown', '- \n' '- \n' '- [Google](http://google.com) \n', ], [ 'rss', '\n' ' Bookmarks\n' ' buku\n' ' \n' ' \n' ' \n' ' 1\n' ' \n' ' \n' ' \n' ' \n' ' 2\n' ' \n' ' \n' ' \n' ' \n' ' \n' ' Google\n' ' \n' ' 3\n' ' \n' ' \n' ' \n' ' \n' '', ], [ 'xbel', '\n' '\n\n' '\n' ' \n' ' \n' ' \n' ' \n' ' \n' ' \n' ' \n' ' Google\n' ' \n' '' ], ], ) def test_convert_bookmark_set(export_type, exp_res, monkeypatch): import buku from buku import convert_bookmark_set bms = [ (1, "http://example.com", "", ",", "", 0), (2, "http://example.org", None, ",bar,baz,foo,", "", 0), (3, "http://google.com", "Google", ",bar,baz,foo,", "", 0), ] if export_type == "random": with pytest.raises(AssertionError): convert_bookmark_set(bms, export_type=export_type) else: def return_fixed_number(): return 1556430615 monkeypatch.setattr(buku.time, "time", return_fixed_number) res = convert_bookmark_set(bms, export_type=export_type) assert res["count"] == 3 assert exp_res == res["data"] @pytest.mark.parametrize( "tags,data", [ [",", "\n"], [",tag1,tag2,", " :tag1:tag2:\n"], [",word1 word2,", " :word1_word2:\n"], [",word1:word2,", " :word1_word2:\n"], [",##tag##,", " :_tag_:\n"], [",##tag##,!!tag!!,", " :_tag_:\n"], [",home / personal,", " :home_personal:\n"], ], ) def test_convert_tags_to_org_mode_tags(tags, data): from buku import convert_tags_to_org_mode_tags res = convert_tags_to_org_mode_tags(tags) assert res == data @pytest.mark.parametrize('charset', ['ISO-8859-1', 'UTF-8']) @pytest.mark.parametrize('mode', ['charset', 'content', 'header']) def test_get_data_from_page(charset, mode): from urllib3.response import HTTPResponse from buku import get_data_from_page title = 'Répertoire des articles relatifs à l\'Asiminier - Asimina triloba (L.) Dunal (site Les Fruitiers Rares)' headers = (None if mode != 'header' else {'Content-Type': f'text/html; charset={charset}'}) meta = { 'charset': f'\n', 'content': f'\n', }.get(mode, '') keywords = '' body = f'\n\n{meta}\n{keywords}\n{title}\n\n\n\n\n' resp = HTTPResponse(body.encode(charset), headers) parsed_title, desc, tags = get_data_from_page(resp) assert (parsed_title, tags) == (title, "foo,bar baz,quux") @pytest.mark.parametrize('tokens, kwargs, expected', [ (None, {}, None), ('404', {}, {404}), ('403,404', {}, {403, 404}), ({'400', '500'}, {}, {400, 500}), (('400-404', '500'), {}, {400, 401, 402, 403, 404, 500}), (['400-404', '500'], {'valid': lambda x: x in range(400, 600)}, {400, 401, 402, 403, 404, 500}), (['400-404', '300'], {'valid': lambda x: x in range(400, 600)}, ValueError('Not a valid range')), ('-3', {}, {-3}), ('-3', {'maxidx': 10}, {8, 9, 10}), ('-30', {'maxidx': 3}, {1, 2, 3}), ('10-3', {'maxidx': 5}, {3, 4, 5}), ]) def test_parse_range(tokens, kwargs, expected): if not isinstance(expected, Exception): assert parse_range(tokens, **kwargs) == expected else: try: parse_range(tokens, **kwargs) assert False, 'error expected' except Exception as e: assert type(e) is type(expected) assert str(e) == str(expected) def test_split_by_marker(): search_string = (' global substring .title substring :url substring :https ' '> description substring #partial,tags: #,exact,tags, *another global substring ') assert split_by_marker(search_string) == [ ' global substring', '.title substring', ':url substring', ':https', '> description substring', '#partial,tags:', '#,exact,tags,', '*another global substring ', ] def test_SortKey(): assert repr(SortKey('foo', ascending=True)) == "+'foo'" assert repr(SortKey('bar', ascending=False)) == "-'bar'" assert SortKey('foo', ascending=True) > SortKey('bar', ascending=True) assert not SortKey('foo', ascending=True) > SortKey('foo', ascending=True) # pylint: disable=unnecessary-negation assert not SortKey('foo', ascending=True) < SortKey('foo', ascending=True) # pylint: disable=unnecessary-negation assert not SortKey('foo', ascending=True) < SortKey('bar', ascending=True) # pylint: disable=unnecessary-negation assert SortKey('foo', ascending=False) < SortKey('bar', ascending=False) assert not SortKey('foo', ascending=False) < SortKey('foo', ascending=False) # pylint: disable=unnecessary-negation assert not SortKey('foo', ascending=False) > SortKey('foo', ascending=False) # pylint: disable=unnecessary-negation assert not SortKey('foo', ascending=False) > SortKey('bar', ascending=False) # pylint: disable=unnecessary-negation custom_order = lambda s: (SortKey(len(s), ascending=False), SortKey(s, ascending=True)) assert sorted(['foo', 'bar', 'baz', 'quux'], key=custom_order) == ['quux', 'bar', 'baz', 'foo'] ================================================ FILE: tests/test_bukuDb/25491522_res.yaml ================================================ ? !!python/tuple ['http://voyagerlive.org/', Voyager, ',bookmarks bar,', null, 0, true, false] : {} ? !!python/tuple ['http://wiki.ubuntu.com/', Ubuntu Wiki (community-edited website), ',bookmarks bar,imported from firefox (2011-09-02 06:03:50),ubuntu and free software links,', null, 0, true, false] : {} ? !!python/tuple ['http://www.debian.org/', Debian (Ubuntu is based on Debian), ',bookmarks bar,imported from firefox (2011-09-02 06:03:50),ubuntu and free software links,', null, 0, true, false] : {} ? !!python/tuple ['http://www.ubuntu.com/', Ubuntu, ',bookmarks bar,imported from firefox (2011-09-02 06:03:50),ubuntu and free software links,', null, 0, true, false] : {} ? !!python/tuple ['https://addons.mozilla.org/fr/firefox/addon/adblock-plus/', Adblock Plus, ',bookmarks bar,f+,', null, 0, true, false] : {} ? !!python/tuple ['https://addons.mozilla.org/fr/firefox/addon/searchpreview/', SearchPreview, ',bookmarks bar,f+,', null, 0, true, false] : {} ? !!python/tuple ['https://addons.mozilla.org/fr/firefox/language-tools/', Language Tools, ',bookmarks bar,f+,', null, 0, true, false] : {} ? !!python/tuple ['https://answers.launchpad.net/ubuntu/+addquestion', Make a Support Request to the Ubuntu Community, ',bookmarks bar,imported from firefox (2011-09-02 06:03:50),ubuntu and free software links,', null, 0, true, false] : {} ? !!python/tuple ['https://one.ubuntu.com/', Ubuntu One - The personal cloud that brings your digital life together, ',bookmarks bar,imported from firefox (2011-09-02 06:03:50),ubuntu and free software links,', null, 0, true, false] : {} ? !!python/tuple ['https://www.google.com/', Google, ',other bookmarks,', null, 0, true, false] : {} ? !!python/tuple ['https://www.mozilla.org/en-US/about/', About Us, ',bookmarks bar,imported from firefox (2011-09-02 06:03:50),mozilla firefox,', null, 0, true, false] : {} ? !!python/tuple ['https://www.mozilla.org/en-US/contribute/', Get Involved, ',bookmarks bar,imported from firefox (2011-09-02 06:03:50),mozilla firefox,', null, 0, true, false] : {} ? !!python/tuple ['https://www.mozilla.org/en-US/firefox/customize/', Customize Firefox, ',bookmarks bar,imported from firefox (2011-09-02 06:03:50),mozilla firefox,', null, 0, true, false] : {} ? !!python/tuple ['https://www.mozilla.org/en-US/firefox/help/', Help and Tutorials, ',bookmarks bar,imported from firefox (2011-09-02 06:03:50),mozilla firefox,', null, 0, true, false] : {} ================================================ FILE: tests/test_bukuDb/25491522_res_nopt.yaml ================================================ ? !!python/tuple ['http://voyagerlive.org/', Voyager, ',', null, 0, true, false] : {} ? !!python/tuple ['http://wiki.ubuntu.com/', Ubuntu Wiki (community-edited website), ',', null, 0, true, false] : {} ? !!python/tuple ['http://www.debian.org/', Debian (Ubuntu is based on Debian), ',', null, 0, true, false] : {} ? !!python/tuple ['http://www.ubuntu.com/', Ubuntu, ',', null, 0, true, false] : {} ? !!python/tuple ['https://addons.mozilla.org/fr/firefox/addon/adblock-plus/', Adblock Plus, ',', null, 0, true, false] : {} ? !!python/tuple ['https://addons.mozilla.org/fr/firefox/addon/searchpreview/', SearchPreview, ',', null, 0, true, false] : {} ? !!python/tuple ['https://addons.mozilla.org/fr/firefox/language-tools/', Language Tools, ',', null, 0, true, false] : {} ? !!python/tuple ['https://answers.launchpad.net/ubuntu/+addquestion', Make a Support Request to the Ubuntu Community, ',', null, 0, true, false] : {} ? !!python/tuple ['https://one.ubuntu.com/', Ubuntu One - The personal cloud that brings your digital life together, ',', null, 0, true, false] : {} ? !!python/tuple ['https://www.google.com/', Google, ',', null, 0, true, false] : {} ? !!python/tuple ['https://www.mozilla.org/en-US/about/', About Us, ',', null, 0, true, false] : {} ? !!python/tuple ['https://www.mozilla.org/en-US/contribute/', Get Involved, ',', null, 0, true, false] : {} ? !!python/tuple ['https://www.mozilla.org/en-US/firefox/customize/', Customize Firefox, ',', null, 0, true, false] : {} ? !!python/tuple ['https://www.mozilla.org/en-US/firefox/help/', Help and Tutorials, ',', null, 0, true, false] : {} ================================================ FILE: tests/test_bukuDb/Bookmarks ================================================ { "checksum": "159ce0a29c234510ba09c3091e0b513b", "roots": { "bookmark_bar": { "children": [ { "date_added": "13084680099000000", "id": "6", "name": "Voyager", "type": "url", "url": "http://voyagerlive.org/" }, { "children": [ { "date_added": "13084680167000000", "id": "8", "name": "0", "type": "url", "url": "file:///.startpage/0/index.html" }, { "date_added": "13084680271000000", "id": "9", "name": "1", "type": "url", "url": "file:///.startpage/1/index.html" }, { "date_added": "13084680271000000", "id": "10", "name": "2", "type": "url", "url": "file:///.startpage/2/index.html" }, { "date_added": "13084680271000000", "id": "11", "name": "3", "type": "url", "url": "file:///.startpage/3/index.html" }, { "date_added": "13084680271000000", "id": "12", "name": "4", "type": "url", "url": "file:///.startpage/4/index.html" }, { "date_added": "13084680271000000", "id": "13", "name": "5", "type": "url", "url": "file:///.startpage/5/index.html" }, { "date_added": "13084680271000000", "id": "14", "name": "6", "type": "url", "url": "file:///.startpage/6/index.html" }, { "date_added": "13084680271000000", "id": "15", "name": "7", "type": "url", "url": "file:///.startpage/7/index.html" }, { "date_added": "13084680271000000", "id": "16", "name": "8", "type": "url", "url": "file:///.startpage/8/index.html" }, { "date_added": "13084680271000000", "id": "17", "name": "9", "type": "url", "url": "file:///.startpage/9/index.html" }, { "date_added": "13084680271000000", "id": "18", "name": "10", "type": "url", "url": "file:///.startpage/10/index.html" }, { "date_added": "13084680271000000", "id": "19", "name": "11", "type": "url", "url": "file:///.startpage/11/index.html" }, { "date_added": "13084680271000000", "id": "20", "name": "12", "type": "url", "url": "file:///.startpage/12/index.html" }, { "date_added": "13084680271000000", "id": "21", "name": "13", "type": "url", "url": "file:///.startpage/13/index.html" }, { "date_added": "13084680271000000", "id": "22", "name": "14", "type": "url", "url": "file:///.startpage/14/index.html" }, { "date_added": "13084680271000000", "id": "23", "name": "15", "type": "url", "url": "file:///.startpage/15/index.html" }, { "date_added": "13084680271000000", "id": "24", "name": "16", "type": "url", "url": "file:///.startpage/16/index.html" }, { "date_added": "13084680271000000", "id": "25", "name": "17", "type": "url", "url": "file:///.startpage/17/index.html" }, { "date_added": "13084680271000000", "id": "26", "name": "18", "type": "url", "url": "file:///.startpage/18/index.html" }, { "date_added": "13084680271000000", "id": "27", "name": "19", "type": "url", "url": "file:///.startpage/19/index.html" }, { "date_added": "13084680271000000", "id": "28", "name": "20", "type": "url", "url": "file:///.startpage/20/index.html" }, { "date_added": "13084680271000000", "id": "29", "name": "21", "type": "url", "url": "file:///.startpage/21/index.html" }, { "date_added": "13084680271000000", "id": "30", "name": "22", "type": "url", "url": "file:///.startpage/22/index.html" }, { "date_added": "13084680271000000", "id": "31", "name": "23", "type": "url", "url": "file:///.startpage/23/index.html" }, { "date_added": "13084680271000000", "id": "32", "name": "24", "type": "url", "url": "file:///.startpage/24/index.html" }, { "date_added": "13084680271000000", "id": "33", "name": "25", "type": "url", "url": "file:///.startpage/25/index.html" }, { "date_added": "13084680271000000", "id": "34", "name": "26", "type": "url", "url": "file:///.startpage/26/index.html" }, { "date_added": "13084680271000000", "id": "35", "name": "27", "type": "url", "url": "file:///.startpage/27/index.html" } ], "date_added": "13149362306499266", "date_modified": "0", "id": "7", "name": "SP", "type": "folder" }, { "children": [ { "date_added": "13084680637000000", "id": "37", "name": "Flash Install", "type": "url", "url": "apt://flashplugin-installer" }, { "date_added": "13084680669000000", "id": "38", "name": "Adblock Plus", "type": "url", "url": "https://addons.mozilla.org/fr/firefox/addon/adblock-plus/" }, { "date_added": "13084680688000000", "id": "39", "name": "SearchPreview", "type": "url", "url": "https://addons.mozilla.org/fr/firefox/addon/searchpreview/" }, { "date_added": "13084680713000000", "id": "40", "name": "Language Tools", "type": "url", "url": "https://addons.mozilla.org/fr/firefox/language-tools/" } ], "date_added": "13149362306505611", "date_modified": "0", "id": "36", "name": "F+", "type": "folder" }, { "children": [ { "children": [ { "date_added": "13084680036000000", "id": "43", "name": "Ubuntu", "type": "url", "url": "http://www.ubuntu.com/" }, { "date_added": "13084680036000000", "id": "44", "name": "Ubuntu Wiki (community-edited website)", "type": "url", "url": "http://wiki.ubuntu.com/" }, { "date_added": "13084680036000000", "id": "45", "name": "Make a Support Request to the Ubuntu Community", "type": "url", "url": "https://answers.launchpad.net/ubuntu/+addquestion" }, { "date_added": "13084680036000000", "id": "46", "name": "Debian (Ubuntu is based on Debian)", "type": "url", "url": "http://www.debian.org/" }, { "date_added": "13084680036000000", "id": "47", "name": "Ubuntu One - The personal cloud that brings your digital life together", "type": "url", "url": "https://one.ubuntu.com/" } ], "date_added": "13149362306508801", "date_modified": "0", "id": "42", "name": "Ubuntu and Free Software links", "type": "folder" }, { "children": [ { "date_added": "13084680036000000", "id": "49", "name": "Help and Tutorials", "type": "url", "url": "https://www.mozilla.org/en-US/firefox/help/" }, { "date_added": "13084680036000000", "id": "50", "name": "Customize Firefox", "type": "url", "url": "https://www.mozilla.org/en-US/firefox/customize/" }, { "date_added": "13084680036000000", "id": "51", "name": "Get Involved", "type": "url", "url": "https://www.mozilla.org/en-US/contribute/" }, { "date_added": "13084680036000000", "id": "52", "name": "About Us", "type": "url", "url": "https://www.mozilla.org/en-US/about/" } ], "date_added": "13149362306510034", "date_modified": "0", "id": "48", "name": "Mozilla Firefox", "type": "folder" } ], "date_added": "13149362306507580", "date_modified": "13149362306507581", "id": "41", "name": "Imported From Firefox (2011-09-02, 06:03:50)", "type": "folder" } ], "date_added": "13149362288728239", "date_modified": "0", "id": "1", "name": "Bookmarks bar", "type": "folder" }, "other": { "children": [ { "date_added": "13149416292911928", "id": "55", "name": "Google", "type": "url", "url": "https://www.google.com/" } ], "date_added": "13149362288728244", "date_modified": "13149416292911928", "id": "2", "name": "Other bookmarks", "type": "folder" }, "synced": { "children": [ ], "date_added": "13149362288728244", "date_modified": "0", "id": "3", "name": "Mobile bookmarks", "type": "folder" } }, "version": 1 } ================================================ FILE: tests/test_bukuDb/firefox_res.yaml ================================================ ? !!python/tuple ['http://voyagerlive.org/', Voyager, ',bookmarks toolbar,', null, 0, true, false] : {} ? !!python/tuple ['http://wiki.ubuntu.com/', Ubuntu Wiki (community-edited website), ',bookmarks menu,ubuntu and free software links,', null, 0, true, false] : {} ? !!python/tuple ['http://www.debian.org/', Debian (Ubuntu is based on Debian), ',bookmarks menu,ubuntu and free software links,', null, 0, true, false] : {} ? !!python/tuple ['http://www.ubuntu.com/', Ubuntu, ',bookmarks menu,ubuntu and free software links,', null, 0, true, false] : {} ? !!python/tuple ['https://addons.mozilla.org/fr/firefox/addon/adblock-plus/', '', ',language,tags,', null, 0, true, false] : {} ? !!python/tuple ['https://addons.mozilla.org/fr/firefox/addon/adblock-plus/', Adblock Plus, ',bookmarks toolbar,f+,language,', null, 0, true, false] : {} ? !!python/tuple ['https://addons.mozilla.org/fr/firefox/addon/searchpreview/', SearchPreview, ',bookmarks toolbar,f+,', null, 0, true, false] : {} ? !!python/tuple ['https://addons.mozilla.org/fr/firefox/language-tools/', '', ',hello,language,tags,', null, 0, true, false] : {} ? !!python/tuple ['https://addons.mozilla.org/fr/firefox/language-tools/', Language Tools, ',bookmarks toolbar,f+,hello,language,', null, 0, true, false] : {} ? !!python/tuple ['https://answers.launchpad.net/ubuntu/+addquestion', Make a Support Request to the Ubuntu Community, ',bookmarks menu,ubuntu and free software links,', null, 0, true, false] : {} ? !!python/tuple ['https://one.ubuntu.com/', Ubuntu One - The personal cloud that brings your digital life together, ',bookmarks menu,ubuntu and free software links,', null, 0, true, false] : {} ? !!python/tuple ['https://www.google.co.in/', '', ',bookmarks menu,', null, 0, true, false] : {} ? !!python/tuple ['https://www.mozilla.org/en-US/about/', About Us, ',bookmarks menu,mozilla firefox,', null, 0, true, false] : {} ? !!python/tuple ['https://www.mozilla.org/en-US/contribute/', Get Involved, ',bookmarks menu,mozilla firefox,', null, 0, true, false] : {} ? !!python/tuple ['https://www.mozilla.org/en-US/firefox/customize/', Customize Firefox, ',bookmarks menu,mozilla firefox,', null, 0, true, false] : {} ? !!python/tuple ['https://www.mozilla.org/en-US/firefox/help/', Help and Tutorials, ',bookmarks menu,mozilla firefox,', null, 0, true, false] : {} ================================================ FILE: tests/test_bukuDb/firefox_res_nopt.yaml ================================================ ? !!python/tuple ['http://voyagerlive.org/', Voyager, ',', null, 0, true, false] : {} ? !!python/tuple ['http://wiki.ubuntu.com/', Ubuntu Wiki (community-edited website), ',', null, 0, true, false] : {} ? !!python/tuple ['http://www.debian.org/', Debian (Ubuntu is based on Debian), ',', null, 0, true, false] : {} ? !!python/tuple ['http://www.ubuntu.com/', Ubuntu, ',', null, 0, true, false] : {} ? !!python/tuple ['https://addons.mozilla.org/fr/firefox/addon/adblock-plus/', '', ',language,', null, 0, true, false] : {} ? !!python/tuple ['https://addons.mozilla.org/fr/firefox/addon/adblock-plus/', Adblock Plus, ',language,', null, 0, true, false] : {} ? !!python/tuple ['https://addons.mozilla.org/fr/firefox/addon/searchpreview/', SearchPreview, ',', null, 0, true, false] : {} ? !!python/tuple ['https://addons.mozilla.org/fr/firefox/language-tools/', '', ',hello,language,', null, 0, true, false] : {} ? !!python/tuple ['https://addons.mozilla.org/fr/firefox/language-tools/', Language Tools, ',hello,language,', null, 0, true, false] : {} ? !!python/tuple ['https://answers.launchpad.net/ubuntu/+addquestion', Make a Support Request to the Ubuntu Community, ',', null, 0, true, false] : {} ? !!python/tuple ['https://one.ubuntu.com/', Ubuntu One - The personal cloud that brings your digital life together, ',', null, 0, true, false] : {} ? !!python/tuple ['https://www.google.co.in/', '', ',', null, 0, true, false] : {} ? !!python/tuple ['https://www.mozilla.org/en-US/about/', About Us, ',', null, 0, true, false] : {} ? !!python/tuple ['https://www.mozilla.org/en-US/contribute/', Get Involved, ',', null, 0, true, false] : {} ? !!python/tuple ['https://www.mozilla.org/en-US/firefox/customize/', Customize Firefox, ',', null, 0, true, false] : {} ? !!python/tuple ['https://www.mozilla.org/en-US/firefox/help/', Help and Tutorials, ',', null, 0, true, false] : {} ================================================ FILE: tests/test_bukuDb/places.sql ================================================ PRAGMA foreign_keys=OFF; BEGIN TRANSACTION; CREATE TABLE moz_places ( id INTEGER PRIMARY KEY, url LONGVARCHAR, title LONGVARCHAR, rev_host LONGVARCHAR, visit_count INTEGER DEFAULT 0, hidden INTEGER DEFAULT 0 NOT NULL, typed INTEGER DEFAULT 0 NOT NULL, favicon_id INTEGER, frecency INTEGER DEFAULT -1 NOT NULL, last_visit_date INTEGER , guid TEXT, foreign_count INTEGER DEFAULT 0 NOT NULL, url_hash INTEGER DEFAULT 0 NOT NULL); INSERT INTO moz_places VALUES(1,'place:folder=BOOKMARKS_MENU&folder=UNFILED_BOOKMARKS&folder=TOOLBAR&queryType=1&sort=12&maxResults=10&excludeQueries=1',NULL,NULL,0,1,0,NULL,0,NULL,'3L8RhCORmJhI',1,268506299617350); INSERT INTO moz_places VALUES(2,'place:type=6&sort=14&maxResults=10',NULL,NULL,0,1,0,NULL,0,NULL,'THKmZ1sqzg9e',1,268505606444332); INSERT INTO moz_places VALUES(3,'http://www.ubuntu.com/',NULL,'moc.utnubu.www.',0,0,0,NULL,64,NULL,'zZD-rGLsu3ta',1,125508050257634); INSERT INTO moz_places VALUES(4,'http://wiki.ubuntu.com/',NULL,'moc.utnubu.ikiw.',0,0,0,NULL,64,NULL,'buL63RhXNx5I',1,125511519733047); INSERT INTO moz_places VALUES(5,'https://answers.launchpad.net/ubuntu/+addquestion',NULL,'ten.daphcnual.srewsna.',0,0,0,NULL,64,NULL,'R7Y0VYN5Qdc0',1,47359338650210); INSERT INTO moz_places VALUES(6,'http://www.debian.org/',NULL,'gro.naibed.www.',0,0,0,NULL,64,NULL,'v3JUUM0EFXCG',1,125508165346216); INSERT INTO moz_places VALUES(7,'https://one.ubuntu.com/',NULL,'moc.utnubu.eno.',0,0,0,NULL,64,NULL,'AGUzUoXo3NUs',1,47359195550374); INSERT INTO moz_places VALUES(8,'https://www.mozilla.org/en-US/firefox/help/',NULL,'gro.allizom.www.',0,0,0,NULL,64,NULL,'j_Zj5HQyRB0C',1,47356364765622); INSERT INTO moz_places VALUES(9,'https://www.mozilla.org/en-US/firefox/customize/',NULL,'gro.allizom.www.',0,0,0,NULL,64,NULL,'geto35febbf0',1,47357014640010); INSERT INTO moz_places VALUES(10,'https://www.mozilla.org/en-US/contribute/',NULL,'gro.allizom.www.',0,0,0,NULL,64,NULL,'Rk5TBl9nW_0G',1,47358034485371); INSERT INTO moz_places VALUES(11,'https://www.mozilla.org/en-US/about/',NULL,'gro.allizom.www.',0,0,0,NULL,64,NULL,'ajjJNGoeVZos',1,47358774953055); INSERT INTO moz_places VALUES(12,'place:sort=8&maxResults=10',NULL,NULL,0,1,0,NULL,0,NULL,'vDKpX_SQJZNF',1,268505095842199); INSERT INTO moz_places VALUES(13,'http://voyagerlive.org/',NULL,'gro.evilregayov.',0,0,0,NULL,64,NULL,'Tg0y_kuW7hAd',1,125507606885498); INSERT INTO moz_places VALUES(14,'file:///.startpage/0/index.html',NULL,'.',7,0,0,NULL,513,1468499310694226,'k3q8NHlxwJ8d',1,219669470494363); INSERT INTO moz_places VALUES(15,'file:///.startpage/1/index.html',NULL,'.',0,0,0,NULL,64,NULL,'4VbS_4dqJ79q',1,219668736475133); INSERT INTO moz_places VALUES(16,'file:///.startpage/2/index.html',NULL,'.',0,0,0,NULL,64,NULL,'D8KMVz0PnhzZ',1,219669324140854); INSERT INTO moz_places VALUES(17,'file:///.startpage/3/index.html',NULL,'.',0,0,0,NULL,64,NULL,'8ybRhb7Km8vY',1,219667182856922); INSERT INTO moz_places VALUES(18,'file:///.startpage/4/index.html',NULL,'.',0,0,0,NULL,64,NULL,'N5CP9cHa9ZDM',1,219669164690790); INSERT INTO moz_places VALUES(19,'file:///.startpage/5/index.html',NULL,'.',0,0,0,NULL,64,NULL,'4GND-j6aizWA',1,219669839582445); INSERT INTO moz_places VALUES(20,'file:///.startpage/6/index.html',NULL,'.',0,0,0,NULL,64,NULL,'AlCg03lD3iJL',1,219667108107103); INSERT INTO moz_places VALUES(21,'file:///.startpage/7/index.html',NULL,'.',0,0,0,NULL,64,NULL,'OTvU7l-YdvmN',1,219668183071102); INSERT INTO moz_places VALUES(22,'file:///.startpage/8/index.html',NULL,'.',0,0,0,NULL,64,NULL,'IPI7YoUqVbqd',1,219668476324512); INSERT INTO moz_places VALUES(23,'file:///.startpage/9/index.html',NULL,'.',0,0,0,NULL,64,NULL,'5kmSOb_Xe4I_',1,219669049854574); INSERT INTO moz_places VALUES(24,'file:///.startpage/10/index.html',NULL,'.',0,0,0,NULL,64,NULL,'BoSf-LyJzWzu',1,219669428108279); INSERT INTO moz_places VALUES(25,'file:///.startpage/11/index.html',NULL,'.',0,0,0,NULL,64,NULL,'216qE1FWvaJN',1,219668510215844); INSERT INTO moz_places VALUES(26,'file:///.startpage/12/index.html',NULL,'.',0,0,0,NULL,64,NULL,'hVpHyr-MGelW',1,219666403675874); INSERT INTO moz_places VALUES(27,'file:///.startpage/13/index.html',NULL,'.',0,0,0,NULL,64,NULL,'2r8eRn19hmv4',1,219667091819632); INSERT INTO moz_places VALUES(28,'file:///.startpage/14/index.html',NULL,'.',0,0,0,NULL,64,NULL,'vxAWivZ0jJmP',1,219668747999666); INSERT INTO moz_places VALUES(29,'file:///.startpage/15/index.html',NULL,'.',0,0,0,NULL,64,NULL,'_80kLPC1SXsf',1,219668718232164); INSERT INTO moz_places VALUES(30,'file:///.startpage/16/index.html',NULL,'.',0,0,0,NULL,64,NULL,'RQTXoE_MEOin',1,219668369570932); INSERT INTO moz_places VALUES(31,'file:///.startpage/17/index.html',NULL,'.',0,0,0,NULL,64,NULL,'4hvYmF1jW_OE',1,219666243124822); INSERT INTO moz_places VALUES(32,'file:///.startpage/18/index.html',NULL,'.',0,0,0,NULL,64,NULL,'zPKBW3WnmK0M',1,219670060396334); INSERT INTO moz_places VALUES(33,'file:///.startpage/19/index.html',NULL,'.',0,0,0,NULL,64,NULL,'AvLKOMNhR38s',1,219669203074249); INSERT INTO moz_places VALUES(34,'file:///.startpage/20/index.html',NULL,'.',0,0,0,NULL,64,NULL,'CFEXVi6ajxMJ',1,219667678613885); INSERT INTO moz_places VALUES(35,'file:///.startpage/21/index.html',NULL,'.',0,0,0,NULL,64,NULL,'9zywynfkqAEh',1,219667658155164); INSERT INTO moz_places VALUES(36,'file:///.startpage/22/index.html',NULL,'.',0,0,0,NULL,64,NULL,'n12ZqgkI0xgv',1,219669359046484); INSERT INTO moz_places VALUES(37,'file:///.startpage/23/index.html',NULL,'.',0,0,0,NULL,64,NULL,'dV1nUAJuxYhJ',1,219667663302713); INSERT INTO moz_places VALUES(38,'file:///.startpage/24/index.html',NULL,'.',0,0,0,NULL,64,NULL,'bHFeAKm2VoxV',1,219668865743796); INSERT INTO moz_places VALUES(39,'file:///.startpage/25/index.html',NULL,'.',0,0,0,NULL,64,NULL,'ZlveTgWYOHyl',1,219666516621298); INSERT INTO moz_places VALUES(40,'file:///.startpage/26/index.html',NULL,'.',0,0,0,NULL,64,NULL,'LT0TSp-aRcGW',1,219669783839253); INSERT INTO moz_places VALUES(41,'file:///.startpage/27/index.html',NULL,'.',0,0,0,NULL,64,NULL,'3L5m6XPVjUwh',1,219667796938995); INSERT INTO moz_places VALUES(42,'apt://flashplugin-installer',NULL,NULL,0,0,0,NULL,64,NULL,'wk7Q00E1AJtJ',1,130688315308473); INSERT INTO moz_places VALUES(43,'https://addons.mozilla.org/fr/firefox/addon/adblock-plus/',NULL,'gro.allizom.snodda.',0,0,0,NULL,64,NULL,'BQTNVCrOi6cy',2,47359114341489); INSERT INTO moz_places VALUES(44,'https://addons.mozilla.org/fr/firefox/addon/searchpreview/','SearchPreview :: Modules pour Firefox','gro.allizom.snodda.',1,0,0,32,120,1500352310710639,'ISDOqrYxP-o2',1,47359086811128); INSERT INTO moz_places VALUES(45,'https://addons.mozilla.org/fr/firefox/language-tools/','Dictionnaires et paquetages linguistiques :: Modules pour Firefox','gro.allizom.snodda.',1,0,0,32,135,1502469203205795,'ejTA_lnpIVGp',3,47356529039849); INSERT INTO moz_places VALUES(86,'place:type=3&sort=4',NULL,NULL,0,1,0,NULL,0,NULL,'0P7W4IkZ9d-x',1,268506321168863); INSERT INTO moz_places VALUES(87,'place:transition=7&sort=4',NULL,NULL,0,1,0,NULL,0,NULL,'pfR1SwF8WV5b',1,268507063987631); INSERT INTO moz_places VALUES(88,'place:type=6&sort=1',NULL,NULL,0,1,0,NULL,0,NULL,'4bHN8NzrU69O',1,268504878649678); INSERT INTO moz_places VALUES(89,'place:folder=TOOLBAR',NULL,NULL,0,1,0,NULL,0,NULL,'-UNrGlY2qHS-',1,268507032739381); INSERT INTO moz_places VALUES(90,'place:folder=BOOKMARKS_MENU',NULL,NULL,0,1,0,NULL,0,NULL,'_ZtxELNAGorr',1,268504983346218); INSERT INTO moz_places VALUES(91,'place:folder=UNFILED_BOOKMARKS',NULL,NULL,0,1,0,NULL,0,NULL,'28nTBc-Glbdq',1,268507911875319); INSERT INTO moz_places VALUES(1896,'https://www.google.co.in/','Google','ni.oc.elgoog.www.',2,0,1,11,2698,1504975524486254,'j_p-Y6RhkHko',1,47357009725544); CREATE TABLE moz_historyvisits ( id INTEGER PRIMARY KEY, from_visit INTEGER, place_id INTEGER, visit_date INTEGER, visit_type INTEGER, session INTEGER); CREATE TABLE moz_inputhistory ( place_id INTEGER NOT NULL, input LONGVARCHAR NOT NULL, use_count INTEGER, PRIMARY KEY (place_id, input)); CREATE TABLE moz_hosts ( id INTEGER PRIMARY KEY, host TEXT NOT NULL UNIQUE, frecency INTEGER, typed INTEGER NOT NULL DEFAULT 0, prefix TEXT); CREATE TABLE moz_bookmarks ( id INTEGER PRIMARY KEY, type INTEGER, fk INTEGER DEFAULT NULL, parent INTEGER, position INTEGER, title LONGVARCHAR, keyword_id INTEGER, folder_type TEXT, dateAdded INTEGER, lastModified INTEGER, guid TEXT, syncStatus INTEGER DEFAULT 0 NOT NULL, syncChangeCounter INTEGER DEFAULT 1 NOT NULL); INSERT INTO moz_bookmarks VALUES(1,2,NULL,0,0,'',NULL,NULL,1467725517327000,1470428315627000,'root________',0,1); INSERT INTO moz_bookmarks VALUES(2,2,NULL,1,0,'Bookmarks Menu',NULL,NULL,1467725517327000,1504975536882000,'menu________',0,4); INSERT INTO moz_bookmarks VALUES(3,2,NULL,1,1,'Bookmarks Toolbar',NULL,NULL,1467725517327000,1470428315492000,'toolbar_____',0,1); INSERT INTO moz_bookmarks VALUES(4,2,NULL,1,2,'Tags',NULL,NULL,1467725517327000,1502516392069000,'tags________',0,128); INSERT INTO moz_bookmarks VALUES(6,1,1,2,0,'Marqués récemment',NULL,NULL,1440206436707000,1470428315238000,'ePlbveDPxcVC',0,1); INSERT INTO moz_bookmarks VALUES(9,2,NULL,2,3,'Ubuntu, and Free Software links',NULL,NULL,1440206436614000,1467725517796000,'IIOoE1AVQALy',0,1); INSERT INTO moz_bookmarks VALUES(10,1,3,9,0,'Ubuntu',NULL,NULL,1440206436615000,1440206436616000,'BdTJgxwzX-bY',0,1); INSERT INTO moz_bookmarks VALUES(11,1,4,9,1,'Ubuntu Wiki (community-edited website)',NULL,NULL,1440206436617000,1440206436618000,'Q3CHtiX8BAQm',0,1); INSERT INTO moz_bookmarks VALUES(12,1,5,9,2,'Make a Support Request to the Ubuntu Community',NULL,NULL,1440206436618000,1440206436619000,'abuWmpFRfxHw',0,1); INSERT INTO moz_bookmarks VALUES(13,1,6,9,3,'Debian (Ubuntu is based on Debian)',NULL,NULL,1440206436619000,1440206436621000,'09Wm3713Bb1z',0,1); INSERT INTO moz_bookmarks VALUES(14,1,7,9,4,'Ubuntu One - The personal cloud that brings your digital life together',NULL,NULL,1440206436622000,1440206436622000,'BkV2PLxgT2hL',0,1); INSERT INTO moz_bookmarks VALUES(15,2,NULL,2,4,'Mozilla Firefox',NULL,NULL,1440206436622000,1440206436627000,'4TFNf2K_7e1a',0,1); INSERT INTO moz_bookmarks VALUES(16,1,8,15,0,'Help and Tutorials',NULL,NULL,1440206436623000,1440206436624000,'vC7MQXkMREQV',0,1); INSERT INTO moz_bookmarks VALUES(17,1,9,15,1,'Customize Firefox',NULL,NULL,1440206436624000,1440206436625000,'hNcuVhGLSBMT',0,1); INSERT INTO moz_bookmarks VALUES(18,1,10,15,2,'Get Involved',NULL,NULL,1440206436625000,1440206436626000,'ChmDGJ8Nh2Rj',0,1); INSERT INTO moz_bookmarks VALUES(19,1,11,15,3,'About Us',NULL,NULL,1440206436627000,1440206436627000,'4KMQLdKoA5ME',0,1); INSERT INTO moz_bookmarks VALUES(21,1,13,3,1,'Voyager',NULL,NULL,1440206499903000,1440206519634000,'GOB7TZA0gCGU',0,1); INSERT INTO moz_bookmarks VALUES(23,1,14,22,0,'0',NULL,NULL,1440206567367000,1440206589837000,'e_UDXJIzf2zR',0,1); INSERT INTO moz_bookmarks VALUES(24,1,15,22,1,'1',NULL,NULL,1440206671321000,1440206683533000,'2hZfPJd4TtJn',0,1); INSERT INTO moz_bookmarks VALUES(25,1,16,22,2,'2',NULL,NULL,1440206671321000,1440206770420000,'W2p3TBdOtDvH',0,1); INSERT INTO moz_bookmarks VALUES(26,1,17,22,3,'3',NULL,NULL,1440206671321000,1440206784296000,'qZYep98FnR__',0,1); INSERT INTO moz_bookmarks VALUES(27,1,18,22,4,'4',NULL,NULL,1440206671321000,1440206793116000,'zZu4UGpXYow3',0,1); INSERT INTO moz_bookmarks VALUES(28,1,19,22,5,'5',NULL,NULL,1440206671321000,1440206801937000,'VXZ3mBzzcA4Q',0,1); INSERT INTO moz_bookmarks VALUES(29,1,20,22,6,'6',NULL,NULL,1440206671321000,1440206810195000,'a0RFsnB3EwVc',0,1); INSERT INTO moz_bookmarks VALUES(30,1,21,22,7,'7',NULL,NULL,1440206671321000,1440206817630000,'6Biu707lOdHg',0,1); INSERT INTO moz_bookmarks VALUES(31,1,22,22,8,'8',NULL,NULL,1440206671321000,1440206825212000,'AP1mXZa0ahQ4',0,1); INSERT INTO moz_bookmarks VALUES(32,1,23,22,9,'9',NULL,NULL,1440206671321000,1440206832966000,'WxjKjeWFlrMP',0,1); INSERT INTO moz_bookmarks VALUES(33,1,24,22,10,'10',NULL,NULL,1440206671321000,1440206844808000,'-QeCAla6-KY8',0,1); INSERT INTO moz_bookmarks VALUES(34,1,25,22,11,'11',NULL,NULL,1440206671321000,1440206849594000,'ZN_9hCgM4lf0',0,1); INSERT INTO moz_bookmarks VALUES(35,1,26,22,12,'12',NULL,NULL,1440206671321000,1440206856076000,'oWFTNWyZ6gMq',0,1); INSERT INTO moz_bookmarks VALUES(36,1,27,22,13,'13',NULL,NULL,1440206671321000,1440206867754000,'-lcyQ56ww8NA',0,1); INSERT INTO moz_bookmarks VALUES(37,1,28,22,14,'14',NULL,NULL,1440206671321000,1440206910367000,'w43SFrygDRwR',0,1); INSERT INTO moz_bookmarks VALUES(38,1,29,22,15,'15',NULL,NULL,1440206671321000,1440206917428000,'8Q-1muzTEeWE',0,1); INSERT INTO moz_bookmarks VALUES(39,1,30,22,16,'16',NULL,NULL,1440206671321000,1440206928910000,'Ghx1rkNDlp4H',0,1); INSERT INTO moz_bookmarks VALUES(40,1,31,22,17,'17',NULL,NULL,1440206671321000,1440206935983000,'d2AJXtLXpkFb',0,1); INSERT INTO moz_bookmarks VALUES(41,1,32,22,18,'18',NULL,NULL,1440206671321000,1440206942585000,'MMo9b33f9xyE',0,1); INSERT INTO moz_bookmarks VALUES(42,1,33,22,19,'19',NULL,NULL,1440206671321000,1440206950138000,'K7socCLnYy4L',0,1); INSERT INTO moz_bookmarks VALUES(43,1,34,22,20,'20',NULL,NULL,1440206671321000,1440206957207000,'5V11irxjjd38',0,1); INSERT INTO moz_bookmarks VALUES(44,1,35,22,21,'21',NULL,NULL,1440206671321000,1440206964605000,'-oxnXIHTe0Qa',0,1); INSERT INTO moz_bookmarks VALUES(45,1,36,22,22,'22',NULL,NULL,1440206671321000,1440206971117000,'EKZ1Rmek4lTO',0,1); INSERT INTO moz_bookmarks VALUES(46,1,37,22,23,'23',NULL,NULL,1440206671321000,1440206978351000,'gpzcG4LjZ2Re',0,1); INSERT INTO moz_bookmarks VALUES(47,1,38,22,24,'24',NULL,NULL,1440206671321000,1440737505375000,'PvDZnqwbwj4u',0,1); INSERT INTO moz_bookmarks VALUES(48,1,39,22,25,'25',NULL,NULL,1440206671321000,1440737513706000,'BuKY3E03NnmF',0,1); INSERT INTO moz_bookmarks VALUES(49,1,40,22,26,'26',NULL,NULL,1440206671321000,1440737524663000,'lf6NCbNyKMnX',0,1); INSERT INTO moz_bookmarks VALUES(50,1,41,22,27,'27',NULL,NULL,1440206671321000,1440737540732000,'StBEBdR-nxjO',0,1); INSERT INTO moz_bookmarks VALUES(51,2,NULL,3,3,'F+',NULL,NULL,1440207026112000,1440207159607000,'njpFXZMf3dGr',0,1); INSERT INTO moz_bookmarks VALUES(52,1,42,51,0,'Flash Install',NULL,NULL,1440207037033000,1440207058958000,'FSJNRAVVHZw2',0,1); INSERT INTO moz_bookmarks VALUES(53,1,43,51,1,'Adblock Plus',NULL,NULL,1440207069038000,1440207082178000,'IrUa9S-JhU_c',0,1); INSERT INTO moz_bookmarks VALUES(54,1,44,51,2,'SearchPreview',NULL,NULL,1440207088169000,1500352294532000,'yS24nxSktibH',0,2); INSERT INTO moz_bookmarks VALUES(55,1,45,51,3,'Language Tools',NULL,NULL,1440207113748000,1440207156708000,'Wp2tuVDbc5tZ',0,2); INSERT INTO moz_bookmarks VALUES(57,1,86,56,0,'History',NULL,NULL,1468512203179000,1468512203180000,'XNGjCIZLlYX7',0,1); INSERT INTO moz_bookmarks VALUES(58,1,87,56,1,'Downloads',NULL,NULL,1468512203180000,1468512203181000,'C4MPThOkxXJp',0,1); INSERT INTO moz_bookmarks VALUES(59,1,88,56,2,'Tags',NULL,NULL,1468512203181000,1468512203182000,'gpxshKA-aBjG',0,1); INSERT INTO moz_bookmarks VALUES(61,1,89,60,0,NULL,NULL,NULL,1468512203184000,1468512203185000,'NrSbqdjDsUqF',0,1); INSERT INTO moz_bookmarks VALUES(62,1,90,60,1,NULL,NULL,NULL,1468512203185000,1468512203186000,'BaYiLZ99gJSr',0,1); INSERT INTO moz_bookmarks VALUES(63,1,91,60,2,NULL,NULL,NULL,1468512203186000,1468512203187000,'w-U2zBo1eOHU',0,1); INSERT INTO moz_bookmarks VALUES(64,1,12,3,0,'Most Visited',NULL,NULL,1470428315492000,1470428315588000,'RtkxwHiU0MDn',0,1); INSERT INTO moz_bookmarks VALUES(65,1,2,2,1,'Recent Tags',NULL,NULL,1470428315627000,1470428315690000,'fLDmWrAmGNWW',0,1); INSERT INTO moz_bookmarks VALUES(67,2,NULL,4,0,'language',NULL,NULL,1491534915350000,1491534938078000,'4bL9ZoFmnBLt',0,1); INSERT INTO moz_bookmarks VALUES(68,1,45,67,0,NULL,NULL,NULL,1491534915363000,1491534915363000,'49l3q4-FiBp0',0,2); INSERT INTO moz_bookmarks VALUES(69,1,43,67,1,NULL,NULL,NULL,1491534938078000,1491534938078000,'d8z-e3rN3r4T',0,1); INSERT INTO moz_bookmarks VALUES(70,2,NULL,4,1,'hello',NULL,NULL,1502469210372000,1502469210376000,'TpKnUAtkhxK6',1,2); INSERT INTO moz_bookmarks VALUES(71,1,45,70,0,NULL,NULL,NULL,1502469210376000,1502469210376000,'2Qo36cSCjizq',1,2); INSERT INTO moz_bookmarks VALUES(72,1,1896,2,5,'',NULL,NULL,1504975536882000,1504976368646000,'uWFrltEh9OTA',1,4); CREATE TABLE moz_keywords ( id INTEGER PRIMARY KEY AUTOINCREMENT, keyword TEXT UNIQUE, place_id INTEGER, post_data TEXT); CREATE TABLE moz_favicons ( id INTEGER PRIMARY KEY, url LONGVARCHAR UNIQUE, data BLOB, mime_type VARCHAR(32), expiration LONG); CREATE TABLE moz_anno_attributes ( id INTEGER PRIMARY KEY, name VARCHAR(32) UNIQUE NOT NULL); CREATE TABLE moz_annos ( id INTEGER PRIMARY KEY, place_id INTEGER NOT NULL, anno_attribute_id INTEGER, mime_type VARCHAR(32) DEFAULT NULL, content LONGVARCHAR, flags INTEGER DEFAULT 0, expiration INTEGER DEFAULT 0, type INTEGER DEFAULT 0, dateAdded INTEGER DEFAULT 0, lastModified INTEGER DEFAULT 0); CREATE TABLE moz_items_annos ( id INTEGER PRIMARY KEY, item_id INTEGER NOT NULL, anno_attribute_id INTEGER, mime_type VARCHAR(32) DEFAULT NULL, content LONGVARCHAR, flags INTEGER DEFAULT 0, expiration INTEGER DEFAULT 0, type INTEGER DEFAULT 0, dateAdded INTEGER DEFAULT 0, lastModified INTEGER DEFAULT 0); ANALYZE sqlite_schema; INSERT INTO sqlite_stat1 VALUES('moz_places','moz_places_guid_uniqueindex','1535 1'); INSERT INTO sqlite_stat1 VALUES('moz_places','moz_places_url_hashindex','1535 1'); INSERT INTO sqlite_stat1 VALUES('moz_places','moz_places_lastvisitdateindex','1535 2'); INSERT INTO sqlite_stat1 VALUES('moz_places','moz_places_frecencyindex','1535 9'); INSERT INTO sqlite_stat1 VALUES('moz_places','moz_places_visitcount','1535 86'); INSERT INTO sqlite_stat1 VALUES('moz_places','moz_places_hostindex','1535 8'); INSERT INTO sqlite_stat1 VALUES('moz_places','moz_places_faviconindex','1535 12'); INSERT INTO sqlite_stat1 VALUES('moz_bookmarks','moz_bookmarks_guid_uniqueindex','69 1'); INSERT INTO sqlite_stat1 VALUES('moz_bookmarks','moz_bookmarks_itemlastmodifiedindex','69 2 1'); INSERT INTO sqlite_stat1 VALUES('moz_bookmarks','moz_bookmarks_parentindex','69 6 1'); INSERT INTO sqlite_stat1 VALUES('moz_bookmarks','moz_bookmarks_itemindex','69 2 2'); INSERT INTO sqlite_stat1 VALUES('moz_historyvisits','moz_historyvisits_dateindex','2151 1'); INSERT INTO sqlite_stat1 VALUES('moz_historyvisits','moz_historyvisits_fromindex','2151 3'); INSERT INTO sqlite_stat1 VALUES('moz_historyvisits','moz_historyvisits_placedateindex','2151 2 1'); INSERT INTO sqlite_stat1 VALUES('moz_inputhistory','sqlite_autoindex_moz_inputhistory_1','36 2 1'); CREATE TABLE moz_bookmarks_deleted ( guid TEXT PRIMARY KEY, dateRemoved INTEGER NOT NULL DEFAULT 0); DELETE FROM sqlite_sequence; CREATE INDEX moz_places_faviconindex ON moz_places (favicon_id); CREATE INDEX moz_places_hostindex ON moz_places (rev_host); CREATE INDEX moz_places_visitcount ON moz_places (visit_count); CREATE INDEX moz_places_frecencyindex ON moz_places (frecency); CREATE INDEX moz_places_lastvisitdateindex ON moz_places (last_visit_date); CREATE INDEX moz_historyvisits_placedateindex ON moz_historyvisits (place_id, visit_date); CREATE INDEX moz_historyvisits_fromindex ON moz_historyvisits (from_visit); CREATE INDEX moz_historyvisits_dateindex ON moz_historyvisits (visit_date); CREATE INDEX moz_bookmarks_itemindex ON moz_bookmarks (fk, type); CREATE INDEX moz_bookmarks_parentindex ON moz_bookmarks (parent, position); CREATE INDEX moz_bookmarks_itemlastmodifiedindex ON moz_bookmarks (fk, lastModified); CREATE INDEX moz_places_url_hashindex ON moz_places (url_hash); CREATE UNIQUE INDEX moz_places_guid_uniqueindex ON moz_places (guid); CREATE UNIQUE INDEX moz_bookmarks_guid_uniqueindex ON moz_bookmarks (guid); CREATE UNIQUE INDEX moz_keywords_placepostdata_uniqueindex ON moz_keywords (place_id, post_data); CREATE UNIQUE INDEX moz_annos_placeattributeindex ON moz_annos (place_id, anno_attribute_id); CREATE UNIQUE INDEX moz_items_annos_itemattributeindex ON moz_items_annos (item_id, anno_attribute_id); COMMIT; ================================================ FILE: tests/test_bukuDb.py ================================================ #!/usr/bin/env python3 # # Unit test cases for buku # import math import os import re import sqlite3 import sys import unittest from genericpath import exists from tempfile import NamedTemporaryFile, TemporaryDirectory from random import shuffle from unittest import mock import pytest import yaml from hypothesis import example, given, settings from hypothesis import strategies as st from buku import PERMANENT_REDIRECTS, BukuDb, FetchResult, BookmarkVar, bookmark_vars, parse_tags, prompt from tests.util import mock_fetch, _add_rec, _tagset def get_temp_dir_path(): with TemporaryDirectory(prefix="bukutest_") as dir_obj: return dir_obj TEST_TEMP_DIR_PATH = get_temp_dir_path() TEST_TEMP_DBDIR_PATH = os.path.join(TEST_TEMP_DIR_PATH, "buku") TEST_TEMP_DBFILE_PATH = os.path.join(TEST_TEMP_DBDIR_PATH, "bookmarks.db") MAX_SQLITE_INT = int(math.pow(2, 63) - 1) TEST_PRINT_REC = ("https://example.com", "", parse_tags(["cat,ant,bee,1"]), "") TEST_BOOKMARKS = [ [ "http://slashdot.org", "SLASHDOT", parse_tags(["old,news"]), "News for old nerds, stuff that doesn't matter", ], [ "http://www.zażółćgęśląjaźń.pl/", "ZAŻÓŁĆ", parse_tags(["zażółć,gęślą,jaźń"]), "Testing UTF-8, zażółć gęślą jaźń.", ], [ "http://example.com/", "test", parse_tags(["test,tes,est,es"]), "a case for replace_tag test", ], ] only_python_3_5 = pytest.mark.skipif( sys.version_info < (3, 5), reason="requires Python 3.5 or later" ) @pytest.fixture(scope="module") def vcr_cassette_dir(request): # Put all cassettes in vhs/{module}/{test}.yaml return os.path.join("tests", "vcr_cassettes", request.module.__name__) def rmdb(*bdbs): for bdb in bdbs: bdb.close() if exists(TEST_TEMP_DBFILE_PATH): os.remove(TEST_TEMP_DBFILE_PATH) @pytest.fixture() def bukuDb(): os.environ["XDG_DATA_HOME"] = TEST_TEMP_DIR_PATH # start every test from a clean state rmdb() bdbs = [] def _bukuDb(*args, **kwargs): nonlocal bdbs bdbs += [BukuDb(*args, **kwargs)] return bdbs[-1] yield _bukuDb rmdb(*bdbs) class PrettySafeLoader( yaml.SafeLoader ): # pylint: disable=too-many-ancestors,too-few-public-methods def construct_python_tuple(self, node): return tuple(self.construct_sequence(node)) PrettySafeLoader.add_constructor( "tag:yaml.org,2002:python/tuple", PrettySafeLoader.construct_python_tuple ) class TestBukuDb(unittest.TestCase): def setUp(self): os.environ["XDG_DATA_HOME"] = TEST_TEMP_DIR_PATH # start every test from a clean state rmdb() self.bookmarks = TEST_BOOKMARKS self.bdb = BukuDb() def tearDown(self): os.environ["XDG_DATA_HOME"] = TEST_TEMP_DIR_PATH rmdb(self.bdb) @pytest.mark.non_tox def test_get_default_dbdir(self): dbdir_expected = TEST_TEMP_DBDIR_PATH home = os.path.expanduser("~") dbdir_local_expected = (os.path.join(home, ".local", "share", "buku") if sys.platform != 'win32' else os.path.join(home, "AppData", "Roaming", "buku")) dbdir_relative_expected = os.path.abspath(".") # desktop linux self.assertEqual(dbdir_expected, BukuDb.get_default_dbdir()) # desktop generic os.environ.pop("XDG_DATA_HOME") self.assertEqual(dbdir_local_expected, BukuDb.get_default_dbdir()) # no desktop # -- home is defined differently on various platforms. # -- keep a copy and set it back once done originals = {} for env_var in ["HOME", "HOMEPATH", "HOMEDIR", "APPDATA"]: if env_var in os.environ: originals[env_var] = os.environ.pop(env_var) try: self.assertEqual(dbdir_relative_expected, BukuDb.get_default_dbdir()) finally: os.environ.update(originals) # # not sure how to test this in nondestructive manner # def test_move_legacy_dbfile(self): # self.fail() def test_initdb(self): rmdb(self.bdb) self.assertIs(False, exists(TEST_TEMP_DBFILE_PATH)) try: conn, curr = BukuDb.initdb() self.assertIsInstance(conn, sqlite3.Connection) self.assertIsInstance(curr, sqlite3.Cursor) self.assertIs(True, exists(TEST_TEMP_DBFILE_PATH)) finally: curr.close() conn.close() def test_get_rec_by_id(self): for bookmark in self.bookmarks: # adding bookmark from self.bookmarks _add_rec(self.bdb, *bookmark) # the expected bookmark expected = (1,) + tuple(TEST_BOOKMARKS[0]) + (0,) bookmark_from_db = self.bdb.get_rec_by_id(1) # asserting bookmark matches expected self.assertEqual(expected, bookmark_from_db) # asserting None returned if index out of range self.assertIsNone(self.bdb.get_rec_by_id(len(self.bookmarks[0]) + 1)) def test_get_rec_all_by_ids(self): for bookmark in self.bookmarks: # adding bookmark from self.bookmarks _add_rec(self.bdb, *bookmark) expected = [(i+1,) + tuple(TEST_BOOKMARKS[i]) + (0,) for i in [0, 2]] bookmarks_from_db = self.bdb.get_rec_all_by_ids([3, 1, 1, 3, 5]) # ignoring order and duplicates self.assertEqual(expected, bookmarks_from_db) def test_get_rec_id(self): for idx, bookmark in enumerate(self.bookmarks): # adding bookmark from self.bookmarks to database _add_rec(self.bdb, *bookmark) # asserting index is in order idx_from_db = self.bdb.get_rec_id(bookmark[0]) self.assertEqual(idx + 1, idx_from_db) # asserting None is returned for nonexistent url idx_from_db = self.bdb.get_rec_id("http://nonexistent.url") self.assertIsNone(idx_from_db) def test_add_rec(self): for bookmark in self.bookmarks: # adding bookmark from self.bookmarks to database self.bdb.add_rec(*bookmark, fetch=False) # retrieving bookmark from database index = self.bdb.get_rec_id(bookmark[0]) from_db = self.bdb.get_rec_by_id(index) self.assertIsNotNone(from_db) # comparing data for pair in zip(from_db[1:], bookmark): self.assertEqual(*pair) def test_swap_recs(self): for bookmark in self.bookmarks: _add_rec(self.bdb, *bookmark) for id1, id2 in [(0, 1), (1, 4), (1, 1)]: self.assertFalse(self.bdb.swap_recs(id1, id2), 'Not a valid index pair: (%d, %d)' % (id1, id2)) self.assertTrue(self.bdb.swap_recs(1, 3), 'This one should be valid') # 3, 2, 1 self.assertEqual([x[0] for x in reversed(self.bookmarks)], [x.url for x in self.bdb.get_rec_all()]) # TODO: tags should be passed to the api as a sequence... def test_suggest_tags(self): for bookmark in self.bookmarks: _add_rec(self.bdb, *bookmark) tagstr = ",test,old," with mock.patch("builtins.input", return_value="1 2 3"): expected_results = ",es,est,news,old,test," suggested_results = self.bdb.suggest_similar_tag(tagstr) self.assertEqual(expected_results, suggested_results) # returns user supplied tags if none are in the DB tagstr = ",uniquetag1,uniquetag2," expected_results = tagstr suggested_results = self.bdb.suggest_similar_tag(tagstr) self.assertEqual(expected_results, suggested_results) def test_update_rec(self): old_values = self.bookmarks[0] new_values = self.bookmarks[1] # adding bookmark and getting index _add_rec(self.bdb, *old_values) index = self.bdb.get_rec_id(old_values[0]) # updating with new values self.bdb.update_rec(index, *new_values) # retrieving bookmark from database from_db = self.bdb.get_rec_by_id(index) self.assertIsNotNone(from_db) # checking if values are updated for pair in zip(from_db[1:], new_values): self.assertEqual(*pair) def test_append_tag_at_index(self): for bookmark in self.bookmarks: _add_rec(self.bdb, *bookmark) # tags to add old_tags = self.bdb.get_rec_by_id(1)[3] new_tags = ",foo,bar,baz" self.bdb.append_tag_at_index(1, new_tags) # updated list of tags from_db = self.bdb.get_rec_by_id(1)[3] # checking if new tags were added to the bookmark self.assertTrue(split_and_test_membership(new_tags, from_db)) # checking if old tags still exist self.assertTrue(split_and_test_membership(old_tags, from_db)) def test_append_tag_at_all_indices(self): for bookmark in self.bookmarks: _add_rec(self.bdb, *bookmark) # tags to add new_tags = ",foo,bar,baz" # record of original tags for each bookmark old_tagsets = { i: self.bdb.get_rec_by_id(i)[3] for i in inclusive_range(1, len(self.bookmarks)) } with mock.patch("builtins.input", return_value="y"): self.bdb.append_tag_at_index(0, new_tags) # updated tags for each bookmark from_db = [ (i, self.bdb.get_rec_by_id(i)[3]) for i in inclusive_range(1, len(self.bookmarks)) ] for index, tagset in from_db: # checking if new tags added to bookmark self.assertTrue(split_and_test_membership(new_tags, tagset)) # checking if old tags still exist for bookmark self.assertTrue(split_and_test_membership(old_tagsets[index], tagset)) def test_delete_tag_at_index(self): # adding bookmarks for bookmark in self.bookmarks: _add_rec(self.bdb, *bookmark) get_tags_at_idx = lambda i: self.bdb.get_rec_by_id(i)[3] # list of two-tuples, each containing bookmark index and corresponding tags tags_by_index = [ (i, get_tags_at_idx(i)) for i in inclusive_range(1, len(self.bookmarks)) ] for i, tags in tags_by_index: # get the first tag from the bookmark to_delete = re.match(",.*?,", tags).group(0) self.bdb.delete_tag_at_index(i, to_delete) # get updated tags from db from_db = get_tags_at_idx(i) self.assertNotIn(to_delete, from_db) def test_search_keywords_and_filter_by_tags(self): # adding bookmark for bookmark in self.bookmarks: _add_rec(self.bdb, *bookmark) with mock.patch("buku.prompt"): expected = [ ( 3, "http://example.com/", "test", ",es,est,tes,test,", "a case for replace_tag test", 0, ) ] results = self.bdb.search_keywords_and_filter_by_tags( ["News", "case"], False, False, False, ["est"], ) self.assertIn(expected[0], results) expected = [ ( 3, "http://example.com/", "test", ",es,est,tes,test,", "a case for replace_tag test", 0, ), ( 2, "http://www.zażółćgęśląjaźń.pl/", "ZAŻÓŁĆ", ",gęślą,jaźń,zażółć,", "Testing UTF-8, zażółć gęślą jaźń.", 0, ), ] results = self.bdb.search_keywords_and_filter_by_tags( ["UTF-8", "case"], False, False, False, "jaźń, test", ) self.assertIn(expected[0], results) self.assertIn(expected[1], results) def test_searchdb(self): # adding bookmarks for bookmark in self.bookmarks: _add_rec(self.bdb, *bookmark) get_first_tag = lambda x: "".join(x[2].split(",")[:2]) for i, bookmark in enumerate(self.bookmarks): tag_search = get_first_tag(bookmark) # search by the domain name for url url_search = re.match(r"https?://(.*)?\..*", bookmark[0]).group(1) title_search = bookmark[1] # Expect a five-tuple containing all bookmark data # db index, URL, title, tags, description expected = [(i + 1,) + tuple(bookmark)] expected[0] += tuple([0]) # search db by tag, url (domain name), and title for keyword in (tag_search, url_search, title_search): with mock.patch("buku.prompt"): # search by keyword results = self.bdb.searchdb([keyword]) self.assertEqual(results, expected) def test_search_by_tag(self): # adding bookmarks for bookmark in self.bookmarks: _add_rec(self.bdb, *bookmark) with mock.patch("buku.prompt"): get_first_tag = lambda x: "".join(x[2].split(",")[:2]) for i, bookmark in enumerate(self.bookmarks): # search for bookmark with a tag that is known to exist results = self.bdb.search_by_tag(get_first_tag(bookmark)) # Expect a five-tuple containing all bookmark data # db index, URL, title, tags, description expected = [(i + 1,) + tuple(bookmark)] expected[0] += tuple([0]) self.assertEqual(results, expected) @pytest.mark.slow @pytest.mark.vcr("tests/vcr_cassettes/test_search_by_multiple_tags_search_any.yaml") def test_search_by_multiple_tags_search_any(self): # adding bookmarks for bookmark in self.bookmarks: self.bdb.add_rec(*bookmark) new_bookmark = [ "https://newbookmark.com", "New Bookmark", parse_tags(["test,old,new"]), "additional bookmark to test multiple tag search", 0, ] self.bdb.add_rec(*new_bookmark) with mock.patch("buku.prompt"): # search for bookmarks matching ANY of the supplied tags results = self.bdb.search_by_tag("test, old") # Expect a list of five-element tuples containing all bookmark data # db index, URL, title, tags, description, ordered by records with # the most number of matches. expected = [ ( 4, "https://newbookmark.com", "New Bookmark", parse_tags([",test,old,new,"]), "additional bookmark to test multiple tag search", 0, ), ( 1, "http://slashdot.org", "SLASHDOT", parse_tags([",news,old,"]), "News for old nerds, stuff that doesn't matter", 0, ), ( 3, "http://example.com/", "test", ",es,est,tes,test,", "a case for replace_tag test", 0, ), ] self.assertEqual(results, expected) @pytest.mark.slow @pytest.mark.vcr("tests/vcr_cassettes/test_search_by_multiple_tags_search_all.yaml") def test_search_by_multiple_tags_search_all(self): # adding bookmarks for bookmark in self.bookmarks: self.bdb.add_rec(*bookmark) new_bookmark = [ "https://newbookmark.com", "New Bookmark", parse_tags(["test,old,new"]), "additional bookmark to test multiple tag search", ] self.bdb.add_rec(*new_bookmark) with mock.patch("buku.prompt"): # search for bookmarks matching ALL of the supplied tags results = self.bdb.search_by_tag("test + old") # Expect a list of five-element tuples containing all bookmark data # db index, URL, title, tags, description expected = [ ( 4, "https://newbookmark.com", "New Bookmark", parse_tags([",test,old,new,"]), "additional bookmark to test multiple tag search", 0, ) ] self.assertEqual(results, expected) def test_search_by_tags_enforces_space_seprations_search_all(self): bookmark1 = [ "https://bookmark1.com", "Bookmark One", parse_tags(["tag, two,tag+two"]), "test case for bookmark with '+' in tag", ] bookmark2 = [ "https://bookmark2.com", "Bookmark Two", parse_tags(["tag,two, tag-two"]), "test case for bookmark with hyphenated tag", ] _add_rec(self.bdb, *bookmark1) _add_rec(self.bdb, *bookmark2) with mock.patch("buku.prompt"): # check that space separation for ' + ' operator is enforced results = self.bdb.search_by_tag("tag+two") # Expect a list of five-element tuples containing all bookmark data # db index, URL, title, tags, description expected = [ ( 1, "https://bookmark1.com", "Bookmark One", parse_tags([",tag,two,tag+two,"]), "test case for bookmark with '+' in tag", 0, ) ] self.assertEqual(results, expected) results = self.bdb.search_by_tag("tag + two") # Expect a list of five-element tuples containing all bookmark data # db index, URL, title, tags, description expected = [ ( 1, "https://bookmark1.com", "Bookmark One", parse_tags([",tag,two,tag+two,"]), "test case for bookmark with '+' in tag", 0, ), ( 2, "https://bookmark2.com", "Bookmark Two", parse_tags([",tag,two,tag-two,"]), "test case for bookmark with hyphenated tag", 0, ), ] self.assertEqual(results, expected) def test_search_by_tags_exclusion(self): # adding bookmarks for bookmark in self.bookmarks: _add_rec(self.bdb, *bookmark) new_bookmark = [ "https://newbookmark.com", "New Bookmark", parse_tags(["test,old,new"]), "additional bookmark to test multiple tag search", ] _add_rec(self.bdb, *new_bookmark) with mock.patch("buku.prompt"): # search for bookmarks matching ANY of the supplied tags # while excluding bookmarks from results that match a given tag results = self.bdb.search_by_tag("test, old - est") # Expect a list of five-element tuples containing all bookmark data # db index, URL, title, tags, description expected = [ ( 4, "https://newbookmark.com", "New Bookmark", parse_tags([",test,old,new,"]), "additional bookmark to test multiple tag search", 0, ), ( 1, "http://slashdot.org", "SLASHDOT", parse_tags([",news,old,"]), "News for old nerds, stuff that doesn't matter", 0, ), ] self.assertEqual(results, expected) @pytest.mark.vcr("tests/vcr_cassettes/test_search_by_tags_enforces_space_seprations_exclusion.yaml") def test_search_by_tags_enforces_space_seprations_exclusion(self): bookmark1 = [ "https://bookmark1.com", "Bookmark One", parse_tags(["tag, two,tag+two"]), "test case for bookmark with '+' in tag", ] bookmark2 = [ "https://bookmark2.com", "Bookmark Two", parse_tags(["tag,two, tag-two"]), "test case for bookmark with hyphenated tag", ] bookmark3 = [ "https://bookmark3.com", "Bookmark Three", parse_tags(["tag, tag three"]), "second test case for bookmark with hyphenated tag", ] self.bdb.add_rec(*bookmark1) self.bdb.add_rec(*bookmark2) self.bdb.add_rec(*bookmark3) with mock.patch("buku.prompt"): # check that space separation for ' - ' operator is enforced results = self.bdb.search_by_tag("tag-two") # Expect a list of five-element tuples containing all bookmark data # db index, URL, title, tags, description expected = [ ( 2, "https://bookmark2.com", "Bookmark Two", parse_tags([",tag,two,tag-two,"]), "test case for bookmark with hyphenated tag", 0, ), ] self.assertEqual(results, expected) results = self.bdb.search_by_tag("tag - two") # Expect a list of five-element tuples containing all bookmark data # db index, URL, title, tags, description expected = [ ( 3, "https://bookmark3.com", "Bookmark Three", parse_tags([",tag,tag three,"]), "second test case for bookmark with hyphenated tag", 0, ), ] self.assertEqual(results, expected) def test_search_and_open_in_browser_by_range(self): # adding bookmarks for bookmark in self.bookmarks: _add_rec(self.bdb, *bookmark) # simulate user input, select range of indices 1-3 index_range = "1-%s" % len(self.bookmarks) with mock.patch("builtins.input", side_effect=[index_range]): with mock.patch("buku.browse") as mock_browse: try: # search the db with keywords from each bookmark # searching using the first tag from bookmarks get_first_tag = lambda x: x[2].split(",")[1] results = self.bdb.searchdb( [get_first_tag(bm) for bm in self.bookmarks] ) prompt(self.bdb, results) except StopIteration: # catch exception thrown by reaching the end of the side effect iterable pass # collect arguments passed to browse arg_list = [args[0] for args, _ in mock_browse.call_args_list] # expect a list of one-tuples that are bookmark URLs expected = [x[0] for x in self.bookmarks] # checking if browse called with expected arguments self.assertEqual(arg_list, expected) @pytest.mark.slow @pytest.mark.vcr("tests/vcr_cassettes/test_search_and_open_all_in_browser.yaml") def test_search_and_open_all_in_browser(self): # adding bookmarks for bookmark in self.bookmarks: self.bdb.add_rec(*bookmark) # simulate user input, select 'a' to open all bookmarks in results with mock.patch("builtins.input", side_effect=["a"]): with mock.patch("buku.browse") as mock_browse: try: # search the db with keywords from each bookmark # searching using the first tag from bookmarks get_first_tag = lambda x: x[2].split(",")[1] results = self.bdb.searchdb( [get_first_tag(bm) for bm in self.bookmarks[:2]] ) prompt(self.bdb, results) except StopIteration: # catch exception thrown by reaching the end of the side effect iterable pass # collect arguments passed to browse arg_list = [args[0] for args, _ in mock_browse.call_args_list] # expect a list of one-tuples that are bookmark URLs expected = [x[0] for x in self.bookmarks][:2] # checking if browse called with expected arguments self.assertEqual(arg_list, expected) def test_delete_rec(self): # adding bookmark and getting index _add_rec(self.bdb, *self.bookmarks[0]) index = self.bdb.get_rec_id(self.bookmarks[0][0]) # deleting bookmark self.bdb.delete_rec(index) # asserting it doesn't exist from_db = self.bdb.get_rec_by_id(index) self.assertIsNone(from_db) def test_delete_rec_yes(self): # checking that "y" response causes delete_rec to return True with mock.patch("builtins.input", return_value="y"): self.assertTrue(self.bdb.delete_rec(0)) def test_delete_rec_no(self): # checking that non-"y" response causes delete_rec to return None with mock.patch("builtins.input", return_value="n"): self.assertFalse(self.bdb.delete_rec(0)) def test_cleardb(self): # adding bookmarks _add_rec(self.bdb, *self.bookmarks[0]) # deleting all bookmarks with mock.patch("builtins.input", return_value="y"): self.bdb.cleardb() # assert table has been dropped assert self.bdb.get_rec_by_id(0) is None def test_replace_tag(self): indices = [] for bookmark in self.bookmarks: # adding bookmark, getting index _add_rec(self.bdb, *bookmark) index = self.bdb.get_rec_id(bookmark[0]) indices += [index] # replacing tags with mock.patch("builtins.input", return_value="y"): self.bdb.replace_tag("news", ["__01"]) with mock.patch("builtins.input", return_value="y"): self.bdb.replace_tag("zażółć", ["__02,__03"]) # replacing tag which is also a substring of other tag with mock.patch("builtins.input", return_value="y"): self.bdb.replace_tag("es", ["__04"]) # removing tags with mock.patch("builtins.input", return_value="y"): self.bdb.replace_tag("gęślą") with mock.patch("builtins.input", return_value="y"): self.bdb.replace_tag("old") # removing non-existent tag with mock.patch("builtins.input", return_value="y"): self.bdb.replace_tag("_") # removing nonexistent tag which is also a substring of other tag with mock.patch("builtins.input", return_value="y"): self.bdb.replace_tag("e") for url, title, _, _ in self.bookmarks: # retrieving from db index = self.bdb.get_rec_id(url) from_db = self.bdb.get_rec_by_id(index) # asserting tags were replaced if title == "SLASHDOT": self.assertEqual(from_db[3], parse_tags(["__01"])) elif title == "ZAŻÓŁĆ": self.assertEqual(from_db[3], parse_tags(["__02,__03,jaźń"])) elif title == "test": self.assertEqual(from_db[3], parse_tags(["test,tes,est,__04"])) # def test_browse_by_index(self): # self.fail() def test_close_quit(self): # quitting with no args try: self.bdb.close_quit() except SystemExit as err: self.assertEqual(err.args[0], 0) # quitting with custom arg try: self.bdb.close_quit(1) except SystemExit as err: self.assertEqual(err.args[0], 1) # def test_import_bookmark(self): # self.fail() @pytest.mark.parametrize('status', [None, 200, 302, 308, 404, 500]) @pytest.mark.parametrize('fetch, url_redirect, tag_redirect, tag_error, del_error', [ (False, False, False, False, None), # offline (True, True, False, False, None), # url-redirect (True, False, True, True, None), # tag-redirect, tag-error (True, True, 'http-{}', 'error:{}', None), # url-redirect, fetch-tags (custom patterns) (True, True, 'redirect', 'error', None), # ... (patterns without codes) (True, True, 'redirect', False, range(400, 600)), # del-error (any errors) (True, True, 'redirect', 'error', {404}), # ... (some errors) ]) def test_add_rec_fetch(bukuDb, caplog, fetch, url_redirect, tag_redirect, tag_error, del_error, status): '''Testing add_rec() behaviour with fetch-status params''' title_in, title, desc = 'Custom Title', 'Fetched Title', 'Fetched description.' tags_in, url_in, url_new = ',custom,tags,', 'https://example.com', 'https://example.com/redirect' url = (url_new if status in PERMANENT_REDIRECTS else url_in) bdb = bukuDb() with mock_fetch(url=url, title=title, desc=desc, fetch_status=status) as fetch_data: index = bdb.add_rec(url=url_in, title_in=title_in, tags_in=tags_in, fetch=fetch, url_redirect=url_redirect, tag_redirect=tag_redirect, tag_error=tag_error, del_error=del_error) # del-error? if del_error and (not status or status in del_error): assert index is None assert bdb.get_max_id() is None err = ('Network error' if not status else 'HTTP error {}'.format(status)) assert caplog.record_tuples == [('root', 40, 'add_rec(): '+err)] return rec = bdb.get_rec_by_id(index) # offline? if not fetch: fetch_data.assert_not_called() assert (rec.url, rec.title, rec.desc) == (url_in, title_in, '') assert _tagset(rec.tags_raw) == _tagset(tags_in) return # url-redirect? if url_redirect and status in PERMANENT_REDIRECTS: assert rec.url == url_new else: assert rec.url == url_in # custom title, fetched description assert (rec.title, rec.desc) == (title_in, desc), 'custom title overrides fetched title' # fetch-tags? _tags = _tagset(tags_in) if tag_redirect and status in PERMANENT_REDIRECTS: _tags |= {('http:{}' if tag_redirect is True else tag_redirect).format(status).lower()} if tag_error and (status or 0) >= 400: _tags |= {('http:{}' if tag_error is True else tag_error).format(status).lower()} assert _tagset(rec.tags) == _tags @pytest.mark.parametrize('status, tags_fetched, tags_in, tags_except, expected', [ (None, None, 'foo,qux,foo bar,bar,baz', 'except,bar,foo,', ',baz,foo bar,qux,'), (200, '', 'foo,qux,foo bar,bar,baz', None, ',bar,baz,foo,foo bar,qux,'), (200, 'there,have been,some,tags,fetched', None, 'except,bar,tags,there,foo', ',fetched,have been,some,'), (200, 'there,have been,some,tags,fetched', 'foo,qux,foo bar,bar,baz', 'except,bar,tags,there,foo', ',baz,fetched,foo bar,have been,qux,some,'), (404, None, 'foo,foo bar,qux,bar,baz', 'except,bar,foo', ',baz,foo bar,http:error,qux,'), (301, 'there,have been,some,tags,fetched', 'foo,foo bar,qux,bar,baz', 'except,bar,tags,there,foo', ',baz,fetched,foo bar,have been,http:redirect,qux,some,'), (308, 'there,have been,some,tags,fetched', 'foo,foo bar,qux,bar,baz', 'except,http:redirect,bar,tags,there,foo', ',baz,fetched,foo bar,have been,qux,some,'), ]) def test_add_rec_tags(bukuDb, caplog, status, tags_fetched, tags_in, tags_except, expected): '''Testing add_rec() behaviour with tags params''' url, keywords = 'https://example.com', (',fetched,tags,' if tags_fetched is None else tags_fetched) bdb = bukuDb() with mock_fetch(url=url, title='Title', keywords=keywords, fetch_status=status): index = bdb.add_rec(url=url, fetch=status is not None, tags_in=tags_in, tags_except=tags_except, tags_fetch=tags_fetched is not None, tag_redirect='http:redirect', tag_error='http:error') rec = bdb.get_rec_by_id(index) assert rec.tags_raw == expected @pytest.mark.parametrize('index', [1, {2, 3}, None]) @pytest.mark.parametrize('export_on', [None, PERMANENT_REDIRECTS, range(400, 600), PERMANENT_REDIRECTS | {404}]) @pytest.mark.parametrize('url_in, title_in, tags_in, url_redirect, tag_redirect, tag_error, del_error', [ (None, None, None, False, False, False, None), # fetched title/desc, no network test (None, 'Custom Title', ',custom,tags,', False, False, False, None), # title, tags, no network test ('http://custom.url', None, None, False, False, False, None), # url, fetched title/desc, no network test ('http://custom.url', 'Custom Title', ',custom,tags,', False, False, False, None), # url, title, tags, no network test (None, 'Custom Title', '+,custom,tags,', True, False, False, None), # title, +tags, url-redirect ('http://custom.url', 'Custom Title', '+,custom,tags,', False, True, True, None), # url, title, +tags, fetch-tags (None, 'Custom Title', None, True, 'http-{}', 'error:{}', None), # title, url-redirect, fetch-tags (custom) (None, None, '-,initial%,', True, 'redirect', 'error', None), # -tags, url-redirect, fetch-tags (no codes) ('http://custom.url', 'Custom Title', None, True, 'redirect', False, range(400, 600)), # url, title, url-redirect, del-error (None, None, ',custom,tags,', True, 'redirect', 'error', {404}), # tags, url-redirect, fetch-tags, del-error ]) def test_update_rec_fetch(bukuDb, caplog, url_in, title_in, tags_in, url_redirect, tag_redirect, tag_error, del_error, export_on, index): '''Testing update_rec() behaviour with fetch-status params''' # redirected URL, nonexistent page, nonexistend domain urls = { 'http://wikipedia.net': {'fetch_status': 301, 'url': 'https://www.wikipedia.org', 'title': 'Wikipedia', 'desc': 'Wikipedia is a free online encyclopedia, created and edited blah blah'}, 'https://python.org/notfound': {'fetch_status': 404, 'title': 'Welcome to Python.org', 'desc': 'The official home of the Python Programming Language'}, 'http://nonexistent.url': {'fetch_status': None}, # unable to resolve host address } # for the URL override custom_url = {'fetch_status': 200, 'title': 'Fetched Title', 'desc': 'Fetched description.'} def custom_fetch(url, http_head=False): data = dict(urls.get(url, custom_url)) _url = data.pop('url', url) return FetchResult(url_in or _url, **data) # computed test parameters title_initial, tags_initial, desc = 'Initial Title', ',initial%,tags,', 'Initial description.' fetch_title = title_in is tags_in is None # when no custom params are passed (except for URL), titles are fetched network_test = url_redirect or tag_redirect or tag_error or del_error or export_on or fetch_title indices = ({index} if isinstance(index, int) else index or range(1, len(urls)+1)) tags = _tagset(tags_in if (tags_in or '').startswith(',') else tags_initial) if not (tags_in or ',').startswith(','): tags = (tags | _tagset(tags_in[1:]) if tags_in.startswith('+') else tags - _tagset(tags_in[1:])) # setup bdb = bukuDb() for url_initial in urls: _add_rec(bdb, url_initial, title_in=title_initial, tags_in=tags_initial, desc=desc) assert bdb.get_max_id() == len(urls), 'expecting correct setup' with mock_fetch(custom_fetch) as fetch_data: with mock.patch('buku.read_in', return_value='y'): ok = bdb.update_rec(index=index, url=url_in, title_in=title_in, tags_in=tags_in, url_redirect=url_redirect, tag_redirect=tag_redirect, tag_error=tag_error, del_error=del_error, export_on=export_on) recs = bdb.get_rec_all() # custom URL on multiple records? if url_in and len(indices) != 1: assert not ok, 'expected to fail' assert caplog.record_tuples == [('root', 40, 'All URLs cannot be same')] fetch_data.assert_not_called() assert recs == [BookmarkVar(id, url, title_initial, tags_initial, desc) for id, url in enumerate(urls, start=1)] return assert ok, 'expected to succeed' # offline? if not network_test and not (url_in and title_in is None): _tags = ',' + ','.join(sorted(tags)) + ',' fetch_data.assert_not_called() for rec, url in zip(recs, urls): if rec.id in indices: assert rec == BookmarkVar(rec.id, url_in or url, title_in or title_initial, _tags, desc) else: assert rec == BookmarkVar(rec.id, url, title_initial, tags_initial, desc) return # export-on (given HTTP codes)? if not export_on: assert bdb._to_export is None, f'expected no to_export backup: {bdb._to_export}' else: assert isinstance(bdb._to_export, dict), f'to_export backup is not a dict: {bdb._to_export}' to_export = dict(bdb._to_export or {}) _urls, _recs = set(urls), {x.url: x for x in recs} # one fetch per index assert fetch_data.call_count == len(indices), f'expected {len(indices)} fetches, done {fetch_data.call_count}' for call in fetch_data.call_args_list: # determining fetched, original and redirected URLs, along with fetched data url = call.args[0] url_old = url if not url_in else list(urls)[index-1] # url_in applies to a single record _urls -= {url_old} data = urls.get(url, custom_url) url_new = (url if not url_redirect else data.get('url', url)) rec = _recs.pop(url_new, None) status = data.get('fetch_status') # del-error? export-on? old = to_export.pop(url_new, None) if not export_on or status not in export_on: assert old is None, f'{url_old}: backup not expected' if del_error and status in del_error: assert rec is None, f'{url_old}: HTTP error {status}, should delete' if export_on and status in export_on: assert isinstance(old, BookmarkVar), f'{url_old}: should backup old record' assert (old.url, old.title, old.tags_raw, old.desc) == (url_old, title_initial, tags_initial, desc) continue if export_on and status in export_on: assert old == url_old, f'{url_old}: should backup old url on redirect' # url-redirect? if url_redirect and status in PERMANENT_REDIRECTS: assert url_new != url_old, f'{url_old}: redirect expected' assert rec.url == url_new, f'{url_old}: should replace with {url_new}' else: assert url_new == rec.url, f'{url_old}: redirect not expected' assert url_new == (url_in or url_old), f'{url_old}: URL should not be changed' # title if title_in or (fetch_title and 'title' in data): assert rec.title == (title_in or data['title']), f'{url_old}: should update title' else: assert rec.title == title_initial, f'{url_old}: should not update title' # description if fetch_title and 'desc' in data: assert rec.desc == data['desc'], f'{url_old}: should update description' else: assert rec.desc == desc, f'{url_old}: should not update description' # tags (+fetch-tags) _tags = set() if tag_redirect and status in PERMANENT_REDIRECTS: _tags |= {('http:{}' if tag_redirect is True else tag_redirect).format(status).lower()} elif tag_error and status in range(400, 600): _tags |= {('http:{}' if tag_error is True else tag_error).format(status).lower()} _tags_str = ',' + ','.join(sorted(_tags)) + ',' assert _tagset(rec.tags) == tags | _tags, f'{url_old}: [{tags_initial} | {tags_in or ","} | {_tags_str}] -> {rec.tags}' # other records should not have been affected (other than possibly indices) assert not to_export, f'unexpected to_export backup: {to_export}' assert set(_recs) == _urls for rec in _recs.values(): assert rec == BookmarkVar(rec.id, rec.url, title_initial, tags_initial, desc) @pytest.mark.parametrize('ext, expected', [ ('db', [(1, 'http://custom.url', 'Fetched Title (DELETED)', ',', 'Fetched description.'), (2, 'https://www.wikipedia.org', 'Wikipedia (OLD URL = http://wikipedia.net)', ',http:301,', 'Wikipedia is a free...'), (3, 'https://python.org/notfound', 'Welcome to Python.org', ',http:404,', 'The official home...')]), ('md', ['- [Fetched Title (DELETED)](http://custom.url)', '- [Wikipedia (OLD URL = http://wikipedia.net)](https://www.wikipedia.org) ', '- [Welcome to Python.org](https://python.org/notfound) ']), ('org', ['* [[http://custom.url][Fetched Title (DELETED)]]', '* [[https://www.wikipedia.org][Wikipedia (OLD URL = http://wikipedia.net)]] :http_301:', '* [[https://python.org/notfound][Welcome to Python.org]] :http_404:']), ('xbel', ['', '', '', '', ' ', ' Fetched Title (DELETED)', ' Fetched description.', ' ', ' ', ' Wikipedia (OLD URL = http://wikipedia.net)', ' Wikipedia is a free...', ' ', ' ', ' Welcome to Python.org', ' The official home...', ' ', '']), ('html', ['', '', '', 'Bookmarks', '

      Bookmarks

      ', '', '

      ', '

      buku bookmarks

      ', '

      ', '

      Fetched Title (DELETED)', '
      Fetched description.', '
      Wikipedia (OLD URL = http://wikipedia.net)', '
      Wikipedia is a free...', '
      Welcome to Python.org', '
      The official home...', '

      ', '

      ']), ('rss', ['', ' Bookmarks', ' buku', ' ', ' Fetched Title (DELETED)', ' ', ' 1', # deleted entry, pre-deletion ID ' Fetched description.

      ]]> ', ' ', ' ', ' Wikipedia (OLD URL = http://wikipedia.net)', ' ', ' 1', # updated entry, current ID ' ', ' Wikipedia is a free...

      ]]>
      ', '
      ', ' ', ' Welcome to Python.org', ' ', ' 2', ' ', ' The official home...

      ]]>
      ', '
      ', '']), ('atom', ['', ' Bookmarks', ' buku', ' ', ' Fetched Title (DELETED)', ' ', ' 1', # deleted entry, pre-deletion ID ' Fetched description.

      ]]>
      ', '
      ', ' ', ' Wikipedia (OLD URL = http://wikipedia.net)', ' ', ' 1', # updated entry, current ID ' ', ' Wikipedia is a free...

      ]]>
      ', '
      ', ' ', ' Welcome to Python.org', ' ', ' 2', ' ', ' The official home...

      ]]>
      ', '
      ', '
      ']), ]) def test_export_on(bukuDb, ext, expected): '''Testing exportdb() behaviour after update_rec() with export_on''' outfile = TEST_TEMP_DIR_PATH + '/export-on.' + ext bdb = bukuDb() _add_rec(bdb, 'https://www.wikipedia.org', 'Wikipedia', ',http:301,', 'Wikipedia is a free...') _add_rec(bdb, 'https://python.org/notfound', 'Welcome to Python.org', ',http:404,', 'The official home...') _add_rec(bdb, 'https://nonexistent.url', 'Custom Title') # not exported to_export = {'http://custom.url': BookmarkVar(1, 'http://custom.url', 'Fetched Title', ',', 'Fetched description.'), # deleted 'https://www.wikipedia.org': 'http://wikipedia.net', # redirect 'https://python.org/notfound': 'https://python.org/notfound'} # unchanged bdb._to_export = dict(to_export) bdb.exportdb(outfile, None) if ext == 'db': assert bukuDb(dbfile=outfile).get_rec_all() == list(bookmark_vars(expected)) else: with open(outfile, encoding='utf-8') as fout: output = fout.read() match = re.search('ADD_DATE="([0-9]+)"', output) timestamp = match and match.group(1) assert output.splitlines() == [s.format(timestamp) for s in expected] @pytest.fixture(scope="function") def refreshdb_fixture(): # Setup os.environ["XDG_DATA_HOME"] = TEST_TEMP_DIR_PATH # start every test from a clean state rmdb() bdb = BukuDb() yield bdb rmdb(bdb) # Teardown os.environ["XDG_DATA_HOME"] = TEST_TEMP_DIR_PATH @pytest.mark.parametrize( "title_in, exp_res", [ ["?", "Example Domain"], [None, "Example Domain"], ["", "Example Domain"], ["random title", "Example Domain"], ], ) def test_refreshdb(refreshdb_fixture, title_in, exp_res): bdb = refreshdb_fixture args = ["http://example.com"] if title_in: args.append(title_in) _add_rec(bdb, *args) with mock_fetch(title=exp_res): bdb.refreshdb(1, 1) from_db = bdb.get_rec_by_id(1) assert from_db[2] == exp_res, "from_db: {}".format(from_db) @pytest.fixture def test_print_caplog(caplog): caplog.handler.records.clear() caplog.records.clear() yield caplog @pytest.mark.parametrize( "kwargs, rec, exp_res", [ [{}, TEST_PRINT_REC, (True, [])], [{"is_range": True}, TEST_PRINT_REC, (True, [])], [{"index": 0}, TEST_PRINT_REC, (True, [])], [{"index": -1}, TEST_PRINT_REC, (True, [])], [{"index": -2}, TEST_PRINT_REC, (True, [])], [{"index": 2}, TEST_PRINT_REC, (False, [("root", 40, "No matching index 2")])], [{"low": -1, "high": -1}, TEST_PRINT_REC, (True, [])], [ {"low": -1, "high": -1, "is_range": True}, TEST_PRINT_REC, (False, [("root", 40, "Negative range boundary")]), ], [{"low": 0, "high": 0, "is_range": True}, TEST_PRINT_REC, (True, [])], [{"low": 0, "high": 1, "is_range": True}, TEST_PRINT_REC, (True, [])], [{"low": 0, "high": 2, "is_range": True}, TEST_PRINT_REC, (True, [])], [{"low": 2, "high": 2, "is_range": True}, TEST_PRINT_REC, (True, [])], [{"low": 2, "high": 3, "is_range": True}, TEST_PRINT_REC, (True, [])], # empty database [{"is_range": True}, None, (True, [])], [{"index": 0}, None, (True, [("root", 40, "0 records")])], [{"index": -1}, None, (False, [("root", 40, "Empty database")])], [{"index": 1}, None, (False, [("root", 40, "No matching index 1")])], [{"low": -1, "high": -1}, TEST_PRINT_REC, (True, [])], [ {"low": -1, "high": -1, "is_range": True}, None, (False, [("root", 40, "Negative range boundary")]), ], [{"low": 0, "high": 0, "is_range": True}, None, (True, [])], [{"low": 0, "high": 1, "is_range": True}, None, (True, [])], [{"low": 0, "high": 2, "is_range": True}, None, (True, [])], [{"low": 2, "high": 2, "is_range": True}, None, (True, [])], [{"low": 2, "high": 3, "is_range": True}, None, (True, [])], ], ) def test_print_rec(bukuDb, kwargs, rec, exp_res, tmp_path, caplog): bdb = bukuDb(dbfile=tmp_path / "tmp.db") if rec: _add_rec(bdb, *rec) # run the function assert (bdb.print_rec(**kwargs), caplog.record_tuples) == exp_res def test_list_tags(capsys, bukuDb): bdb = bukuDb() # adding bookmarks _add_rec(bdb, "http://one.com", "", parse_tags(["cat,ant,bee,1"]), "") _add_rec(bdb, "http://two.com", "", parse_tags(["Cat,Ant,bee,1"]), "") _add_rec(bdb, "http://three.com", "", parse_tags(["Cat,Ant,3,Bee,2"]), "") # listing tags, asserting output out, err = capsys.readouterr() prompt(bdb, None, True, listtags=True) out, err = capsys.readouterr() exp_out = " 1. 1 (2)\n 2. 2 (1)\n 3. 3 (1)\n 4. ant (3)\n 5. bee (3)\n 6. cat (3)\n\n" assert out == exp_out assert err == "" def test_compactdb(bukuDb): bdb = bukuDb() # adding bookmarks for bookmark in TEST_BOOKMARKS: _add_rec(bdb, *bookmark) # manually deleting 2nd index from db, calling compactdb bdb.cur.execute("DELETE FROM bookmarks WHERE id = ?", (2,)) bdb.compactdb(2) # asserting bookmarks have correct indices assert bdb.get_rec_by_id(1) == ( 1, "http://slashdot.org", "SLASHDOT", ",news,old,", "News for old nerds, stuff that doesn't matter", 0, ) assert bdb.get_rec_by_id(2) == ( 2, "http://example.com/", "test", ",es,est,tes,test,", "a case for replace_tag test", 0, ) assert bdb.get_rec_by_id(3) is None @pytest.mark.vcr() @pytest.mark.parametrize( "low, high, delay_commit, input_retval, exp_res", [ # delay_commit, y input_retval [0, 0, True, "y", (True, [])], # delay_commit, non-y input_retval [ 0, 0, True, "x", ( False, [tuple([x] + y + [0]) for x, y in zip(range(1, 4), TEST_BOOKMARKS)], ), ], # non delay_commit, y input_retval [0, 0, False, "y", (True, [])], # non delay_commit, non-y input_retval [ 0, 0, False, "x", ( False, [tuple([x] + y + [0]) for x, y in zip(range(1, 4), TEST_BOOKMARKS)], ), ], ], ) def test_delete_rec_range_and_delay_commit( bukuDb, tmp_path, low, high, delay_commit, input_retval, exp_res ): """test delete rec, range and delay commit.""" bdb = bukuDb(dbfile=tmp_path / "tmp.db") kwargs = {"is_range": True, "low": low, "high": high, "delay_commit": delay_commit} kwargs["index"] = 0 # Fill bookmark for bookmark in TEST_BOOKMARKS: _add_rec(bdb, *bookmark) with mock.patch("builtins.input", return_value=input_retval): res = bdb.delete_rec(**kwargs) assert (res, bdb.get_rec_all()) == exp_res # teardown os.environ["XDG_DATA_HOME"] = TEST_TEMP_DIR_PATH @pytest.mark.parametrize( "index, delay_commit, input_retval", [ [-1, False, False], [0, False, False], [1, False, True], [1, False, False], [1, True, True], [1, True, False], [100, False, True], ], ) def test_delete_rec_index_and_delay_commit(bukuDb, index, delay_commit, input_retval): """test delete rec, index and delay commit.""" bdb = bukuDb() bdb_dc = bukuDb() # instance for delay_commit check. # Fill bookmark for bookmark in TEST_BOOKMARKS: _add_rec(bdb, *bookmark) db_len = len(TEST_BOOKMARKS) n_index = index with mock.patch("builtins.input", return_value=input_retval): res = bdb.delete_rec(index=index, delay_commit=delay_commit) if n_index < 0: assert not res elif n_index > db_len: assert not res assert len(bdb.get_rec_all()) == db_len elif index == 0 and not input_retval: assert not res assert len(bdb.get_rec_all()) == db_len else: assert res assert len(bdb.get_rec_all()) == db_len - 1 if delay_commit: assert len(bdb_dc.get_rec_all()) == db_len else: assert len(bdb_dc.get_rec_all()) == db_len - 1 # teardown os.environ["XDG_DATA_HOME"] = TEST_TEMP_DIR_PATH @pytest.mark.parametrize( "index, is_range, low, high", [ # range on non zero index (0, True, 1, 1), # range on zero index (0, True, 0, 0), # zero index only (0, False, 0, 0), ], ) def test_delete_rec_on_empty_database(bukuDb, index, is_range, low, high): """test delete rec, on empty database.""" bdb = bukuDb() with mock.patch("builtins.input", return_value="y"): res = bdb.delete_rec(index, is_range, low, high) if (is_range and any([low == 0, high == 0])) or (not is_range and index == 0): assert res # teardown os.environ["XDG_DATA_HOME"] = TEST_TEMP_DIR_PATH return if is_range and low > 1 and high > 1: assert not res # teardown os.environ["XDG_DATA_HOME"] = TEST_TEMP_DIR_PATH @pytest.mark.parametrize( "kwargs, exp_res, raise_error", [ [{"index": 'a', "low": 'a', "high": 1, "is_range": True}, None, True], [{"index": 'a', "low": 'a', "high": 1, "is_range": False}, None, True], [{"index": 'a', "low": 1, "high": 'a', "is_range": True}, None, True], [{"index": 'a', "is_range": False}, None, True], [{"index": 'a', "is_range": True}, None, True], ], ) def test_delete_rec_on_non_integer( bukuDb, tmp_path, monkeypatch, kwargs, exp_res, raise_error ): """test delete rec on non integer arg.""" import buku bdb = bukuDb(dbfile=tmp_path / "tmp.db") for bookmark in TEST_BOOKMARKS: _add_rec(bdb, *bookmark) def mockreturn(): return "y" exp_res = None res = None monkeypatch.setattr(buku, "read_in", mockreturn) if raise_error: with pytest.raises(TypeError): res = bdb.delete_rec(**kwargs) else: res = bdb.delete_rec(**kwargs) assert res == exp_res @pytest.mark.parametrize("url", ["", False, None, 0]) def test_add_rec_add_invalid_url(bukuDb, caplog, url): """test method.""" bdb = bukuDb() res = _add_rec(bdb, url=url) assert res is None caplog.records[0].levelname == "ERROR" caplog.records[0].getMessage() == "Invalid URL" @pytest.mark.parametrize( "kwargs, exp_arg", [ [{"url": "example.com"}, ("example.com", "Example Domain", ",", "", False)], [ {"url": "http://example.com"}, ("http://example.com", "Example Domain", ",", "", False), ], [ {"url": "http://example.com", "immutable": True}, ("http://example.com", "Example Domain", ",", "", True), ], [ {"url": "http://example.com", "desc": "randomdesc"}, ("http://example.com", "Example Domain", ",", "randomdesc", False), ], [ {"url": "http://example.com", "title_in": "randomtitle"}, ("http://example.com", "randomtitle", ",", "", False), ], [ {"url": "http://example.com", "tags_in": "tag1"}, ("http://example.com", "Example Domain", ",tag1,", "", False), ], [ {"url": "http://example.com", "tags_in": ",tag1"}, ("http://example.com", "Example Domain", ",tag1,", "", False), ], [ {"url": "http://example.com", "tags_in": ",tag1,"}, ("http://example.com", "Example Domain", ",tag1,", "", False), ], ], ) def test_add_rec_exec_arg(bukuDb, kwargs, exp_arg): """test func.""" bdb = bukuDb() _cur = bdb.cur try: bdb.cur = mock.Mock() bdb.get_rec_id = mock.Mock(return_value=None) with mock_fetch(title=exp_arg[1]): bdb.add_rec(**kwargs) assert bdb.cur.execute.call_args[0][1] == exp_arg finally: bdb.cur = _cur def test_update_rec_index_0(bukuDb, caplog): """test method.""" bdb = bukuDb() res = bdb.update_rec(index=0, url="http://example.com") assert not res assert caplog.records[0].getMessage() == "All URLs cannot be same" assert caplog.records[0].levelname == "ERROR" @pytest.mark.parametrize( "kwargs, exp_res", [ [{"index": 1}, False], [{"index": 1, "url": 'url'}, False], [{"index": 1, "url": ''}, False], ], ) def test_update_rec(bukuDb, tmp_path, kwargs, exp_res): bdb = bukuDb(tmp_path / "tmp.db") res = bdb.update_rec(**kwargs) assert res == exp_res @pytest.mark.parametrize("invalid_tag", ["+,", "-,"]) def test_update_rec_invalid_tag(bukuDb, caplog, invalid_tag): """test method.""" url = "http://example.com" bdb = bukuDb() res = bdb.update_rec(index=1, url=url, tags_in=invalid_tag) assert not res assert caplog.records[0].getMessage() == "Please specify a tag" assert caplog.records[0].levelname == "ERROR" @pytest.mark.parametrize( "read_in_retval, exp_res, record_tuples", [ ["y", False, [("root", 40, "No matches found")]], ["n", False, []], ["", False, []], ], ) def test_update_rec_update_all_bookmark( caplog, tmp_path, bukuDb, read_in_retval, exp_res, record_tuples ): """test method.""" with mock.patch("buku.read_in", return_value=read_in_retval): bdb = bukuDb(tmp_path / "tmp.db") res = bdb.update_rec(index=0, tags_in="tags1") assert (res, caplog.record_tuples) == (exp_res, record_tuples) @pytest.mark.parametrize( "get_system_editor_retval, index, exp_res", [ ["none", 0, False], ["nano", -2, False], ], ) def test_edit_update_rec_with_invalid_input(bukuDb, get_system_editor_retval, index, exp_res): """test method.""" with mock.patch("buku.get_system_editor", return_value=get_system_editor_retval): assert bukuDb().edit_update_rec(index=index) == exp_res @pytest.mark.vcr("tests/vcr_cassettes/test_browse_by_index.yaml") @given( low=st.integers(min_value=-2, max_value=3), high=st.integers(min_value=-2, max_value=3), index=st.integers(min_value=-2, max_value=3), is_range=st.booleans(), empty_database=st.booleans(), ) @example(low=0, high=0, index=0, is_range=False, empty_database=True) @settings(max_examples=2, deadline=None) def test_browse_by_index(low, high, index, is_range, empty_database): """test method.""" n_low, n_high = (high, low) if low > high else (low, high) with mock.patch("buku.browse"): import buku bdb = buku.BukuDb(TEST_TEMP_DBFILE_PATH) try: bdb.delete_rec_all() db_len = 0 if not empty_database: bdb.add_rec("https://www.google.com/ncr", "?") db_len += 1 res = bdb.browse_by_index(index=index, low=low, high=high, is_range=is_range) if is_range and (low < 0 or high < 0): assert not res elif is_range and n_low > 0 and n_high > 0: assert res elif is_range: assert not res elif not is_range and index < 0: assert not res elif not is_range and index > db_len: assert not res elif not is_range and index >= 0 and empty_database: assert not res elif not is_range and 0 <= index <= db_len and not empty_database: assert res else: raise ValueError finally: rmdb(bdb) @pytest.fixture() def chrome_db(): # compatibility dir_path = os.path.dirname(os.path.realpath(__file__)) res_yaml_file = os.path.join(dir_path, "test_bukuDb", "25491522_res.yaml") res_nopt_yaml_file = os.path.join(dir_path, "test_bukuDb", "25491522_res_nopt.yaml") json_file = os.path.join(dir_path, "test_bukuDb", "Bookmarks") return json_file, res_yaml_file, res_nopt_yaml_file @pytest.mark.parametrize("add_pt", [True, False]) def test_load_chrome_database(bukuDb, chrome_db, add_pt): """test method.""" # compatibility json_file = chrome_db[0] res_yaml_file = chrome_db[1] if add_pt else chrome_db[2] dump_data = False # NOTE: change this value to dump data if not dump_data: with open(res_yaml_file, "r", encoding="utf8", errors="surrogateescape") as f: try: res_yaml = yaml.load(f, Loader=yaml.FullLoader) except RuntimeError: res_yaml = yaml.load(f, Loader=PrettySafeLoader) # init bdb = bukuDb() bdb.add_rec = mock.Mock() bdb.load_chrome_database(json_file, None, add_pt) call_args_list_dict = dict(bdb.add_rec.call_args_list) # test if not dump_data: assert call_args_list_dict == res_yaml # dump data for new test if dump_data: with open(res_yaml_file, "w", encoding="utf8", errors="surrogateescape") as f: yaml.dump(call_args_list_dict, f) print("call args list dict dumped to:{}".format(res_yaml_file)) @pytest.fixture() def firefox_db(tmpdir): dir_path = os.path.dirname(os.path.realpath(__file__)) res_yaml_file = os.path.join(dir_path, "test_bukuDb", "firefox_res.yaml") res_nopt_yaml_file = os.path.join(dir_path, "test_bukuDb", "firefox_res_nopt.yaml") ff_db_path = os.path.join(dir_path, "test_bukuDb", "places.sqlite") if not os.path.isfile(ff_db_path): db = sqlite3.connect(ff_db_path) with open(os.path.join(dir_path, 'test_bukuDb', 'places.sql'), encoding='utf-8') as sql: db.cursor().executescript(sql.read()) db.commit() return ff_db_path, res_yaml_file, res_nopt_yaml_file @pytest.mark.parametrize("add_pt", [True, False]) def test_load_firefox_database(bukuDb, firefox_db, add_pt): # compatibility ff_db_path = firefox_db[0] dump_data = False # NOTE: change this value to dump data res_yaml_file = firefox_db[1] if add_pt else firefox_db[2] if not dump_data: with open(res_yaml_file, "r", encoding="utf8", errors="surrogateescape") as f: res_yaml = yaml.load(f, Loader=PrettySafeLoader) # init bdb = bukuDb() bdb.add_rec = mock.Mock() bdb.load_firefox_database(ff_db_path, None, add_pt) call_args_list_dict = dict(bdb.add_rec.call_args_list) # test if not dump_data: assert call_args_list_dict == res_yaml if dump_data: with open(res_yaml_file, "w", encoding="utf8", errors="surrogateescape") as f: yaml.dump(call_args_list_dict, f) print("call args list dict dumped to:{}".format(res_yaml_file)) @pytest.mark.parametrize('ignore_case, fields, expected', [ (True, ['+id'], ['http://slashdot.org', 'http://www.zażółćgęśląjaźń.pl/', 'http://example.com/', 'javascript:void(0)', 'javascript:void(1)', 'example.com/#']), (True, [], ['http://slashdot.org', 'http://www.zażółćgęśląjaźń.pl/', 'http://example.com/', 'javascript:void(0)', 'javascript:void(1)', 'example.com/#']), (True, ['-metadata', '+netloc', '-url', 'id'], ['http://www.zażółćgęśląjaźń.pl/', 'http://example.com/', 'example.com/#', 'http://slashdot.org', 'javascript:void(1)', 'javascript:void(0)']), (False, ['-metadata', '+netloc', 'url', 'id'], ['example.com/#', 'http://example.com/', 'javascript:void(0)', 'javascript:void(1)', 'http://www.zażółćgęśląjaźń.pl/', 'http://slashdot.org']), (True, ['+title', '-tags', 'description', 'index', 'uri'], ['javascript:void(1)', 'javascript:void(0)', 'http://slashdot.org', 'http://example.com/', 'example.com/#', 'http://www.zażółćgęśląjaźń.pl/']), (True, ['# tEst ', '#invalid,tag', '-index'], ['javascript:void(1)', 'javascript:void(0)', 'http://www.zażółćgęśląjaźń.pl/', 'http://slashdot.org', 'example.com/#', 'http://example.com/']), (True, ['-# teSt ', '-#invalid,tag', '-index'], ['example.com/#', 'http://example.com/', 'javascript:void(1)', 'javascript:void(0)', 'http://www.zażółćgęśląjaźń.pl/', 'http://slashdot.org']), ]) def test_sort_and_reorder(bukuDb, fields, ignore_case, expected): _bookmarks = (TEST_BOOKMARKS + [(f'javascript:void({i})', 'foo', parse_tags([f'tag{i}']), 'stuff') for i in range(2)] + [('example.com/#', 'test', parse_tags(['test,tes,est,es']), 'a case for replace_tag test')]) bookmarks = [(i,) + tuple(x) for i, x in enumerate(_bookmarks, start=1)] shuffle(bookmarks) # making sure sorting by index works as well bdb = bukuDb() assert [x.url for x in bdb._sort(bookmarks, fields, ignore_case=ignore_case)] == expected for bookmark in _bookmarks: _add_rec(bdb, *bookmark) bdb.reorder(fields, ignore_case=ignore_case) assert [x.url for x in bdb.get_rec_all()] == expected @pytest.mark.parametrize('ignore_case, fields, expected', [ (True, ['+id'], 'id ASC'), (True, [], 'id ASC'), (False, ['-metadata', '+netloc', 'url', 'id'], 'metadata DESC, LOWER(NETLOC(url)) ASC, url ASC, id ASC'), (True, ['-metadata', '+netloc', '-url', 'id'], 'LOWER(metadata) DESC, LOWER(NETLOC(url)) ASC, LOWER(url) DESC, id ASC'), (False, ['+title', '-tags', 'description', 'index', 'uri'], 'metadata ASC, tags DESC, desc ASC, id ASC, url ASC'), (True, ['+title', '-tags', 'description', 'index', 'uri'], 'LOWER(metadata) ASC, LOWER(tags) DESC, LOWER(desc) ASC, id ASC, LOWER(url) ASC'), (True, ['#foo', '# BaR ', "-# b'A'z ", '# invalid, tag '], "tags LIKE '%,foo,%' ASC, tags LIKE '%,bar,%' ASC, tags LIKE '%,b''a''z,%' DESC") ]) def test_order(bukuDb, fields, ignore_case, expected): assert bukuDb()._order(fields, ignore_case=ignore_case) == expected @pytest.mark.parametrize('order, expected', [ (['netloc'], ['http://example.com/', 'https://example.com', '//example.com#', 'example.com?', 'http://slashdot.org', 'http://www.zażółćgęśląjaźń.pl/']), (['-netloc'], ['http://www.zażółćgęśląjaźń.pl/', 'http://slashdot.org', 'http://example.com/', 'https://example.com', '//example.com#', 'example.com?']), (['netloc', 'url'], ['//example.com#', 'example.com?', 'http://example.com/', 'https://example.com', 'http://slashdot.org', 'http://www.zażółćgęśląjaźń.pl/']), (['netloc', '-url'], ['https://example.com', 'http://example.com/', 'example.com?', '//example.com#', 'http://slashdot.org', 'http://www.zażółćgęśląjaźń.pl/']), ]) def test_order_by_netloc(bukuDb, order, expected): bdb = bukuDb() _EXTRA = ['https://example.com', '//example.com#', 'example.com?'] for bookmark in (TEST_BOOKMARKS + [(url, 'test', parse_tags(['test,tes,est,es']), 'a case for replace_tag test') for url in _EXTRA]): _add_rec(bdb, *bookmark) assert [x.url for x in bdb.get_rec_all(order=order)] == expected @pytest.mark.parametrize('keyword, params, expected', [ ('', {}, []), ('', {'markers': True}, []), ('*', {'markers': True}, []), (':', {'markers': True}, []), ('>', {'markers': True}, []), ('#', {'markers': True}, []), ('#,', {'markers': True}, []), ('# ,, ,', {'markers': True}, []), ('#, ,, ,', {'markers': True}, []), ('foo, bar?, , baz', {'regex': True}, [ ('metadata', False, 'foo, bar?, , baz'), ('url', False, 'foo, bar?, , baz'), ('desc', False, 'foo, bar?, , baz'), ('tags', False, 'foo, bar?, , baz'), ]), ('foo, bar?, , baz', {}, [ ('metadata', False, 'foo, bar?, , baz'), ('url', False, 'foo, bar?, , baz'), ('desc', False, 'foo, bar?, , baz'), ('tags', False, 'bar?', 'baz', 'foo'), ]), ('foo, bar?, , baz', {'deep': True}, [ ('metadata', True, 'foo, bar?, , baz'), ('url', True, 'foo, bar?, , baz'), ('desc', True, 'foo, bar?, , baz'), ('tags', True, 'bar?', 'baz', 'foo'), ]), ('foo, bar?, , baz', {'markers': True}, [ ('metadata', False, 'foo, bar?, , baz'), ('url', False, 'foo, bar?, , baz'), ('desc', False, 'foo, bar?, , baz'), ('tags', False, 'bar?', 'baz', 'foo'), ]), ('foo, bar?, , baz', {'deep': True, 'markers': True}, [ ('metadata', True, 'foo, bar?, , baz'), ('url', True, 'foo, bar?, , baz'), ('desc', True, 'foo, bar?, , baz'), ('tags', True, 'bar?', 'baz', 'foo'), ]), ('*foo, bar?, , baz', {'markers': True}, [ ('metadata', False, 'foo, bar?, , baz'), ('url', False, 'foo, bar?, , baz'), ('desc', False, 'foo, bar?, , baz'), ('tags', False, 'bar?', 'baz', 'foo'), ]), ('*foo, bar?, , baz', {'deep': True, 'markers': True}, [ ('metadata', True, 'foo, bar?, , baz'), ('url', True, 'foo, bar?, , baz'), ('desc', True, 'foo, bar?, , baz'), ('tags', True, 'bar?', 'baz', 'foo'), ]), ('.foo, bar?, , baz', {'markers': True}, [('metadata', False, 'foo, bar?, , baz')]), ('.foo, bar?, , baz', {'deep': True, 'markers': True}, [('metadata', True, 'foo, bar?, , baz')]), (':foo, bar?, , baz', {'markers': True}, [('url', False, 'foo, bar?, , baz')]), (':foo, bar?, , baz', {'deep': True, 'markers': True}, [('url', True, 'foo, bar?, , baz')]), ('>foo, bar?, , baz', {'markers': True}, [('desc', False, 'foo, bar?, , baz')]), ('>foo, bar?, , baz', {'deep': True, 'markers': True}, [('desc', True, 'foo, bar?, , baz')]), ('#foo, bar?, , baz', {'markers': True}, [('tags', True, 'bar?', 'baz', 'foo')]), ('#foo, bar?, , baz', {'deep': True, 'markers': True}, [('tags', True, 'bar?', 'baz', 'foo')]), ('#foo, bar?, , baz', {'regex': True, 'markers': True}, [('tags', True, 'foo, bar?, , baz')]), ('#,foo, bar?, , baz', {'markers': True}, [('tags', False, 'bar?', 'baz', 'foo')]), ('#,foo, bar?, , baz', {'deep': True, 'markers': True}, [('tags', False, 'bar?', 'baz', 'foo')]), ('#,foo, bar?, , baz', {'regex': True, 'markers': True}, [('tags', False, 'foo, bar?, , baz')]), ]) def test_search_tokens(bukuDb, keyword, params, expected): assert bukuDb()._search_tokens(keyword, **params) == expected @pytest.mark.parametrize('regex, tokens, args, clauses', [ (True, [], [], ''), (True, [('metadata', False, 'foo, bar?, , baz')], [r'foo, bar?, , baz'], 'metadata REGEXP ?'), # escape manually (True, [('tags', False, 'foo, bar?, , baz')], [r'foo, bar?, , baz'], 'tags REGEXP ?'), # specify borders manually (True, [('metadata', False, 'foo, bar?, , baz'), ('url', False, 'foo, bar?, , baz'), ('desc', False, 'foo, bar?, , baz'), ('tags', False, 'foo, bar?, , baz')], [r'foo, bar?, , baz']*4, 'metadata REGEXP ? OR url REGEXP ? OR desc REGEXP ? OR tags REGEXP ?'), (False, [], [], ''), (False, [('desc', False, 'foo, bar?, , baz')], [r'\bfoo,\ bar\?,\ ,\ baz\b'], 'desc REGEXP ?'), (False, [('desc', True, 'foo, bar?, , baz')], ['foo, bar?, , baz'], "desc LIKE ('%' || ? || '%')"), (False, [('tags', False, 'bar?', 'baz', 'foo')], [r',bar\?,', r',baz,', r',foo,'], '(tags REGEXP ? AND tags REGEXP ? AND tags REGEXP ?)'), (False, [('tags', True, 'bar?', 'baz', 'foo')], ['bar?', 'baz', 'foo'], "(tags LIKE ('%' || ? || '%') AND tags LIKE ('%' || ? || '%') AND tags LIKE ('%' || ? || '%'))"), (False, [('metadata', False, 'foo, bar?, , baz'), ('url', False, 'foo, bar?, , baz'), ('desc', False, 'foo, bar?, , baz'), ('tags', False, 'bar?', 'baz', 'foo')], [r'\bfoo,\ bar\?,\ ,\ baz\b']*3 + [r',bar\?,', r',baz,', r',foo,'], 'metadata REGEXP ? OR url REGEXP ? OR desc REGEXP ? OR (tags REGEXP ? AND tags REGEXP ? AND tags REGEXP ?)'), (False, [('metadata', True, 'foo, bar?, , baz'), ('url', True, 'foo, bar?, , baz'), ('desc', True, 'foo, bar?, , baz'), ('tags', True, 'bar?', 'baz', 'foo')], ['foo, bar?, , baz']*3 + ['bar?', 'baz', 'foo'], "metadata LIKE ('%' || ? || '%') OR url LIKE ('%' || ? || '%') OR desc LIKE ('%' || ? || '%')" " OR (tags LIKE ('%' || ? || '%') AND tags LIKE ('%' || ? || '%') AND tags LIKE ('%' || ? || '%'))"), ]) def test_search_clause(bukuDb, regex, tokens, args, clauses): assert bukuDb()._search_clause(tokens, regex=regex) == (clauses, args) @pytest.mark.parametrize('keywords, params, expected', [ (['slashdot'], {}, ['http://slashdot.org']), (['slashdot|example'], {'regex': True}, ['http://slashdot.org', 'http://example.com/']), (['slashdot|example'], {'regex': True, 'order': ['-title']}, ['http://example.com/', 'http://slashdot.org']), (['old,news,old'], {}, ['http://slashdot.org']), # tags matching (['bold,news,old'], {}, []), # ALL tags within a token must match (['#test'], {'markers': True}, ['http://example.com/']), (['#es,test'], {'markers': True}, ['http://example.com/']), (['#te'], {'markers': True}, ['http://example.com/']), (['#,te'], {'markers': True}, []), (['#,es'], {'markers': True}, ['http://example.com/']), (['#,es,te'], {'markers': True}, []), # ALL tags within a token must match (['>for', ':com'], {'markers': True, 'all_keywords': True}, ['http://example.com/']), (['>for', ':com'], {'markers': True, 'all_keywords': False}, ['http://example.com/', 'http://slashdot.org']), (['>test'], {'markers': True, 'deep': False}, ['http://example.com/']), (['>test'], {'markers': True, 'deep': True, 'order': ['title']}, ['http://example.com/', 'http://www.zażółćgęśląjaźń.pl/']), ]) def test_searchdb(bukuDb, keywords, params, expected): bdb = bukuDb() for bookmark in TEST_BOOKMARKS: _add_rec(bdb, *bookmark) assert [x.url for x in bdb.searchdb(keywords, **params)] == expected @pytest.mark.parametrize('keyword_results, stag_results, exp_res', [ ([], [], []), (["item1"], ["item1", "item2"], ["item1"]), (["item2"], ["item1"], []), ]) def test_search_keywords_and_filter_by_tags(bukuDb, keyword_results, stag_results, exp_res): with mock.patch('buku.BukuDb.searchdb', return_value=keyword_results): with mock.patch('buku.BukuDb.search_by_tag', return_value=stag_results): assert exp_res == bukuDb().search_keywords_and_filter_by_tags(['keywords'], stag=['stag']) @pytest.mark.parametrize('search_results, exclude_results, exp_res', [ ([], [], []), (["item1", "item2"], ["item2"], ["item1"]), (["item2"], ["item1"], ["item2"]), (["item1", "item2"], ["item1", "item2"], []), ]) def test_exclude_results_from_search(bukuDb, search_results, exclude_results, exp_res): with mock.patch('buku.BukuDb.searchdb', return_value=exclude_results): assert exp_res == bukuDb().exclude_results_from_search(search_results, ['without']) def test_exportdb_empty_db(bukuDb): with NamedTemporaryFile(delete=False) as f: db = bukuDb(dbfile=f.name) with NamedTemporaryFile(delete=False) as f2: res = db.exportdb(f2.name) assert not res def test_exportdb_single_rec(bukuDb, tmpdir): f1 = NamedTemporaryFile(delete=False) f1.close() db = bukuDb(dbfile=f1.name) _add_rec(db, "http://example.com") exp_file = tmpdir.join("export") db.exportdb(exp_file.strpath) with open(exp_file.strpath, encoding="utf8", errors="surrogateescape") as f2: assert f2.read() def test_exportdb_to_db(bukuDb): f1 = NamedTemporaryFile(delete=False) f1.close() f2 = NamedTemporaryFile(delete=False, suffix=".db") f2.close() db = bukuDb(dbfile=f1.name) _add_rec(db, "http://example.com") _add_rec(db, "http://google.com") with mock.patch("builtins.input", return_value="y"): db.exportdb(f2.name) db2 = bukuDb(dbfile=f2.name) assert db.get_rec_all() == db2.get_rec_all() @pytest.mark.parametrize('pick', [None, 0, 3, 7, 10]) @mock.patch('builtins.print') @mock.patch('builtins.open') @mock.patch('random.sample') @mock.patch('buku.convert_bookmark_set') @mock.patch('buku.BukuDb._sort') def test_exportdb_pick(_bukudb_sort, _convert_bookmark_set, _sample, _open, _print, bukuDb, pick): wrap = mock.Mock() wrap.attach_mock(_print, 'print') wrap.attach_mock(_open, 'open') wrap.attach_mock(_sample, 'sample') wrap.attach_mock(_convert_bookmark_set, 'convert_bookmark_set') wrap.attach_mock(_bukudb_sort, 'BukuDb_sort') _sample.return_value = _sampled = object() _bukudb_sort.return_value = _selection = object() _convert_bookmark_set.return_value = _converted = {'data': object(), 'count': 42} filepath, order, records, picked = 'output.md', object(), range(7), pick and pick < 7 bdb = bukuDb() assert bdb.exportdb(filepath, records, order=order, pick=pick) pick_expected = [mock.call.sample(records, pick), mock.call.BukuDb_sort(_sampled, order)] expected_calls = [mock.call.open(filepath, mode='w', encoding='utf-8'), mock.call.open().__enter__(), # pylint: disable=unnecessary-dunder-call mock.call.convert_bookmark_set((_selection if picked else records), 'markdown', {}), mock.call.open().__enter__().write(_converted['data']), # pylint: disable=unnecessary-dunder-call mock.call.print('42 exported'), mock.call.open().__exit__(None, None, None)] assert wrap.mock_calls == ([] if not picked else pick_expected) + expected_calls @pytest.mark.parametrize( "urls, exp_res", [ [[], None], [["http://example.com"], 1], [["http://example.com", "http://google.com"], 2], ], ) def test_get_max_id(bukuDb, urls, exp_res): with NamedTemporaryFile(delete=False) as f: db = bukuDb(dbfile=f.name) if urls: list(map(lambda x: _add_rec(db, x), urls)) assert db.get_max_id() == exp_res # Helper functions for testcases def split_and_test_membership(a, b): # :param a, b: comma separated strings to split # test everything in a in b return all(x in b.split(",") for x in a.split(",")) def inclusive_range(start, end): return list(range(start, end + 1)) def normalize_range(db_len, low, high): """normalize index and range. Args: db_len (int): database length. low (int): low limit. high (int): high limit. Returns: Tuple contain following normalized variables (low, high) """ require_comparison = True # don't deal with non instance of the variable. if not isinstance(low, int): n_low = low require_comparison = False if not isinstance(high, int): n_high = high require_comparison = False max_value = db_len if low == "max" and high == "max": n_low = db_len n_high = max_value elif low == "max" and high != "max": n_low = high n_high = max_value elif low != "max" and high == "max": n_low = low n_high = max_value else: n_low = low n_high = high if require_comparison: if n_high < n_low: n_high, n_low = n_low, n_high return (n_low, n_high) if __name__ == "__main__": unittest.main() ================================================ FILE: tests/test_cli.py ================================================ from unittest import mock from io import StringIO import os import pytest import buku @pytest.fixture def stdin(monkeypatch): with monkeypatch.context(): monkeypatch.setattr('sys.stdin', (buffer := StringIO())) yield buffer @pytest.fixture def BukuDb(): with mock.patch('buku.BukuDb') as cls: cls.return_value.close_quit.side_effect = SystemExit yield cls @pytest.fixture def bdb(BukuDb): yield BukuDb.return_value @pytest.fixture def piped_input(): with mock.patch('buku.piped_input') as fn: yield fn @pytest.fixture def prompt(): with mock.patch('buku.prompt') as fn: yield fn @pytest.fixture def exit(): with mock.patch('sys.exit', side_effect=SystemExit) as fn: yield fn def test_version(BukuDb, piped_input, capsys): with pytest.raises(SystemExit): buku.main(['--version']) assert capsys.readouterr().out.splitlines() == [buku.__version__] def test_usage(BukuDb, piped_input, monkeypatch, capsys): with pytest.raises(SystemExit): buku.main(['--unknown'], program_name='buku') BukuDb.assert_not_called() assert capsys.readouterr().err.splitlines() == [ 'usage: buku [OPTIONS] [KEYWORD [KEYWORD ...]]', 'buku: error: unrecognized arguments: --unknown', ] @pytest.mark.parametrize('argv', [['--help'], ['foo', 'bar', '--help']]) def test_help(BukuDb, exit, piped_input, argv): with mock.patch('buku.ExtendedArgumentParser.print_help') as print_help: with pytest.raises(SystemExit): buku.main(argv) BukuDb.assert_not_called() print_help.assert_called_with() exit.assert_called_with(0) @pytest.mark.parametrize('nostdin', [True, False]) @pytest.mark.parametrize('db', [None, './foo.db']) def test_prompt(BukuDb, bdb, piped_input, prompt, nostdin, db): argv = (['--nostdin'] if nostdin else []) + (['--db', db] if db else []) BukuDb.get_default_dbdir.return_value = '/default/db/dir' with pytest.raises(SystemExit): buku.main(argv) if argv and argv[0] != '--nostdin': piped_input.assert_called_with(argv, []) else: piped_input.assert_not_called() BukuDb.assert_called_with(dbfile=db or os.path.join('/default/db/dir', 'bookmarks.db'), default_scheme=buku.SCHEME_HTTP) prompt.assert_called_with(bdb, None) bdb.close_quit.assert_called_with(0) @pytest.mark.parametrize('fetch_params', [ {'offline': True}, {'url_redirect': True}, {'tag_redirect': True, 'tag_error': True}, {'url_redirect': True, 'tag_redirect': 'redirect', 'tag_error': 'error'}, {'url_redirect': True, 'tag_redirect': 'redirect', 'del_range': [], 'del_error': range(400, 600)}, {'url_redirect': True, 'tag_redirect': 'redirect', 'tag_error': 'error', 'del_range': ['400-404', '500'], 'del_error': {400, 401, 402, 403, 404, 500}}, ]) @pytest.mark.parametrize('value_params', [ {'add_tags': ['foo,bar', 'baz'], 'tags_fetch': False, 'tags_in': ',bar baz,foo,', 'title': ''}, {'tag': ['foo', 'bar,baz'], 'tags_fetch': False, 'tags_in': ',baz,foo bar,', 'title': 'Custom Title'}, {'add_tags': ['+', 'foo', 'bar', 'baz'], 'tags_in': ',foo bar baz,', 'comment': ''}, {'tag': ['+', 'foo,bar,baz'], 'tags_in': ',bar,baz,foo,', 'comment': 'Custom Description'}, {'add_tags': ['-', 'foo', 'baz', 'baz'], 'tags_except': ',foo baz baz,', 'immutable': False}, {'tag': ['-', 'foo,', ',baz,', ',baz'], 'tags_except': ',baz,foo,', 'immutable': True}, {'add_tags': ['foo,baz,bar'], 'tag': ['baz,qux'], 'tags_fetch': False, 'tags_in': ',bar,baz,foo,qux,'}, {'add_tags': ['+', 'foo,baz,bar'], 'tag': ['baz,', 'qux,'], 'tags_fetch': False, 'tags_in': ',bar,baz,foo,qux,'}, {'add_tags': ['foo,baz,', 'bar,'], 'tag': ['+', 'baz,qux'], 'tags_fetch': False, 'tags_in': ',bar,baz,foo,qux,'}, {'add_tags': ['-', 'foo,baz,bar,'], 'tag': ['baz,', 'qux,'], 'tags_fetch': False, 'tags_in': ',baz,qux,', 'tags_except': ',bar,baz,foo,'}, {'add_tags': ['foo,baz,', 'bar,'], 'tag': ['-', 'baz,qux'], 'tags_fetch': False, 'tags_in': ',bar,baz,foo,', 'tags_except': ',baz,qux,'}, {'add_tags': ['-', 'foo,baz,bar,'], 'tag': ['-', 'baz,', 'qux,'], 'tags_except': ',bar,baz,foo,qux,'}, {'add_tags': ['-', 'foo,baz,', 'bar,'], 'tag': ['+', 'baz,', 'qux,'], 'tags_in': ',baz,qux,', 'tags_except': ',bar,baz,foo,'}, {'add_tags': ['+', 'foo,baz,', 'bar,'], 'tag': ['-', 'baz,', 'qux,'], 'tags_in': ',bar,baz,foo,', 'tags_except': ',baz,qux,'}, ]) def test_add(stdin, bdb, prompt, value_params, fetch_params): _test_add(bdb, prompt, **value_params, **fetch_params) def _test_add(bdb, prompt, *, add_tags=[], tag=[], tags_fetch=True, tags_in=None, tags_except=None, title=None, comment=None, immutable=None, offline=False, url_redirect=False, tag_redirect=False, tag_error=False, del_range=None, del_error=None): argv = ['--add', (url := 'https://example.com/')] + add_tags if tag: argv += ['--tag'] + tag if title is not None: argv += ['--title', title] if comment is not None: argv += ['--comment', comment] if immutable is not None: argv += ['--immutable', str(int(immutable))] if offline: argv += ['--offline'] if url_redirect: argv += ['--url-redirect'] if tag_redirect: argv += ['--tag-redirect'] + ([] if isinstance(tag_redirect, bool) else [tag_redirect]) if tag_error: argv += ['--tag-error'] + ([] if isinstance(tag_error, bool) else [tag_error]) if del_error: argv += ['--del-error'] + del_range print(argv) with pytest.raises(SystemExit): buku.main(argv) network_test = url_redirect or tag_redirect or tag_error or del_error fetch = not offline and (network_test or tags_fetch or title is None) bdb.add_rec.assert_called_with( url, title, tags_in, comment, immutable, delay_commit=False, fetch=fetch, tags_fetch=tags_fetch, tags_except=tags_except, url_redirect=url_redirect, tag_redirect=tag_redirect, tag_error=tag_error, del_error=del_error) bdb.searchdb.assert_not_called() prompt.assert_not_called() bdb.close_quit.assert_called_with(0) @pytest.mark.parametrize('np', [{}, {'np': []}]) @pytest.mark.parametrize('count', [{}, {'count': ['10']}]) @pytest.mark.parametrize('order, indices, command', [ (['tags', '-netloc', '+url'], None, {'order': ['tags,-netloc,+url'], 'print': []}), (['-description', '+uri'], [5, 8, 9, 10, 11, 12, 40, 41, 42], {'order': [',-description', '+uri'], 'print': ['5', '8-12', '-3']}), ]) def test_order_print(bdb, stdin, prompt, order, indices, command, count, np): command = dict(command, **count, **np) argv = [s for k, v in command.items() for s in ([f'--{k}'] + v)] print(argv) result = [None] * 20 bdb.list_using_id.return_value = result bdb.get_max_id.return_value = 42 with pytest.raises(SystemExit): buku.main(argv) bdb.get_max_id.assert_called_with() if not (_count := command.get('count')): bdb.print_rec.assert_called_with(indices, order=order) else: if not command['print']: bdb.list_using_id.assert_called_with(order=order) else: bdb.list_using_id.assert_called_with(command['print'], order=order) prompt.assert_called_with(bdb, result, noninteractive=('np' in command), num=int(_count[0]), order=order) @pytest.mark.parametrize('search', ['', 'sany', 'sall', 'sreg', 'stag']) @pytest.mark.parametrize('exclude', [None, ['xyzzy', 'grue']]) @pytest.mark.parametrize('keywords, rest', [ ([], {}), (['foo', 'bar'], {'markers': []}), (['foo', 'bar'], {'deep': []}), (['foo', 'bar'], {'stag': ['baz', 'qux']}) ]) def test_order_search(bdb, stdin, prompt, search, exclude, keywords, rest): if (search == '' and not keywords) or (search == 'stag' and 'stag' in rest): pytest.skip('Invalid combination') order, stag, deep, markers = ['title', '-index'], rest.get('stag'), 'deep' in rest, 'markers' in rest argv = ([] if search == '' else [f'--{search}']) + keywords + ['--order', ','.join(order)] argv += [s for k, v in rest.items() for s in ([f'--{k}'] + v)] + ([] if not exclude else ['--exclude'] + exclude) bdb.search_by_tag.return_value = ['tag search results'] with pytest.raises(SystemExit): buku.main(argv) if search == 'stag': if not keywords: prompt.assert_called_with(bdb, None, noninteractive=False, listtags=True, suggest=False, order=order) else: bdb.search_by_tag.assert_called_with(' '.join(keywords), order=order) bdb.exclude_results_from_search.assert_called_with( bdb.search_by_tag.return_value, exclude, deep=deep, markers=markers) if search == 'stag' or not keywords: bdb.search_keywords_and_filter_by_tags.assert_not_called() elif search in ('', 'sany'): bdb.search_keywords_and_filter_by_tags.assert_called_with( keywords, deep=deep, stag=stag, markers=markers, without=exclude, order=order) elif search == 'sall': bdb.search_keywords_and_filter_by_tags.assert_called_with( keywords, all_keywords=True, deep=deep, stag=stag, markers=markers, without=exclude, order=order) elif search == 'sreg': bdb.search_keywords_and_filter_by_tags.assert_called_with( keywords, regex=True, stag=stag, markers=markers, without=exclude, order=order) @pytest.mark.parametrize('json', [None, '', 'output.json']) @pytest.mark.parametrize('indices', [None, '', '1-10', '-10']) # None = search @pytest.mark.parametrize('random', [None, 1, 3]) @mock.patch('random.sample', return_value='sampled') @mock.patch('buku.print_rec_with_filter') @mock.patch('buku.write_string_to_file') @mock.patch('buku.format_json', return_value='formatted') @mock.patch('buku.print_json_safe') def test_random(_print_json_safe, _format_json, _write_string_to_file, _print_rec_with_filter, _sample, bdb, stdin, prompt, random, indices, json): wrap = mock.Mock() wrap.attach_mock(_sample, 'random_sample') wrap.attach_mock(_print_rec_with_filter, 'print_rec_with_filter') wrap.attach_mock(_write_string_to_file, 'write_string_to_file') wrap.attach_mock(_format_json, 'format_json') wrap.attach_mock(_print_json_safe, 'print_json_safe') wrap.attach_mock(prompt, 'prompt') wrap.attach_mock(bdb, 'bdb') bdb.get_max_id.return_value = 42 bdb._sort.return_value = 'sorted' bdb.search_keywords_and_filter_by_tags.return_value = 'found' argv = (['--sall', 'foo'] if indices is None else ['--print'] + ([] if not indices else [indices])) argv += ([] if json is None else ['--json'] + ([] if not json else [json])) argv += ([] if not random else ['--random'] + ([] if random == 1 else [str(random)])) with pytest.raises(SystemExit): buku.main(argv) calls = ([] if indices is None else [mock.call.bdb.get_max_id()]) if indices: # --print 1-10 idxs = list(range(33, 43) if indices == '-10' else range(1, 11)) calls += ([mock.call.bdb.print_rec(idxs, order=[])] if not random else [mock.call.random_sample(idxs, random), mock.call.bdb.print_rec('sampled', order=[])]) elif indices is not None: # --print calls += ([mock.call.bdb.print_rec(None, order=[])] if not random else [mock.call.random_sample(range(1, 43), random), mock.call.bdb.print_rec('sampled', order=[])]) else: # --sall foo calls += [mock.call.bdb.search_keywords_and_filter_by_tags( ['foo'], all_keywords=True, deep=False, stag=None, markers=False, without=None, order=[])] if random: calls += [mock.call.random_sample('found', random), mock.call.bdb._sort('sampled', [])] res = ('sorted' if random else 'found') if json: calls += [mock.call.format_json(res, (random == 1), field_filter=0), mock.call.write_string_to_file('formatted', json)] elif json is not None: calls += [mock.call.print_json_safe(res, (random == 1), field_filter=0)] elif random: calls += [mock.call.print_rec_with_filter(res, field_filter=0)] else: calls += [mock.call.prompt(bdb, res, noninteractive=False, deep=False, markers=False, order=[], num=10)] calls += [mock.call.bdb.close_quit(0)] assert wrap.mock_calls == calls @pytest.mark.parametrize('search', [True, False]) @pytest.mark.parametrize('random', [None, 1, 3]) @mock.patch('random.sample', return_value='sampled') @mock.patch('buku.print_rec_with_filter') def test_random_export(_print_rec_with_filter, _sample, bdb, stdin, prompt, random, search): wrap = mock.Mock() wrap.attach_mock(_sample, 'random_sample') wrap.attach_mock(_print_rec_with_filter, 'print_rec_with_filter') wrap.attach_mock(prompt, 'prompt') wrap.attach_mock(bdb, 'bdb') bdb.get_max_id.return_value = 42 bdb._sort.return_value = 'sorted' bdb.search_keywords_and_filter_by_tags.return_value = 'found' argv = ['--export', 'export.md'] + ([] if not search else ['--sall', 'foo']) argv += ([] if not random else ['--random'] + ([] if random == 1 else [str(random)])) with pytest.raises(SystemExit): buku.main(argv) calls = [] if not search: calls += [mock.call.bdb.exportdb('export.md', order=[], pick=random)] else: calls += [mock.call.bdb.search_keywords_and_filter_by_tags( ['foo'], all_keywords=True, deep=False, stag=None, markers=False, without=None, order=[])] if random: calls += [mock.call.random_sample('found', random), mock.call.bdb._sort('sampled', [])] res = ('sorted' if random else 'found') if random: calls += [mock.call.print_rec_with_filter(res, field_filter=0)] else: calls += [mock.call.prompt(bdb, res, noninteractive=True, deep=False, markers=False, order=[], num=10)] calls += [mock.call.bdb.exportdb('export.md', res)] calls += [mock.call.bdb.close_quit(0)] assert wrap.mock_calls == calls @pytest.mark.parametrize('db', [None, './foo.db', 'bar.sqlite', 'name']) @pytest.mark.parametrize('action', ['print', 'lock', 'unlock']) @mock.patch('buku.BukuCrypt') def test_custom_db(_BukuCrypt, BukuDb, stdin, db, action): wrap = mock.Mock() wrap.attach_mock(BukuDb, 'BukuDb') wrap.attach_mock(BukuDb.return_value, 'bdb') wrap.attach_mock(_BukuCrypt, 'BukuCrypt') BukuDb.return_value.get_max_id.return_value = None BukuDb.get_default_dbdir.return_value = '/default/db/dir' _db = (db if db != 'name' else '/default/db/dir/name.db') argv = ['--nostdin'] + ([] if not db else ['--db', db]) + [f'--{action}'] with pytest.raises(SystemExit): buku.main(argv) calls = [] if db == 'name': calls += [mock.call.BukuDb.get_default_dbdir()] if action == 'lock': calls += [mock.call.BukuCrypt.encrypt_file(8, dbfile=_db)] else: if action == 'unlock': calls += [mock.call.BukuCrypt.decrypt_file(8, dbfile=_db)] calls += [mock.call.BukuDb(None, 0, True, dbfile=_db, colorize=True, default_scheme=buku.SCHEME_HTTP)] if action == 'print': calls += [mock.call.bdb.get_max_id(), mock.call.bdb.print_rec(None, order=[])] calls += [mock.call.bdb.close_quit(0)] assert wrap.mock_calls == calls ================================================ FILE: tests/test_import_firefox_json.py ================================================ import json from buku import import_firefox_json def test_load_from_empty(): """test method.""" # Arrange data = json.loads("{}") # Act items = import_firefox_json(data) # Assert count = sum(1 for _ in items) assert count == 0 def test_load_full_entry(): """test method.""" # Arrange data = json.loads(""" { "title" : "main", "typeCode": 2, "children": [ { "title" : "title", "typeCode": 2, "children": [ { "dateAdded": 1269200039653000, "guid": "xxxydfalkj", "id": 113, "index": 0, "lastModified": 1305978154986000, "title": "entry title", "type": "text/x-moz-place", "typeCode": 1, "tags" : "x,y", "uri": "http://uri.com/abc?234&536", "annos" : [{ "name": "bookmarkProperties/description", "value": "desc" }] }] }] }""") # Act items = import_firefox_json(data) # Assert result = [] for item in items: result.append(item) assert len(result) == 1 assert result[0][0] == 'http://uri.com/abc?234&536' assert result[0][1] == 'entry title' assert result[0][2] == ',x,y,' assert result[0][3] == 'desc' def test_load_no_typecode(): """test method.""" # Arrange data = json.loads(""" { "title" : "main", "typeCode": 2, "children": [ { "title" : "title", "children": [ { "title" : "title1", "uri" : "http://uri1", "annos" : [{ "name": "bookmarkProperties/description", "value": "desc" }] }] }] }""") # Act items = import_firefox_json(data) # Assert result = [] for item in items: result.append(item) assert len(result) == 0 def test_load_invalid_typecode(): """test method.""" # Arrange data = json.loads(""" { "title" : "title", "children": [ { "title" : "title1", "typeCode" : 99, "uri" : "http://uri1", "annos" : [{ "name": "bookmarkProperties/description", "value": "desc" }] }] }""") # Act items = import_firefox_json(data) # Assert result = [] for item in items: result.append(item) assert len(result) == 0 def test_load_folder_with_no_children(): """test method.""" # Arrange data = json.loads(""" { "title" : "title", "typeCode" : 2 } """) # Act items = import_firefox_json(data) # Assert result = [] for item in items: result.append(item) assert len(result) == 0 def test_load_one_child(): """test method.""" # Arrange data = json.loads(""" { "title" : "main", "typeCode" : 2, "children": [ { "title" : "title", "typeCode" : 2, "children": [ { "title" : "title1", "typeCode" : 1, "uri" : "http://uri1", "annos" : [{ "name": "bookmarkProperties/description", "value": "desc" }] } ]} ] } """) # Act items = import_firefox_json(data) # Assert result = [] for item in items: result.append(item) assert len(result) == 1 assert result[0][0] == 'http://uri1' assert result[0][1] == 'title1' assert result[0][2] == ',' assert result[0][3] == 'desc' def test_load_one_container_child(): """test method.""" # Arrange data = json.loads(""" { "title" : "main", "typeCode": 2, "children": [ { "title" : "title", "typeCode" : 2, "children": [ { "title":"bookmark folder", "typeCode":2 }] }] }""") # Act items = import_firefox_json(data) # Assert result = [] for item in items: result.append(item) assert len(result) == 0 def test_load_many_children(): """test method.""" # Arrange data = json.loads(""" { "title" : "main", "typeCode" : 2, "children": [ { "title":"Weitere Lesezeichen", "typeCode":2, "children": [ {"title":"title1","typeCode":1,"uri":"http://uri1.com/#more-74"}, {"title":"title2","typeCode":1,"uri":"http://uri2.com/xyz"}, {"title":"title3","typeCode":1,"uri":"http://uri3.com"} ]} ] } """) # Act items = import_firefox_json(data) # Assert result = [] for item in items: result.append(item) assert len(result) == 3 def test_load_container_no_title(): """test method.""" # Arrange data = json.loads(""" { "title" : "main", "typeCode" : 2, "children": [ { "typeCode" : 2, "children": [ {"title":"title1","typeCode":1,"uri":"http://uri.com"} ]} ] } """) # Act items = import_firefox_json(data, add_bookmark_folder_as_tag=True) # Assert result = [] for item in items: result.append(item) assert len(result) == 1 assert result[0][0] == 'http://uri.com' assert result[0][2] == ',,' def test_load_hierarchical_container_without_ignore(): """test method.""" # Arrange data = json.loads(""" { "title" : "main", "typeCode" : 2, "children": [ { "title" : "title", "typeCode" : 2, "children": [ {"title":"title1","typeCode":1,"uri":"http://uri.com"} ] }] } """) # Act items = import_firefox_json(data, add_bookmark_folder_as_tag=True) # Assert result = [] for item in items: result.append(item) assert len(result) == 1 assert result[0][0] == 'http://uri.com' assert result[0][2] == ',title,' def test_load_hierarchical_container_with_ignore(): """test method.""" # Arrange data = json.loads(""" { "title" : "main", "typeCode" : 2, "children": [ { "title" : "title", "typeCode" : 2, "root": "bookmarksMenuFolder", "children": [ { "title" : "title2", "typeCode" : 2, "children": [ {"title":"title1","typeCode":1,"uri":"http://uri1.com/#more-74"} ] }, {"title":"title4","typeCode":1,"uri":"http://uri4.com/#more-74"} ] }] } """) # Act items = import_firefox_json(data, add_bookmark_folder_as_tag=True) # Assert result = [] for item in items: result.append(item) assert len(result) == 2 assert result[0][0] == 'http://uri1.com/#more-74' assert result[1][0] == 'http://uri4.com/#more-74' assert result[0][2] == ',title2,' assert result[1][2] == ',' def test_load_separator(): """test method.""" # Arrange data = json.loads(""" { "title" : "main", "typeCode" : 2, "children": [ { "title" : "title", "typeCode" : 2, "children": [ { "title": "", "type": "text/x-moz-place-separator", "typeCode": 3 }] }] }""") # Act items = import_firefox_json(data) # Assert result = [] for item in items: result.append(item) assert len(result) == 0 def test_load_multiple_tags(): """test method.""" # Arrange data = json.loads(""" { "title" : "main", "typeCode": 2, "children": [ { "title" : "title", "typeCode": 2, "children": [ { "title" : "title1", "uri" : "http://uri1", "tags" : "tag1, tag2", "typeCode": 1, "annos" : [{ "name": "bookmarkProperties/description", "value": "desc" }] }] }] }""") # Act items = import_firefox_json(data) # Assert result = [] for item in items: result.append(item) assert len(result) == 1 assert result[0][2] == ",tag1,tag2," ================================================ FILE: tests/test_requirements.py ================================================ import pathlib import sys from typing import Any import pytest if sys.version_info >= (3, 11): import tomllib else: import tomli as tomllib ROOT_DIR = pathlib.Path(__file__).parents[1] @pytest.fixture(scope='module') def pyproject() -> dict[str, Any]: data = (ROOT_DIR / 'pyproject.toml').read_text() return tomllib.loads(data)['project'] _reqs = lambda path: [s for s in pathlib.Path(path).read_text(encoding='utf8', errors='surrogateescape').splitlines() if not s.startswith('#') and s != 'setuptools'] def test_bukuserver_requirement(pyproject: dict[str, Any]): assert sorted(_reqs('bukuserver/requirements.txt')) == sorted(pyproject['optional-dependencies']['server']) def test_buku_requirement(pyproject: dict[str, Any]): assert sorted(_reqs('requirements.txt')) == sorted(pyproject['dependencies']) ================================================ FILE: tests/test_server.py ================================================ import os from typing import Any, Dict from http import HTTPStatus import pytest import flask from click.testing import CliRunner from buku import FetchResult from bukuserver import server from bukuserver.response import Response from bukuserver.server import get_bool_from_env_var from tests.util import mock_fetch def assert_response(response, exp_res: Response, data: Dict[str, Any] = None): assert response.status_code == exp_res.status_code assert response.get_json() == exp_res.json(data=data) @pytest.mark.parametrize( 'data, exp_json', [ [None, {'status': 0, 'message': 'Success.'}], [{}, {'status': 0, 'message': 'Success.'}], [{'key': 'value'}, {'status': 0, 'message': 'Success.', 'key': 'value'}], ] ) def test_response_json(data, exp_json): assert Response.SUCCESS.json(data=data) == exp_json @pytest.mark.parametrize( 'args,word', [ ('--help', 'bukuserver'), ('--version', 'buku') ] ) def test_cli(args, word): runner = CliRunner() result = runner.invoke(server.cli, [args]) assert result.exit_code == 0 assert word in result.output @pytest.fixture def client(tmp_path): test_db = tmp_path / 'test.db' app = server.create_app(test_db.as_posix()) client = app.test_client() yield client flask.g.bukudb.close() os.remove(test_db) def test_home(client): rd = client.get('/') assert rd.status_code == 200 assert not flask.g.bukudb.get_rec_all() @pytest.mark.parametrize('method, url, exp_res, data', [ ('get', '/api/tags', Response.SUCCESS, {'tags': []}), ('get', '/api/bookmarks', Response.SUCCESS, {'bookmarks': []}), ('get', '/api/bookmarks/search?keywords=x', Response.SUCCESS, {'bookmarks': []}), ('post', '/api/bookmarks/refresh', Response.FAILURE, None), ]) def test_api_empty_db(client, method, url, exp_res, data): rd = getattr(client, method)(url) assert_response(rd, exp_res, data) @pytest.mark.parametrize('url, methods', [ ('api/tags', ['post', 'put', 'delete']), ('/api/tags/tag1', ['post']), ('api/bookmarks', ['put']), ('/api/bookmarks/1', ['post']), ('/api/bookmarks/refresh', ['get', 'put', 'delete']), ('api/bookmarks/1/refresh', ['get', 'put', 'delete']), ('/api/bookmarks/1/2', ['post']), ]) def test_api_not_allowed(client, url, methods): for method in methods: rd = getattr(client, method)(url) assert rd.status_code == HTTPStatus.METHOD_NOT_ALLOWED.value @pytest.mark.parametrize('method, url, json, exp_res', [ ('get', '/api/tags/tag1', None, Response.TAG_NOT_FOUND), ('put', '/api/tags/tag1', {'tags': ['tag2']}, Response.TAG_NOT_FOUND), ('delete', '/api/tags/tag1', None, Response.TAG_NOT_FOUND), ('get', '/api/tags/tag1,tag2', None, Response.TAG_NOT_VALID), ('put', '/api/tags/tag1,tag2', {'tags': ['tag2']}, Response.TAG_NOT_VALID), ('delete', '/api/tags/tag1,tag2', None, Response.TAG_NOT_VALID), ('get', '/api/bookmarks/1', None, Response.BOOKMARK_NOT_FOUND), ('put', '/api/bookmarks/1', {'title': 'none'}, Response.BOOKMARK_NOT_FOUND), ('delete', '/api/bookmarks/1', None, Response.BOOKMARK_NOT_FOUND), ('post', '/api/bookmarks/1/refresh', None, Response.BOOKMARK_NOT_FOUND), ('get', '/api/bookmarks/1/2', None, Response.RANGE_NOT_VALID), ('put', '/api/bookmarks/1/2', {1: {'title': 'one'}, 2: {'title': 'two'}}, Response.RANGE_NOT_VALID), ('delete', '/api/bookmarks/1/2', None, Response.RANGE_NOT_VALID), ]) def test_api_invalid_id(client, method, url, json, exp_res): rd = getattr(client, method)(url, json=json) assert_response(rd, exp_res) def test_api_tag(client): url = 'http://google.com' with mock_fetch(title='Google'): rd = client.post('/api/bookmarks', json={'url': url, 'tags': ['tag1', 'TAG2'], 'fetch': True}) assert_response(rd, Response.SUCCESS, {'index': 1}) rd = client.get('/api/tags') assert_response(rd, Response.SUCCESS, {'tags': ['tag1', 'tag2']}) rd = client.get('/api/tags/tag1') assert_response(rd, Response.SUCCESS, {'name': 'tag1', 'usage_count': 1}) rd = client.put('/api/tags/tag1', json={'tags': 'string'}) assert_response(rd, Response.INPUT_NOT_VALID, data={'errors': {'tags': ['Invalid input.']}}) for json in [{}, {'tags': None}, {'tags': ''}, {'tags':[]}]: rd = client.put('/api/tags/tag1', json={'tags': []}) assert_response(rd, Response.INPUT_NOT_VALID, data={'errors': {'tags': ['This field is required.']}}) rd = client.put('/api/tags/tag1', json={'tags': ['ok', '', None]}) errors = {'tags': [[], ['Invalid input.'], ['The value must be a string.']]} assert_response(rd, Response.INPUT_NOT_VALID, data={'errors': errors}) rd = client.put('/api/tags/tag1', json={'tags': ['one,two', 3,]}) errors = {'tags': [['Invalid input.'], ['The value must be a string.']]} assert_response(rd, Response.INPUT_NOT_VALID, data={'errors': errors}) rd = client.put('/api/tags/tag1', json={'tags': ['tag3', 'TAG 4']}) assert_response(rd, Response.SUCCESS) rd = client.get('/api/tags') assert_response(rd, Response.SUCCESS, {'tags': ['tag 4', 'tag2', 'tag3']}) rd = client.put('/api/tags/tag 4', json={'tags': ['tag5']}) assert_response(rd, Response.SUCCESS) rd = client.get('/api/tags') assert_response(rd, Response.SUCCESS, {'tags': ['tag2', 'tag3', 'tag5']}) rd = client.delete('/api/tags/tag3') assert_response(rd, Response.SUCCESS) rd = client.delete('/api/tags/tag3') assert_response(rd, Response.TAG_NOT_FOUND) rd = client.delete('/api/tags/tag,2') assert_response(rd, Response.TAG_NOT_VALID) rd = client.get('/api/bookmarks/1') assert_response(rd, Response.SUCCESS, {'description': '', 'tags': ['tag2', 'tag5'], 'title': 'Google', 'url': url}) def test_api_bookmark(client): url = 'http://google.com' rd = client.post('/api/bookmarks', json={}) errors = {'url': ['This field is required.']} assert_response(rd, Response.INPUT_NOT_VALID, data={'errors': errors}) with mock_fetch(title='Google'): rd = client.post('/api/bookmarks', json={'url': url, 'fetch': True}) assert_response(rd, Response.SUCCESS, {'index': 1}) rd = client.post('/api/bookmarks', json={'url': url, 'fetch': True}) assert_response(rd, Response.FAILURE) rd = client.get('/api/bookmarks') assert_response(rd, Response.SUCCESS, {'bookmarks': [{'description': '', 'tags': [], 'title': 'Google', 'url': url}]}) rd = client.get('/api/bookmarks/1') assert_response(rd, Response.SUCCESS, {'description': '', 'tags': [], 'title': 'Google', 'url': url}) rd = client.put('/api/bookmarks/1', json={'tags': 'not a list'}) assert_response(rd, Response.INPUT_NOT_VALID, data={'errors': {'tags': ['Invalid input.']}}) rd = client.put('/api/bookmarks/1', json={'tags': ['tag1', 'tag2']}) assert_response(rd, Response.SUCCESS) with mock_fetch(title='Google'): rd = client.put('/api/bookmarks/1', json={'fetch': True}) assert_response(rd, Response.SUCCESS) rd = client.get('/api/bookmarks/1') assert_response(rd, Response.SUCCESS, {'description': '', 'tags': ['tag1', 'tag2'], 'title': 'Google', 'url': url}) rd = client.put('/api/bookmarks/1', json={'tags': [], 'description': 'Description'}) assert_response(rd, Response.SUCCESS) rd = client.get('/api/bookmarks/1') assert_response(rd, Response.SUCCESS, {'description': 'Description', 'tags': [], 'title': 'Google', 'url': url}) @pytest.mark.parametrize('d_url', ['/api/bookmarks', '/api/bookmarks/1']) def test_api_bookmark_delete(client, d_url): url = 'http://google.com' rd = client.post('/api/bookmarks', json={'url': url, 'fetch': False}) assert_response(rd, Response.SUCCESS, {'index': 1}) rd = client.delete(d_url) assert_response(rd, Response.SUCCESS) @pytest.mark.parametrize('api_url', ['/api/bookmarks/refresh', '/api/bookmarks/1/refresh']) def test_api_bookmark_refresh(client, api_url): url = 'http://google.com' with mock_fetch(title='Google'): rd = client.post('/api/bookmarks', json={'url': url}) assert_response(rd, Response.SUCCESS, {'index': 1}) rd = client.post(api_url) assert_response(rd, Response.SUCCESS) rd = client.get('/api/bookmarks/1') assert_response(rd, Response.SUCCESS, {'description': '', 'tags': [], 'title': 'Google', 'url': url}) @pytest.mark.parametrize('kwargs, kwmock, exp_res, data', [ ( {'data': {'url': 'http://google.com'}}, {'title': 'Google', 'fetch_status': 200}, Response.SUCCESS, {'bad url': 0, 'recognized mime': 0, 'tags': '', 'title': 'Google'} ), ({}, {}, Response.INPUT_NOT_VALID, {'errors': {'url': ['This field is required.']}}), ( {'data': {'url': 'chrome://bookmarks/'}}, {'bad': True}, Response.SUCCESS, {'bad url': 1, 'recognized mime': 0, 'tags': '', 'title': ''} ), ]) @pytest.mark.parametrize('endpoint', ['/api/fetch_data', '/api/network_handle']) def test_api_fetch_data(client, endpoint, kwargs, kwmock, exp_res, data): with mock_fetch(**kwmock): rd = client.post(endpoint, **kwargs) assert rd.status_code == exp_res.status_code rd_json = rd.get_json() rd_json.pop('description', None) if endpoint == '/api/fetch_data' and exp_res is Response.SUCCESS: data = FetchResult(kwargs['data']['url'], **kwmock)._asdict() assert rd_json == exp_res.json(data=data) def test_api_bookmark_range(client): bookmarks = [('http://google.com', 'Google'), ('http://example.com', 'Example Domain')] for index, (url, title) in enumerate(bookmarks, start=1): with mock_fetch(title=title): rd = client.post('/api/bookmarks', json={'url': url, 'fetch': True}) assert_response(rd, Response.SUCCESS, {'index': index}) rd = client.put('/api/bookmarks/1/2', json={ '1': {'tags': ['tag1 A', 'tag1 B', 'tag1 C']}, '2': {'tags': ['tag2']} }) assert_response(rd, Response.SUCCESS) rd = client.get('/api/bookmarks/1/2') assert_response(rd, Response.SUCCESS, {'bookmarks': { '1': {'description': '', 'tags': ['tag1 a', 'tag1 b', 'tag1 c'], 'title': 'Google', 'url': 'http://google.com'}, '2': {'description': '', 'tags': ['tag2',], 'title': 'Example Domain', 'url': 'http://example.com'}}}) rd = client.put('/api/bookmarks/1/2', json={ '1': {'title': 'Bookmark 1', 'tags': ['tag1 C', 'tag1 A'], 'del_tags': True}, '2': {'title': 'Bookmark 2', 'tags': ['-', 'tag2'], 'del_tags': False} }) assert_response(rd, Response.SUCCESS) rd = client.get('/api/bookmarks/1/2') assert_response(rd, Response.SUCCESS, {'bookmarks': { '1': {'description': '', 'tags': ['tag1 b'], 'title': 'Bookmark 1', 'url': 'http://google.com'}, '2': {'description': '', 'tags': ['-', 'tag2',], 'title': 'Bookmark 2', 'url': 'http://example.com'}}}) rd = client.put('/api/bookmarks/2/1', json={}) assert_response(rd, Response.RANGE_NOT_VALID) rd = client.put('/api/bookmarks/1/2', json={}) assert_response(rd, Response.INPUT_NOT_VALID, data={ 'errors': { '1': 'Input required.', '2': 'Input required.' } }) rd = client.put('/api/bookmarks/1/2', json={'1': {'tags': []}}) assert_response(rd, Response.INPUT_NOT_VALID, data={'errors': {'2': 'Input required.'}}) rd = client.put('/api/bookmarks/1/2', json={ '1': {'tags': ['ok', 'with,delim']}, '2': {'tags': 'string'}, }) assert_response(rd, Response.INPUT_NOT_VALID, data={ 'errors': { '1': {'tags': [[], ['Invalid input.']]}, '2': {'tags': ['Invalid input.']} } }) rd = client.get('/api/bookmarks/2/1') assert_response(rd, Response.RANGE_NOT_VALID) rd = client.delete('/api/bookmarks/1/2') assert_response(rd, Response.SUCCESS) rd = client.get('/api/bookmarks') assert_response(rd, Response.SUCCESS, {'bookmarks': []}) def test_api_bookmark_search(client): with mock_fetch(title='Google'): rd = client.post('/api/bookmarks', json={'url': 'http://google.com', 'fetch': True}) assert_response(rd, Response.SUCCESS, {'index': 1}) rd = client.get('/api/bookmarks/search', query_string={'keywords': ['google']}) assert_response(rd, Response.SUCCESS, {'bookmarks': [ {'description': '', 'index': 1, 'tags': [], 'title': 'Google', 'url': 'http://google.com'}]}) rd = client.delete('/api/bookmarks/search', data={'keywords': ['google']}) assert_response(rd, Response.SUCCESS, {'deleted': 1}) rd = client.get('/api/bookmarks') assert_response(rd, Response.SUCCESS, {'bookmarks': []}) @pytest.mark.parametrize('env_val, exp_val', [ ['true', True], ['false', False], ['0', False], ['1', True], [None, True], ['random', True] ]) def test_get_bool_from_env_var(monkeypatch, env_val, exp_val): key = 'BUKUSERVER_TEST' if env_val is not None: monkeypatch.setenv(key, env_val) assert get_bool_from_env_var(key, True) == exp_val ================================================ FILE: tests/test_views.py ================================================ """test for views. resources: https://flask.palletsprojects.com/en/2.2.x/testing/ """ import os from argparse import Namespace from unittest import mock import pytest import flask from flask import request from lxml import etree from werkzeug.datastructures import MultiDict from buku import BukuDb from bukuserver import server from bukuserver.views import BookmarkModelView, TagModelView, filter_key from tests.util import mock_fetch, _add_rec @pytest.fixture() def dbfile(tmp_path): return (tmp_path / "test.db").as_posix() @pytest.fixture() def app(dbfile): app = server.create_app(dbfile) app.config.update({'TESTING': True, 'WTF_CSRF_ENABLED': False}) # other setup can go here yield app # clean up / reset resources here flask.g.bukudb.close() if os.path.exists(dbfile): os.remove(dbfile) def env_fixture(name, **kwargs): # place this fixture BEFORE app or its dependencies """Produces a fixture that mocks a test parameter directly in an env var (before app init)""" def _env_fixture(dbfile, monkeypatch, request): if request.param is not None: # default value placeholder monkeypatch.setenv(name, str(request.param)) app = server.create_app(dbfile) app.config.update({'TESTING': True, 'WTF_CSRF_ENABLED': False}) yield request.param flask.g.bukudb.close() if os.path.exists(dbfile): os.remove(dbfile) return pytest.fixture(**kwargs)(_env_fixture) @pytest.fixture() def client(app): return app.test_client() @pytest.fixture() def runner(app): return app.test_cli_runner() @pytest.fixture() def bukudb(dbfile): bdb = BukuDb(dbfile=dbfile) yield bdb bdb.close() if os.path.exists(dbfile): os.remove(dbfile) @pytest.fixture def tmv_instance(bukudb): """define tag model view instance""" return TagModelView(bukudb) @pytest.fixture def bmv_instance(bukudb): """define tag model view instance""" return BookmarkModelView(bukudb) @pytest.mark.parametrize('idx, char', [('', ''), (0, '0'), (9, '9'), (10, 'A'), (35, 'Z'), (36, 'a'), (61, 'z')]) def test_filter_key(idx, char): with mock.patch('bukuserver.views.BookmarkModelView._filter_arg', return_value='filter_name'): assert filter_key(None, idx) == f'flt{char}_filter_name' @pytest.mark.parametrize('disable_favicon', [False, True]) def test_bookmark_model_view(bukudb, disable_favicon, app): inst = BookmarkModelView(bukudb) model = Namespace(description="randomdesc", id=1, tags="tags1", title="Example Domain", url="http://example.com") app.config["BUKUSERVER_DISABLE_FAVICON"] = disable_favicon with app.test_request_context(): assert inst._list_entry(None, model, "Entry") def test_tag_model_view_get_list_empty_db(tmv_instance): res = tmv_instance.get_list(None, None, None, None, []) assert res == (0, []) @pytest.mark.parametrize( "sort_field, sort_desc, filters, exp_res", [ [None, False, [], (0, [])], [None, False, [(0, "name", "t2")], (0, [])], ["name", False, [], (0, [])], ["name", True, [], (0, [])], ["usage_count", True, [], (0, [])], ], ) def test_tag_model_view_get_list(tmv_instance, sort_field, sort_desc, filters, exp_res): _add_rec(tmv_instance.bukudb, 'http://example.com/1.jpg', tags_in='t1,t2,t3') _add_rec(tmv_instance.bukudb, 'http://example.com/2.jpg', tags_in='t2,t3') _add_rec(tmv_instance.bukudb, 'http://example.com/3.jpg', tags_in='t3') res = tmv_instance.get_list(0, sort_field, sort_desc, None, filters) assert res == exp_res @pytest.mark.parametrize('url, backlink', [ ['http://example.com', None], ['http://example.com', '/bookmark/'], ]) def test_bmv_create_form(bmv_instance, url, backlink, app): with app.test_request_context(): request.args = MultiDict({'link': url, 'url': backlink} if backlink else {'link': url}) form = bmv_instance.create_form() assert form.url.data == url # # -= functional tests =- # xpath_alert = lambda kind, message: f'//div[@class="alert alert-{kind} alert-dismissable"][contains(., "{message}")]' xpath_cls = lambda s: ''.join(f'[contains(concat(" ", @class, " "), " {s} ")]' for s in s.split(' ') if s) def assert_success_alert(dom, edit, id=1): message = f'Record was successfully {"saved" if edit else "created"}.' assert dom.xpath(xpath_alert('success', message)), 'alert missing' assert dom.xpath(f'//script[contains(., "const SUCCESS = [")][contains(., "{message}")][contains(., "/bookmark/details/?id={id}&")]') def assert_failure_alert(dom, edit): assert dom.xpath(xpath_alert('danger', f'Failed to {"update" if edit else "create"} record. Duplicate URL')), 'alert missing' assert not dom.xpath('//script[contains(., "const SUCCESS = [")][contains(., "/bookmark/details/?id=")]') def assert_response(response, uri, *, status=200, argnames=None, args=None): assert response.status_code == status assert response.request.path == uri if argnames is not None: assert set(response.request.args) == set(argnames) if args is not None: assert dict(response.request.args) == args return etree.HTML(response.text) def assert_bookmark(bookmark, query, tags=None): assert bookmark.url == query['link'] assert bookmark.title == query['title'] assert bookmark.desc == query['description'] assert bookmark.tags == tags or query['tags'] @pytest.mark.gui @pytest.mark.slow @pytest.mark.parametrize('exists, uri, tab, args', [ (False, '/bookmark/new/', 'Create', ['link', 'title', 'description', 'popup']), (True, '/bookmark/edit/', 'Edit', ['id', 'popup']), ]) def test_bookmarklet_view(bukudb, client, exists, uri, tab, args): query = {'url': 'http://example.com', 'title': 'Sample site', 'description': 'Foo bar baz'} if exists: _add_rec(bukudb, query['url']) response = client.get('/bookmarklet', query_string=query, follow_redirects=True) dom = assert_response(response, uri, argnames=args) assert dom.xpath(f'//ul{xpath_cls("nav nav-tabs")}//a{xpath_cls("nav-link active")}/text()') == [tab] assert dom.xpath('//input[@name="link"]/@value') == [query['url']] assert bool(dom.xpath('//input[@name="id"]')) == exists @pytest.mark.gui @pytest.mark.slow @pytest.mark.parametrize('fetch, title, desc', [ (True, 'Some title', ''), (True, '', 'Some description'), (False, 'Some title', ''), (False, '', 'Some description'), (None, 'Some title', ''), (None, '', 'Some description'), ]) def test_create_and_fetch(bukudb, monkeypatch, client, fetch, title, desc): query = {'link': 'http://example.com', 'title': title, 'description': desc, 'tags': 'foo, bar, baz'} _title, _desc = 'Fetched title', 'Fetched description' if fetch is not None: query['fetch'] = 'on' if fetch else '' with mock_fetch(title=_title, desc=_desc): response = client.post('/bookmark/new/', data=query, follow_redirects=True) dom = assert_response(response, '/bookmark/') assert_success_alert(dom, edit=False) [bookmark] = bukudb.get_rec_all() assert_bookmark(bookmark, { 'link': query['link'], 'tags': ',bar,baz,foo,', 'title': (title or _title) if fetch or fetch is None else title, # defaults to True 'description': (desc or _desc) if fetch or fetch is None else desc, }) @pytest.mark.gui @pytest.mark.slow @pytest.mark.parametrize('redirect, uri, args', [ ('_add_another', '/bookmark/new/', {}), ('_continue_editing', '/bookmark/edit/', {'id': '1', 'url': '/bookmark/'}), ]) def test_create_redirect(client, redirect, uri, args): query = {'link': 'http://example.com', 'title': '', 'description': '', 'tags': '', 'fetch': '', redirect: 'on'} response = client.post('/bookmark/new/', data=query, follow_redirects=True) dom = assert_response(response, uri, args=args) assert_success_alert(dom, edit=False) @pytest.mark.gui @pytest.mark.slow def test_create_duplicate(bukudb, client): query = {'link': 'http://example.com', 'title': '', 'description': '', 'tags': ''} _add_rec(bukudb, query['link']) response = client.post('/bookmark/new/', data=query, follow_redirects=True) dom = assert_response(response, '/bookmark/new/') assert_failure_alert(dom, edit=False) @pytest.mark.gui @pytest.mark.slow @pytest.mark.parametrize('override', [False, True]) def test_update(bukudb, client, override): _add_rec(bukudb, 'http://example.org') query = {'link': 'http://example.com', 'title': 'Sample site', 'description': 'Foo bar baz', 'tags': 'foo, bar, baz'} if override: _add_rec(bukudb, query['link']) response = client.post('/bookmark/edit/', query_string={'id': 1}, data=query, follow_redirects=True) if override: dom = assert_response(response, '/bookmark/edit/') assert_failure_alert(dom, edit=True) else: dom = assert_response(response, '/bookmark/') assert_success_alert(dom, edit=True) [bookmark] = bukudb.get_rec_all() assert_bookmark(bookmark, query, tags=',bar,baz,foo,') @pytest.mark.gui @pytest.mark.slow @pytest.mark.parametrize('redirect, uri, args', [ ('_add_another', '/bookmark/new/', {'url': '/bookmark/'}), ('_continue_editing', '/bookmark/edit/', {'id': '1', 'url': '/bookmark/'}), ]) def test_update_redirect(bukudb, client, redirect, uri, args): _add_rec(bukudb, 'http://example.org') query = {'link': 'http://example.com', 'title': 'Sample site', 'description': 'Foo bar baz', 'tags': 'foo, bar, baz', redirect: 'on'} response = client.post('/bookmark/edit/', query_string={'id': 1}, data=query, follow_redirects=True) dom = assert_response(response, uri, args=args) assert_success_alert(dom, edit=True) [bookmark] = bukudb.get_rec_all() assert_bookmark(bookmark, query, tags=',bar,baz,foo,') @pytest.mark.gui @pytest.mark.slow @pytest.mark.parametrize('exists', [True, False]) def test_delete(client, bukudb, exists): if exists: _add_rec(bukudb, 'http://example.com') response = client.post('/bookmark/delete/', data={'id': 1}, follow_redirects=True) dom = assert_response(response, '/bookmark/') assert dom.xpath(xpath_alert('success', 'Record was successfully deleted.') if exists else xpath_alert('danger', 'Record does not exist.')) @pytest.mark.gui @pytest.mark.slow @pytest.mark.parametrize('total, per_page, pages, last_page', [ (0, 5, 1, 0), (1, 5, 1, 1), (5, 5, 1, 5), (6, 5, 2, 1), (9, 5, 2, 4), (10, 5, 2, 5), (11, 5, 3, 1), (9, None, 1, 9), (10, None, 1, 10), (11, None, 2, 1), (14, 15, 1, 14), (15, 15, 1, 15), (16, 15, 2, 1), ]) def test_env_per_page(bukudb, app, client, total, per_page, pages, last_page): for i in range(1, total+1): _add_rec(bukudb, f'http://example.com/{i}') if per_page: app.config.update({'BUKUSERVER_PER_PAGE': per_page}) response = client.get('/bookmark/last-page', follow_redirects=True) dom = assert_response(response, '/bookmark/', args={'page': str(pages - 1)}) cells = dom.xpath(f'//td{xpath_cls("col-entry")}') assert len(cells) == last_page for i, cell in enumerate(cells, start=1): url = f'http://example.com/{total - last_page + i}' assert cell.xpath(f'//a[@href="{url}"]/text()') == ['', url] @pytest.mark.gui @pytest.mark.slow @pytest.mark.parametrize('new_tab', [False, True, None]) @pytest.mark.parametrize('favicons', [False, True, None]) @pytest.mark.parametrize('mode', ['full', 'netloc', 'netloc-tag', None]) def test_env_entry_render_params(bukudb, app, client, mode, favicons, new_tab): _test_env_entry_render_params(bukudb, app, client, mode, favicons, new_tab, 'http://example.com', 'example.com', 'Sample site') @pytest.mark.parametrize('url, netloc, title', [ ('http://example.com', 'example.com', ''), ('javascript:void(0)', '', 'Sample site'), ('javascript:void(0)', '', ''), ]) @pytest.mark.parametrize('mode', ['full', 'netloc', 'netloc-tag']) def test_env_entry_render_params_blanks(bukudb, app, client, mode, url, netloc, title): _test_env_entry_render_params(bukudb, app, client, mode, True, True, url, netloc, title) def _test_env_entry_render_params(bukudb, app, client, mode, favicons, new_tab, url, netloc, title): desc, tags = 'Foo bar baz', ',bar,baz,foo,' _add_rec(bukudb, url, title, tags, desc) _tags = tags.strip(',').split(',') if mode: app.config.update({'BUKUSERVER_URL_RENDER_MODE': mode}) if favicons is not None: app.config.update({'BUKUSERVER_DISABLE_FAVICON': not favicons}) if new_tab is not None: app.config.update({'BUKUSERVER_OPEN_IN_NEW_TAB': new_tab}) dom = assert_response(client.get('/bookmark/'), '/bookmark/') cell = ' '.join(etree.tostring(dom.xpath(f'//td{xpath_cls("col-entry")}')[0], encoding='unicode').strip().split()) target = '' if not new_tab else ' target="_blank"' icon = '' if not favicons else (netloc and f' ') urltext = title or '<EMPTY TITLE>' _title = (urltext if not netloc and mode in ('full', None) else f'{urltext}') prefix = f' {icon}{_title}' tags = [f'{s}' for s in _tags] netloc_tag = ('' if mode == 'netloc' or not netloc else f'netloc:{netloc}') suffix = f'
      {netloc_tag}{"".join(tags)}
      {desc}
      ' if mode == 'netloc': _netloc = netloc and f' ({netloc})' assert cell == prefix + _netloc + suffix elif mode == 'netloc-tag': assert cell == prefix + suffix else: assert cell == f'{prefix}{url}{suffix}' readonly = env_fixture('BUKUSERVER_READONLY', params=[False, True, None]) @pytest.mark.gui @pytest.mark.slow def test_env_readonly(bukudb, readonly, client): _add_rec(bukudb, 'http://example.com') edit = not readonly response = client.get('/bookmark/') dom = assert_response(response, '/bookmark/') assert bool(dom.xpath(f'//td{xpath_cls("list-buttons-column")}/a[@title="Edit Record"]')) == edit, 'edit icon' assert bool(dom.xpath(f'//td{xpath_cls("list-buttons-column")}/form[@action="/bookmark/delete/"]')) == edit, 'delete icon' response = client.get('/bookmark/details/', query_string={'id': 1}) dom = assert_response(response, '/bookmark/details/') assert (dom.xpath(f'//ul{xpath_cls("nav nav-tabs")}/li/a/text()') == (['List', 'Details'] if readonly else ['List', 'Create', 'Edit', 'Details'])) response = client.get('/bookmark/new/', follow_redirects=True) assert_response(response, '/bookmark/' if readonly else '/bookmark/new/') response = client.get('/bookmark/edit/', query_string={'id': 1}, follow_redirects=True) assert_response(response, '/bookmark/' if readonly else '/bookmark/edit/') proxy_path = env_fixture('BUKUSERVER_REVERSE_PROXY_PATH', params=['', '/buku', None]) @pytest.mark.gui @pytest.mark.slow def test_env_reverse_proxy_path(proxy_path, client): links = [(proxy_path or '') + s for s in ['/', '/bookmark/', '/tag/', '/statistic/']] dom = assert_response(client.get(links[0]), links[0]) assert dom.xpath(f'//nav{xpath_cls("navbar")}//a/@href ') == ['/'] + links body_links = dom.xpath('//main//a/@href') assert body_links[-1].startswith('javascript:') assert body_links[:-1] == links[1:] assert dom.xpath('//main//form/@action') == [links[0]] for link in links[1:]: assert_response(client.get(link), link) theme = env_fixture('BUKUSERVER_THEME', params=['default', 'slate', None]) @pytest.mark.gui @pytest.mark.slow def test_env_theme(theme, client): dom = assert_response(client.get('/'), '/') assert dom.xpath('//head/link[@rel="stylesheet"][starts-with(@href, $href)]', href=f'/static/admin/bootstrap/bootstrap4/swatch/{theme or "default"}/bootstrap.min.css?') _DICT = { 'en': {f'//ul{xpath_cls("nav navbar-nav")}/li/a/text()': ['Home', 'Bookmarks', 'Tags', 'Statistic'], f'//ul{xpath_cls("nav nav-tabs")}/li/a/text()': ['List (1)', 'Create', 'Random', 'Reorder', 'Add Filter', '10 items'], f'//td{xpath_cls("list-buttons-column")}/a/@title': ['View Record', 'Edit Record'], f'//td{xpath_cls("list-buttons-column")}/form/button/@title': ['Delete Record']}, 'de': {f'//ul{xpath_cls("nav navbar-nav")}/li/a/text()': ['Start', 'Lesezeichen', 'Schilder', 'Statistik'], f'//ul{xpath_cls("nav nav-tabs")}/li/a/text()': ['Liste (1)', 'Erstellen', 'Zufälliger', 'Neu anordnen', 'Filter hinzufügen', '10 Elemente'], f'//td{xpath_cls("list-buttons-column")}/a/@title': ['Eintrag ansehen', 'Eintrag bearbeiten'], f'//td{xpath_cls("list-buttons-column")}/form/button/@title': ['Datenzatz löschen']}, 'fr': {f'//ul{xpath_cls("nav navbar-nav")}/li/a/text()': ['Accueil', 'Signets', 'Étiquettes', 'Statistique'], f'//ul{xpath_cls("nav nav-tabs")}/li/a/text()': ['Liste (1)', 'Créer', 'Aléatoire', 'Réorganiser', 'Ajouter un filtre', '10 articles'], f'//td{xpath_cls("list-buttons-column")}/a/@title': ['Afficher L\'enregistrement', 'Modifier enregistrement'], f'//td{xpath_cls("list-buttons-column")}/form/button/@title': ['Supprimer l\'enregistrement']}, 'ru': {f'//ul{xpath_cls("nav navbar-nav")}/li/a/text()': ['Главная', 'Закладки', 'Теги', 'Статистика'], f'//ul{xpath_cls("nav nav-tabs")}/li/a/text()': ['Список (1)', 'Создать', 'Случайная', 'Изменить порядок', 'Добавить Фильтр', '10 элементы'], f'//td{xpath_cls("list-buttons-column")}/a/@title': ['Просмотр записи', 'Редактировать запись'], f'//td{xpath_cls("list-buttons-column")}/form/button/@title': ['Удалить запись']}, } locale = env_fixture('BUKUSERVER_LOCALE', params=['en', 'de', 'fr', 'ru', None]) @pytest.mark.gui @pytest.mark.slow def test_env_locale(bukudb, locale, client): strings = _DICT[locale or 'en'] _add_rec(bukudb, 'http://example.com') dom = assert_response(client.get('/bookmark/'), '/bookmark/') for k, v in strings.items(): assert [s.strip() for s in dom.xpath(k) if s.strip()] == v ================================================ FILE: tests/util.py ================================================ from unittest import mock import os from urllib3 import HTTPResponse from buku import FetchResult def mock_http(body=None, **kwargs): body = (None if not body else str(body).encode('UTF-8')) return mock.patch('urllib3.PoolManager.request', return_value=HTTPResponse(body, **kwargs)) def mock_fetch(custom=None, **kwargs): _url = kwargs.pop('url', None) status = kwargs.pop('fetch_status', (None if kwargs.get('bad') else 200)) fn = lambda url, http_head=False: FetchResult(_url or url, fetch_status=status, **kwargs) return mock.patch('buku.fetch_data', side_effect=custom or fn) def _add_rec(db, *args, **kw): """Use THIS instead of db.add_rec() UNLESS you want to wait for unnecessary network requests.""" return db.add_rec(*args, fetch=False, **kw) def _tagset(s): return set(x for x in str(s or '').lower().split(',') if x) def append(buffer, text): pos = buffer.tell() try: buffer.seek(0, os.SEEK_END) return buffer.write(text) finally: buffer.seek(pos) ================================================ FILE: tests/vcr_cassettes/test_browse_by_index.yaml ================================================ interactions: - request: body: null headers: Accept: ['*/*'] Accept-Encoding: ['gzip,deflate'] Cookie: [''] DNT: ['1'] User-Agent: ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0'] method: GET uri: https://www.google.com/ncr response: body: {string: "\n302 Moved\n

      302 Moved

      \nThe document\ \ has moved\nhere.\r\n\r\ \n"} headers: Alt-Svc: ['quic=":443"; ma=2592000; v="44,43,39,35"'] Cache-Control: [private] Content-Length: ['220'] Content-Type: [text/html; charset=UTF-8] Date: ['Mon, 29 Oct 2018 14:17:10 GMT'] Location: ['https://www.google.com/'] P3P: [CP="This is not a P3P policy! See g.co/p3phelp for more info."] Server: [gws] Set-Cookie: ['1P_JAR=2018-10-29-14; expires=Wed, 28-Nov-2018 14:17:10 GMT; path=/; domain=.google.com', 'NID=144=nmrNRCo8ZiIdTt7bdJgwALmtWLPM-abnCScUe00VBvWqOCXna9soY-_3T6sRrVwS2VH3GgZJ5wqkpuL5pQ8rDaZ9e3xpjeNEvy7EuGoBoj9JTxAQr2cGdzsTKvJmJIAtyn3eCTIm0Ep_oyffK6bvY9OO2brG1R_WASXKLFGSVeY; expires=Tue, 30-Apr-2019 14:17:10 GMT; path=/; domain=.google.com; HttpOnly'] Strict-Transport-Security: [max-age=604800] X-Frame-Options: [SAMEORIGIN] X-XSS-Protection: [1; mode=block] status: {code: 302, message: Found} - request: body: null headers: Accept: ['*/*'] Accept-Encoding: ['gzip,deflate'] Cookie: [''] DNT: ['1'] User-Agent: ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0'] method: GET uri: https://www.google.com/ response: body: string: !!binary | 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 SNbzZ3wtmtVy+gMAAP//AwAKirftYzoDAA== headers: Alt-Svc: ['quic=":443"; ma=2592000; v="44,43,39,35"'] Cache-Control: ['private, max-age=0'] Content-Encoding: [gzip] Content-Type: [text/html; charset=UTF-8] Date: ['Mon, 29 Oct 2018 14:17:11 GMT'] Expires: ['-1'] P3P: [CP="This is not a P3P policy! See g.co/p3phelp for more info."] Server: [gws] Set-Cookie: ['1P_JAR=2018-10-29-14; expires=Wed, 28-Nov-2018 14:17:11 GMT; path=/; domain=.google.com', 'NID=144=jV4dlrQX3-8b60O9UydYFCK_463W8HCKJI3DwjzVfxLfm77Saiomv9Rfo6lRrA_1203d78XOCP3j20fKOgbTqde_SY8mP2z7sTUNY5aXgtVZc5NhxEZtPsw_IpBNsRtm3DE7yvxLAX7KLF-WQ8QtOv6uIRxbpon-0jcYH2BCVKI; expires=Tue, 30-Apr-2019 14:17:11 GMT; path=/; domain=.google.com; HttpOnly'] Strict-Transport-Security: [max-age=604800] X-Frame-Options: [SAMEORIGIN] X-XSS-Protection: [1; mode=block] status: {code: 200, message: OK} version: 1 ================================================ FILE: tests/vcr_cassettes/test_delete_rec_range_and_delay_commit.yaml ================================================ interactions: - request: body: null headers: Accept: ['*/*'] Accept-Encoding: ['gzip,deflate'] Cookie: [''] DNT: ['1'] User-Agent: ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0'] method: GET uri: http://slashdot.org/ response: body: {string: "\r\n301 Moved Permanently\r\n\ \r\n

      301 Moved Permanently

      \r\ \n
      nginx/1.13.12
      \r\n\r\n\r\n"} headers: Connection: [keep-alive] Content-Length: ['186'] Content-Type: [text/html] Date: ['Mon, 29 Oct 2018 13:06:23 GMT'] Location: ['https://slashdot.org/'] Server: [nginx/1.13.12] status: {code: 301, message: Moved Permanently} - request: body: null headers: Accept: ['*/*'] Accept-Encoding: ['gzip,deflate'] Cookie: [''] DNT: ['1'] User-Agent: ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0'] method: GET uri: https://slashdot.org/ response: body: string: !!binary | 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 headers: Cache-Control: [no-cache] Connection: [keep-alive] Content-Encoding: [gzip] Content-Type: [text/html; charset=utf-8] Date: ['Mon, 29 Oct 2018 13:06:25 GMT'] Pragma: [no-cache] SLASH_LOG_DATA: [shtml] Server: [nginx/1.13.12] Strict-Transport-Security: [max-age=31536000] X-XRDS-Location: ['https://slashdot.org/slashdot.xrds'] status: {code: 200, message: OK} - request: body: null headers: Accept: ['*/*'] Accept-Encoding: ['gzip,deflate'] Cookie: [''] DNT: ['1'] User-Agent: ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0'] method: GET uri: "http://www.za\u017C\xF3\u0142\u0107g\u0119\u015Bl\u0105ja\u017A\u0144.pl/" response: body: string: !!binary | H4sIAAAAAAAAA51WzW7bRhA+J0DeYcpDTpLYuC2a2pSKWk7rFHFstGqM+FIMyZW0/Nlldpdhl6/R Hg3kGQIDPhnw2/Tgm6EX6CwpQorsqnUAgT/ab2a++fablYIvDo7Hk7cnL+BwcvQKTn7bf/VyDH/2 ff/0q7HvH0wO2oWvB18+g4lCobnhUmDm+y9e/zV68jiYmzxr7gxjdzfcZGx0hteLi79hdnOZfYQE r24Dv10gRM4MwtyYos/elfz90BtLYZgw/YktmAdR+zb0DPvD+C79HkA0R6WZGXIt+8+ff/Ndf8dz qfxl1UdBKGPrHh4FBWhjM9bG9zHjM7ELEWVkas9rIIQePX1XSrP3AwjOKHcCApWMZcUZVNexhUJq o6QQHGqBycce8Gs4kRlG1kVQX5o33+lqkUByU9sUcsIFfpc58IstdBSfzU3H5oin8oIo/MKS7VF3 mlipfN7I/OHqduAoBKECv0W9xiREFdecJa4VYXvUapKqhdE1PRuWagM1UuOK+FN7FX2vTUpNZpyq WdAFi+yUR7VgEGdI4bpmM9l2jY0cCaMkAW2qYtOh57Z21/eLbFDxlBcs5jiQaua7N/8ExUxh7lGa nNo77RAeGFQz2mHv9zBDkXqjokWyPPBxNHiIMmcyxKj+lJAmRlVVDawsTRmyQSRzv0ITzb9/P3z5 Zn//29Nnhztv3na86k7Z88tOWVqBZfRdspVN8PyStKCP4wuFklOmpULYV5hFjVR0zVutGtl3obXK Zq2rD7ftwoOafipCXfyH7T6NGNc32kioQKcX6BwCqcyLkhCyIqKxpK1XWMU1Co49xx5iMhBMaUDd gHCMke6ZXjML7WSqrLHOLT0or22FYumPmoI524V682gYwM+NJnLlTNZ4lS1jepCahWKdTdcsuqX6 MqsUklCuD0rn/ITNMsVgxhrz5giaCV3SNUfRBs+kmyPV6tSxK7MypLOvYwWxKlNUtX3QNm1OifNk zFKpwp1wUGSd/w5LZWRFutM2FHrOi4KL2V3b3Qtz/vvXWjXTOZJkuZuArtrxlCk6k8/Yr+trm8UO HE2MErYCbi0lQ4W1NcoWxqZ8rbmfSHpFnBvAooLjBjhZB27W3gxx2kvylo3mWzkYLJixlxENJlli ReFHaWS75saa7M5R3a26hppmvGYZF7Jiq4itpXMZC0YnERlkTerNRo4cqhXgftU/t/Nl+SYxK/9P eYf6jOor999zENEUtT/Mgb/8o/Dk8T9lh+FvdAgAAA== headers: Accept-Ranges: [bytes] Content-Encoding: [gzip] Content-Length: ['943'] Content-Type: [text/html] Date: ['Mon, 29 Oct 2018 13:06:34 GMT'] Server: [Apache] Set-Cookie: ['startBAK=R3415748110; path=/; expires=Mon, 29-Oct-2018 14:14:08 GMT', 'start=R118851658; path=/; expires=Mon, 29-Oct-2018 14:08:37 GMT'] Vary: [Accept-Encoding] X-IPLB-Instance: ['17350'] status: {code: 200, message: OK} - request: body: null headers: Accept: ['*/*'] Accept-Encoding: ['gzip,deflate'] Cookie: [''] DNT: ['1'] User-Agent: ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0'] method: GET uri: http://slashdot.org/ response: body: {string: "\r\n301 Moved Permanently\r\n\ \r\n

      301 Moved Permanently

      \r\ \n
      nginx/1.13.12
      \r\n\r\n\r\n"} headers: Connection: [keep-alive] Content-Length: ['186'] Content-Type: [text/html] Date: ['Mon, 29 Oct 2018 13:07:42 GMT'] Location: ['https://slashdot.org/'] Server: [nginx/1.13.12] status: {code: 301, message: Moved Permanently} - request: body: null headers: Accept: ['*/*'] Accept-Encoding: ['gzip,deflate'] Cookie: [''] DNT: ['1'] User-Agent: ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0'] method: GET uri: https://slashdot.org/ response: body: string: !!binary | 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 4vv/H2SqXHtnXQIA headers: Cache-Control: [no-cache] Connection: [keep-alive] Content-Encoding: [gzip] Content-Type: [text/html; charset=utf-8] Date: ['Mon, 29 Oct 2018 13:07:46 GMT'] Pragma: [no-cache] SLASH_LOG_DATA: [shtml] Server: [nginx/1.13.12] Strict-Transport-Security: [max-age=31536000] X-XRDS-Location: ['https://slashdot.org/slashdot.xrds'] status: {code: 200, message: OK} - request: body: null headers: Accept: ['*/*'] Accept-Encoding: ['gzip,deflate'] Cookie: [''] DNT: ['1'] User-Agent: ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0'] method: GET uri: "http://www.za\u017C\xF3\u0142\u0107g\u0119\u015Bl\u0105ja\u017A\u0144.pl/" response: body: string: !!binary | H4sIAAAAAAAAA51WzW7bRhA+J0DeYcpDTpLYuC2a2pSKWk7rFHFstGqM+FIMyZW0/Nlldpdhl6/R Hg3kGQIDPhnw2/Tgm6EX6CwpQorsqnUAgT/ab2a++fablYIvDo7Hk7cnL+BwcvQKTn7bf/VyDH/2 ff/0q7HvH0wO2oWvB18+g4lCobnhUmDm+y9e/zV68jiYmzxr7gxjdzfcZGx0hteLi79hdnOZfYQE r24Dv10gRM4MwtyYos/elfz90BtLYZgw/YktmAdR+zb0DPvD+C79HkA0R6WZGXIt+8+ff/Ndf8dz qfxl1UdBKGPrHh4FBWhjM9bG9zHjM7ELEWVkas9rIIQePX1XSrP3AwjOKHcCApWMZcUZVNexhUJq o6QQHGqBycce8Gs4kRlG1kVQX5o33+lqkUByU9sUcsIFfpc58IstdBSfzU3H5oin8oIo/MKS7VF3 mlipfN7I/OHqduAoBKECv0W9xiREFdecJa4VYXvUapKqhdE1PRuWagM1UuOK+FN7FX2vTUpNZpyq WdAFi+yUR7VgEGdI4bpmM9l2jY0cCaMkAW2qYtOh57Z21/eLbFDxlBcs5jiQaua7N/8ExUxh7lGa nNo77RAeGFQz2mHv9zBDkXqjokWyPPBxNHiIMmcyxKj+lJAmRlVVDawsTRmyQSRzv0ITzb9/P3z5 Zn//29Nnhztv3na86k7Z88tOWVqBZfRdspVN8PyStKCP4wuFklOmpULYV5hFjVR0zVutGtl3obXK Zq2rD7ftwoOafipCXfyH7T6NGNc32kioQKcX6BwCqcyLkhCyIqKxpK1XWMU1Co49xx5iMhBMaUDd gHCMke6ZXjML7WSqrLHOLT0or22FYumPmoI524V682gYwM+NJnLlTNZ4lS1jepCahWKdTdcsuqX6 MqsUklCuD0rn/ITNMsVgxhrz5giaCV3SNUfRBs+kmyPV6tSxK7MypLOvYwWxKlNUtX3QNm1OifNk zFKpwp1wUGSd/w5LZWRFutM2FHrOi4KL2V3b3Qtz/vvXWjXTOZJkuZuArtrxlCk6k8/Yr+trm8UO HE2MErYCbi0lQ4W1NcoWxqZ8rbmfSHpFnBvAooLjBjhZB27W3gxx2kvylo3mWzkYLJixlxENJlli ReFHaWS75saa7M5R3a26hppmvGYZF7Jiq4itpXMZC0YnERlkTerNRo4cqhXgftU/t/Nl+SYxK/9P eYf6jOor999zENEUtT/Mgb/8o/Dk8T9lh+FvdAgAAA== headers: Accept-Ranges: [bytes] Content-Encoding: [gzip] Content-Length: ['943'] Content-Type: [text/html] Date: ['Mon, 29 Oct 2018 13:07:48 GMT'] Server: [Apache] Set-Cookie: ['startBAK=R3415744843; path=/; expires=Mon, 29-Oct-2018 14:08:21 GMT', 'start=R118851658; path=/; expires=Mon, 29-Oct-2018 14:08:21 GMT'] Vary: [Accept-Encoding] X-IPLB-Instance: ['17521'] status: {code: 200, message: OK} version: 1 ================================================ FILE: tests/vcr_cassettes/test_search_by_multiple_tags_search_all.yaml ================================================ interactions: - request: body: null headers: Accept: ['*/*'] Accept-Encoding: ['gzip,deflate'] Cookie: [''] DNT: ['1'] User-Agent: ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0'] method: GET uri: http://slashdot.org/ response: body: {string: "\r\n301 Moved Permanently\r\n\ \r\n

      301 Moved Permanently

      \r\ \n
      nginx/1.13.12
      \r\n\r\n\r\n"} headers: Connection: [keep-alive] Content-Length: ['186'] Content-Type: [text/html] Date: ['Mon, 29 Oct 2018 13:27:43 GMT'] Location: ['https://slashdot.org/'] Server: [nginx/1.13.12] status: {code: 301, message: Moved Permanently} - request: body: null headers: Accept: ['*/*'] Accept-Encoding: ['gzip,deflate'] Cookie: [''] DNT: ['1'] User-Agent: ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0'] method: GET uri: https://slashdot.org/ response: body: string: !!binary | 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 AgA= headers: Cache-Control: [no-cache] Connection: [keep-alive] Content-Encoding: [gzip] Content-Type: [text/html; charset=utf-8] Date: ['Mon, 29 Oct 2018 13:27:45 GMT'] Pragma: [no-cache] SLASH_LOG_DATA: [shtml] Server: [nginx/1.13.12] Strict-Transport-Security: [max-age=31536000] X-XRDS-Location: ['https://slashdot.org/slashdot.xrds'] status: {code: 200, message: OK} - request: body: null headers: Accept: ['*/*'] Accept-Encoding: ['gzip,deflate'] Cookie: [''] DNT: ['1'] User-Agent: ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0'] method: GET uri: "http://www.za\u017C\xF3\u0142\u0107g\u0119\u015Bl\u0105ja\u017A\u0144.pl/" response: body: string: !!binary | H4sIAAAAAAAAA51WzW7bRhA+J0DeYcpDTpLYuC2a2pSKWk7rFHFstGqM+FIMyZW0/Nlldpdhl6/R Hg3kGQIDPhnw2/Tgm6EX6CwpQorsqnUAgT/ab2a++fablYIvDo7Hk7cnL+BwcvQKTn7bf/VyDH/2 ff/0q7HvH0wO2oWvB18+g4lCobnhUmDm+y9e/zV68jiYmzxr7gxjdzfcZGx0hteLi79hdnOZfYQE r24Dv10gRM4MwtyYos/elfz90BtLYZgw/YktmAdR+zb0DPvD+C79HkA0R6WZGXIt+8+ff/Ndf8dz qfxl1UdBKGPrHh4FBWhjM9bG9zHjM7ELEWVkas9rIIQePX1XSrP3AwjOKHcCApWMZcUZVNexhUJq o6QQHGqBycce8Gs4kRlG1kVQX5o33+lqkUByU9sUcsIFfpc58IstdBSfzU3H5oin8oIo/MKS7VF3 mlipfN7I/OHqduAoBKECv0W9xiREFdecJa4VYXvUapKqhdE1PRuWagM1UuOK+FN7FX2vTUpNZpyq WdAFi+yUR7VgEGdI4bpmM9l2jY0cCaMkAW2qYtOh57Z21/eLbFDxlBcs5jiQaua7N/8ExUxh7lGa nNo77RAeGFQz2mHv9zBDkXqjokWyPPBxNHiIMmcyxKj+lJAmRlVVDawsTRmyQSRzv0ITzb9/P3z5 Zn//29Nnhztv3na86k7Z88tOWVqBZfRdspVN8PyStKCP4wuFklOmpULYV5hFjVR0zVutGtl3obXK Zq2rD7ftwoOafipCXfyH7T6NGNc32kioQKcX6BwCqcyLkhCyIqKxpK1XWMU1Co49xx5iMhBMaUDd gHCMke6ZXjML7WSqrLHOLT0or22FYumPmoI524V682gYwM+NJnLlTNZ4lS1jepCahWKdTdcsuqX6 MqsUklCuD0rn/ITNMsVgxhrz5giaCV3SNUfRBs+kmyPV6tSxK7MypLOvYwWxKlNUtX3QNm1OifNk zFKpwp1wUGSd/w5LZWRFutM2FHrOi4KL2V3b3Qtz/vvXWjXTOZJkuZuArtrxlCk6k8/Yr+trm8UO HE2MErYCbi0lQ4W1NcoWxqZ8rbmfSHpFnBvAooLjBjhZB27W3gxx2kvylo3mWzkYLJixlxENJlli ReFHaWS75saa7M5R3a26hppmvGYZF7Jiq4itpXMZC0YnERlkTerNRo4cqhXgftU/t/Nl+SYxK/9P eYf6jOor999zENEUtT/Mgb/8o/Dk8T9lh+FvdAgAAA== headers: Accept-Ranges: [bytes] Content-Encoding: [gzip] Content-Length: ['943'] Content-Type: [text/html] Date: ['Mon, 29 Oct 2018 13:27:50 GMT'] Server: [Apache] Set-Cookie: ['startBAK=R3415777513; path=/; expires=Mon, 29-Oct-2018 14:40:11 GMT', 'start=R118851658; path=/; expires=Mon, 29-Oct-2018 14:32:10 GMT'] Vary: [Accept-Encoding] X-IPLB-Instance: ['5238'] status: {code: 200, message: OK} - request: body: null headers: Accept: ['*/*'] Accept-Encoding: ['gzip,deflate'] Cookie: [''] DNT: ['1'] User-Agent: ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0'] method: GET uri: http://example.com/ response: body: string: !!binary | H4sIADuBBVIAA41UQa/TMAy+71eYcgFpXfeAB1PXViBA4gIc4MIxa9zVWpOUJO02offfcdu9ruXt QCu1jh1//mzHSZ5Jk/tzjVB6VWWL5PGHQmYL4Cfx5CvMPp+EqiuET0YJ0kk0aBfDFoVeQF4K69Cn QeOLcBNAlE2Mpfd1iL8batPgo9EetQ+7sAHkwyoNPJ581IXfjlC3kLRQmAYt4bE21k/8jyR9mUps KcewXyyBNHkSVehyUWF6d4Vy/szJdAwugXPngsG2M/IMf3qxX4r8sLem0TLMTWVsDM+LNb+vtuMW JeyedAzrq6oWUpLez3QFMw0Loag6xxB8r1HDD6FdsITgC1YtesoFfMMGWTMqlvDBcgZLcLw1dGip uCL2wkP/ldROSPfpx/B2va5PT3neowLReHOD7v3M4VbuxST+zliJNrRCUuNiuEO1nVAScUX6sOR/ S448ygnBR7jXmzebzQSx60UoMTdWeDLMVRuNU9D3CiUJeKHEKbxk+a7L8uW0ZfMO/k8mD6M0L+Sk mPOKzfp+w/ZPadZz61jvsWRXEsM3ifojmnXyIomGeVwkXWo8nkzycpDLuyejyarBVmc/S3Igez2w hM6LXUWu5F54AzuExrFYGAtUVY3zXdVbBBwQHc8Pe+eN4gFzK/hlGs753DmBZ+Th4F3Q9dXrSL40 jYfaEiPnhktBuu8n8Fq4A6feB63RKnKODaskqkfWCd8XFos06G6NOIqOx+OKhBYrY/fREM9Fl2hB 9tVY5PCMp/oYqxWDiawHTKK+Ukl0qVs0XG9/AQiVqov2BAAA headers: Cache-Control: [max-age=604800] Content-Encoding: [gzip] Content-Length: ['606'] Content-Type: [text/html; charset=UTF-8] Date: ['Mon, 29 Oct 2018 13:28:00 GMT'] Etag: ['"1541025663+gzip"'] Expires: ['Mon, 05 Nov 2018 13:28:00 GMT'] Last-Modified: ['Fri, 09 Aug 2013 23:54:35 GMT'] Server: [ECS (oxr/83C5)] Vary: [Accept-Encoding] X-Cache: [HIT] status: {code: 200, message: OK} version: 1 ================================================ FILE: tests/vcr_cassettes/test_search_by_multiple_tags_search_any.yaml ================================================ interactions: - request: body: null headers: Accept: ['*/*'] Accept-Encoding: ['gzip,deflate'] Cookie: [''] DNT: ['1'] User-Agent: ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0'] method: GET uri: http://slashdot.org/ response: body: {string: "\r\n301 Moved Permanently\r\n\ \r\n

      301 Moved Permanently

      \r\ \n
      nginx/1.13.12
      \r\n\r\n\r\n"} headers: Connection: [keep-alive] Content-Length: ['186'] Content-Type: [text/html] Date: ['Mon, 29 Oct 2018 13:53:02 GMT'] Location: ['https://slashdot.org/'] Server: [nginx/1.13.12] status: {code: 301, message: Moved Permanently} - request: body: null headers: Accept: ['*/*'] Accept-Encoding: ['gzip,deflate'] Cookie: [''] DNT: ['1'] User-Agent: ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0'] method: GET uri: https://slashdot.org/ response: body: string: !!binary | H4sIAAAAAAAAA+ydeX8bx7Gu/6Y+xRjWCcmEALhopUT6arOjxLJ1RTo55ygOf0NgSELCFgwgirJ9 P/t93qrumQEBUpRE2nFsJZbImZ5eqqurq99a+v5n9XpyNO5160dZ2s5GyfhkmG21JqNR1h8n+9lh p5/U69vXFq4t3P/s8bePdv/n+RMrb494qG+Tbto/3Kpl/RpP76si/auaX2R9Vfr0yV2v5X4vG6d8 Px7Ws39NOm+2av9d/+5B/dGgN0zHnf1uVktag/6YtrdqT59sZe3DbKV1NBr0sq01Kqcb1+7nrVFn ON4+7vTbg+NGJ9/LJqPB3qTfGfSTrWTt3v1mKBGLJvmotVVTo/lms5k2DvJ2v9Ea9Jo01XyVN/OD 8VHWy5pv6Oxg1BzSXCfPGsNB9+Sg0+02ep1+41Ve2/7oilu94UUqyLtpftQejBuD0SF9m/THo5Mz vrNZWhxnb8fNV+mb1Ae8uH2tc5AszSPMcvLDtSTZ77x721BnOv3OeElP9KfZTMaDZJzl401+W3zc yYfd9CT57uniZrKYdo/Tk3xxJZRdfD7Z73byI+b0m7SXqcRO6PW8Ml8PDgcqM4f2ebuZt7u8b+Rv DstvHw36uThvpzUYWvWH3cF+2k0OR4PJ8IxiyVd6mXz34utqW1PUPGwPR/XWYPC6k+WNYTdU9NPy vWs/XatMa7fTf52Msu5WLR+fdDPGmY1rydEoO9iqzXBOKy9ZJ0/77U52OGiKuryo+Tqq2Qzp922Y UVXOPm/8q1Xnq/owUrYuqqwk8Xk/fVPfT0en3tt8MqNp67VI028zuu5gtJl8vrp26/atm2FgalJN +5Ix9vjsLP5YOpj0W2OtoqV8ZbDSX0lXRiudlXcrGdyTv1zcGdPYzqCfjr7df5W1xovfb43u5S9H 32/prx9/jJ8vLcNZS3rW+Je9avzrxx9ffr/cGE7yo6V0dDjpMcP58k8rVqa7tfbHfnacPE7H2dLy vc7WoNEaZfzypMui7I+X+stw3zseH2bj8Cx/eLKbHooDefty9ft7nUaan/RbW2v8pNWe3nvXGKaS Yd8M2hn8nmej8cPsYDDKlhjS8rXkp+WwUFbag5b1aGUxLKOVgl+Pj48buYZdzzVuExrDTv+QVblI 8YIgizBSUv665K+Ykn5fhFpJFm/dUpkqsyEhvb2k096q7Vvf9IWE3xT7lAscLqpMkq/pJNGalzwY HCRhavN2L2t30uSzra1kceCTFUvbkj9Vbiv54ScGwJ+f7O8za2zknXF2ZrXTtXrZatVW95t0lFgt W6d6a+W9F3rfGA0G43ZnhEhn6VUXcw06Li1DzWuqa5geZt1B2qbcD9cW9FsvHb1G5txaXdu4sXrr 5t2bN1c31tc31m6z6hecznuBzpvJUmS9ZbHXbgeOWr4GMSIzF/XvtQf9bCm5vqINKh90s5Wkl57s Zwncbs2qE430YJyN9mDf9kkLxlvYmtvAvcoXoSf+zd6YDvBRHBQMZ/zgb5N6+WJ6HNUKnanOqO9U /6o1TjVVrdBG4z2rdu2cqmY6xxbzbb97gmgdDkbjZO2/Epi1leYI42sLTsYff0yWnqXjo8YIUTro LS1vrzZW15YTnl9vpK/St0s/JO10nG5qmhcGQ2ZYVNpjt2aTzjS3ldkvCBgZQq+pdrOk4NR4bXgq 8yqvFJkh5bWFnxAc0+t44dN0mkSq1cL9cWeMpI576WbyTXacJ4irpJ+N2vkKwmVycJCMj9IxbDeG yfL7Tf9GX89oVY+cb+q7yIWKSmXbkTS2e0nrKB3l2XhrMj6o35HeltCTUFMfwbpVa2c+fnaEShUf 0sVGogXFvLPI8iQfTEatzAY1zlpHffarQ7FEF1nf9iLHnfFRgn6YpW9OEtY8isB4cJxCgeTrTn/y NoE3km+HWT/Z8co6eT6Bibz/lRHAFUMk/slWbXC4aXT6yCGo5kCVqTp/dcQRm80ZysH+ZtpGwUVj CQtiq7a2qj+37t66s75+Y219Lgn4zpZfp135cP3W7bt3b66vr965eUtfFe05Q73pZMda/5Uvjjvt 8dGWGlxJJuzR9byVdlPOAVsnGVxPxzq9Sc+eZluU6aVvKw/WEBG1pFlOkbeTDofdrN4b7CMX6sfZ fp0H9VY6VL2VtmmhMrRzPmVjH09yU8JMh6vUsd9FOYhDLbXHVtof9DsMJSqPUQGe2sfUb5Go/C7t srL7rIdaYjs47CuxyTrM4Hkx/xIUqBvVNpNbN1aHb5erTXC86TWm2kg0DdONVFVbO0eUWmps1Rtc SWB5aXomAeZowejwLbT/vNNq5HlXeu8Xt9bbB3fu3lm709q/eWPjAO6w2eEs+BKt4umT5M7325UB X2pfOtmd+gX6c/+zlxz0Ogff27nWzqmhb7evsm+3P7xvtmav6dTrp2k7Q/vD+Gjn2YNHf91hv3jy 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 jaJSOcLO847AlU58//8DARhRP9pfAgA= headers: Cache-Control: [no-cache] Connection: [keep-alive] Content-Encoding: [gzip] Content-Type: [text/html; charset=utf-8] Date: ['Mon, 29 Oct 2018 13:53:04 GMT'] Pragma: [no-cache] SLASH_LOG_DATA: [shtml] Server: [nginx/1.13.12] Strict-Transport-Security: [max-age=31536000] X-XRDS-Location: ['https://slashdot.org/slashdot.xrds'] status: {code: 200, message: OK} - request: body: null headers: Accept: ['*/*'] Accept-Encoding: ['gzip,deflate'] Cookie: [''] DNT: ['1'] User-Agent: ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0'] method: GET uri: "http://www.za\u017C\xF3\u0142\u0107g\u0119\u015Bl\u0105ja\u017A\u0144.pl/" response: body: string: !!binary | H4sIAAAAAAAAA51WzW7bRhA+J0DeYcpDTpLYuC2a2pSKWk7rFHFstGqM+FIMyZW0/Nlldpdhl6/R Hg3kGQIDPhnw2/Tgm6EX6CwpQorsqnUAgT/ab2a++fablYIvDo7Hk7cnL+BwcvQKTn7bf/VyDH/2 ff/0q7HvH0wO2oWvB18+g4lCobnhUmDm+y9e/zV68jiYmzxr7gxjdzfcZGx0hteLi79hdnOZfYQE r24Dv10gRM4MwtyYos/elfz90BtLYZgw/YktmAdR+zb0DPvD+C79HkA0R6WZGXIt+8+ff/Ndf8dz qfxl1UdBKGPrHh4FBWhjM9bG9zHjM7ELEWVkas9rIIQePX1XSrP3AwjOKHcCApWMZcUZVNexhUJq o6QQHGqBycce8Gs4kRlG1kVQX5o33+lqkUByU9sUcsIFfpc58IstdBSfzU3H5oin8oIo/MKS7VF3 mlipfN7I/OHqduAoBKECv0W9xiREFdecJa4VYXvUapKqhdE1PRuWagM1UuOK+FN7FX2vTUpNZpyq WdAFi+yUR7VgEGdI4bpmM9l2jY0cCaMkAW2qYtOh57Z21/eLbFDxlBcs5jiQaua7N/8ExUxh7lGa nNo77RAeGFQz2mHv9zBDkXqjokWyPPBxNHiIMmcyxKj+lJAmRlVVDawsTRmyQSRzv0ITzb9/P3z5 Zn//29Nnhztv3na86k7Z88tOWVqBZfRdspVN8PyStKCP4wuFklOmpULYV5hFjVR0zVutGtl3obXK Zq2rD7ftwoOafipCXfyH7T6NGNc32kioQKcX6BwCqcyLkhCyIqKxpK1XWMU1Co49xx5iMhBMaUDd gHCMke6ZXjML7WSqrLHOLT0or22FYumPmoI524V682gYwM+NJnLlTNZ4lS1jepCahWKdTdcsuqX6 MqsUklCuD0rn/ITNMsVgxhrz5giaCV3SNUfRBs+kmyPV6tSxK7MypLOvYwWxKlNUtX3QNm1OifNk zFKpwp1wUGSd/w5LZWRFutM2FHrOi4KL2V3b3Qtz/vvXWjXTOZJkuZuArtrxlCk6k8/Yr+trm8UO HE2MErYCbi0lQ4W1NcoWxqZ8rbmfSHpFnBvAooLjBjhZB27W3gxx2kvylo3mWzkYLJixlxENJlli ReFHaWS75saa7M5R3a26hppmvGYZF7Jiq4itpXMZC0YnERlkTerNRo4cqhXgftU/t/Nl+SYxK/9P eYf6jOor999zENEUtT/Mgb/8o/Dk8T9lh+FvdAgAAA== headers: Accept-Ranges: [bytes] Content-Encoding: [gzip] Content-Length: ['943'] Content-Type: [text/html] Date: ['Mon, 29 Oct 2018 13:53:31 GMT'] Server: [Apache] Set-Cookie: ['startBAK=R3415777513; path=/; expires=Mon, 29-Oct-2018 14:54:44 GMT', 'start=R118851658; path=/; expires=Mon, 29-Oct-2018 15:04:07 GMT'] Vary: [Accept-Encoding] X-IPLB-Instance: ['17521'] status: {code: 200, message: OK} - request: body: null headers: Accept: ['*/*'] Accept-Encoding: ['gzip,deflate'] Cookie: [''] DNT: ['1'] User-Agent: ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0'] method: GET uri: http://example.com/ response: body: string: !!binary | H4sIADuBBVIAA41UQa/TMAy+71eYcgFpXfeAB1PXViBA4gIc4MIxa9zVWpOUJO02offfcdu9ruXt QCu1jh1//mzHSZ5Jk/tzjVB6VWWL5PGHQmYL4Cfx5CvMPp+EqiuET0YJ0kk0aBfDFoVeQF4K69Cn QeOLcBNAlE2Mpfd1iL8batPgo9EetQ+7sAHkwyoNPJ581IXfjlC3kLRQmAYt4bE21k/8jyR9mUps KcewXyyBNHkSVehyUWF6d4Vy/szJdAwugXPngsG2M/IMf3qxX4r8sLem0TLMTWVsDM+LNb+vtuMW JeyedAzrq6oWUpLez3QFMw0Loag6xxB8r1HDD6FdsITgC1YtesoFfMMGWTMqlvDBcgZLcLw1dGip uCL2wkP/ldROSPfpx/B2va5PT3neowLReHOD7v3M4VbuxST+zliJNrRCUuNiuEO1nVAScUX6sOR/ S448ygnBR7jXmzebzQSx60UoMTdWeDLMVRuNU9D3CiUJeKHEKbxk+a7L8uW0ZfMO/k8mD6M0L+Sk mPOKzfp+w/ZPadZz61jvsWRXEsM3ifojmnXyIomGeVwkXWo8nkzycpDLuyejyarBVmc/S3Igez2w hM6LXUWu5F54AzuExrFYGAtUVY3zXdVbBBwQHc8Pe+eN4gFzK/hlGs753DmBZ+Th4F3Q9dXrSL40 jYfaEiPnhktBuu8n8Fq4A6feB63RKnKODaskqkfWCd8XFos06G6NOIqOx+OKhBYrY/fREM9Fl2hB 9tVY5PCMp/oYqxWDiawHTKK+Ukl0qVs0XG9/AQiVqov2BAAA headers: Cache-Control: [max-age=604800] Content-Encoding: [gzip] Content-Length: ['606'] Content-Type: [text/html; charset=UTF-8] Date: ['Mon, 29 Oct 2018 13:53:34 GMT'] Etag: ['"1541025663+gzip"'] Expires: ['Mon, 05 Nov 2018 13:53:34 GMT'] Last-Modified: ['Fri, 09 Aug 2013 23:54:35 GMT'] Server: [ECS (oxr/83C5)] Vary: [Accept-Encoding] X-Cache: [HIT] status: {code: 200, message: OK} version: 1 ================================================ FILE: tests/vcr_cassettes/test_search_by_tags_enforces_space_seprations_exclusion.yaml ================================================ interactions: - request: body: null headers: Accept: ['*/*'] Accept-Encoding: ['gzip,deflate'] Cookie: [''] DNT: ['1'] User-Agent: ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0'] method: GET uri: https://bookmark2.com/ response: body: {string: 'A client error occurred: '} headers: CF-RAY: [4715f7345f2caa4a-SIN] Connection: [keep-alive] Content-Length: ['25'] Content-Type: [text/plain; charset=utf-8] Date: ['Mon, 29 Oct 2018 13:25:00 GMT'] Expect-CT: ['max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"'] Server: [cloudflare] Set-Cookie: ['__cfduid=dfed596f417f6cf01aa1751eb7a78f0881540819500; expires=Tue, 29-Oct-19 13:25:00 GMT; path=/; domain=.bookmark2.com; HttpOnly'] status: {code: 404, message: Not Found} version: 1 ================================================ FILE: tox.bat ================================================ @if not defined BASEPYTHON set BASEPYTHON=python @tox.exe %* ================================================ FILE: tox.ini ================================================ [tox] envlist = py310,py311,py312,py313,py314,pylint,flake8 [flake8] max-line-length = 139 exclude = .tox build venv ignore = # C901 func is too complex C901, # E126 continuation line over-indented for hanging indent E126, # E127 continuation line over-indented for visual indent E127, # E226 missing whitespace around arithmetic operator E226, # E231 missing whitespace after ',' E231, # E302 expected 2 blank lines, found 1 E302, # E305 expected 2 blank lines after class or function definition, found 1 E305, # E731 do not assign a lambda expression, use a def E731, # W292 no newline at end of file W292, # W504 line break after binary operator W504, # E203 whitespace before : E203, [pytest] timeout = 10 timeout_method = thread markers = non_tox: not run on tox slow: slow tests gui: GUI (functional) tests [testenv] usedevelop = true dependency_groups = tests ; Tox installs groups first, which misinterprets our self-referential extras ; Workaround by installing all extras manually: https://github.com/tox-dev/tox/issues/3561 extras = server,locales [testenv:py310] commands = pytest --cov buku -vv -m "not non_tox" {posargs} [testenv:py311] commands = pytest --cov buku -vv -m "not non_tox" {posargs} [testenv:py312] commands = pytest --cov buku -vv -m "not non_tox" {posargs} [testenv:py313] commands = pytest --cov buku -vv -m "not non_tox" {posargs} [testenv:py314] commands = pytest --cov buku -vv -m "not non_tox" {posargs} [testenv:quick] basepython = {env:BASEPYTHON:py313} commands = pytest --cov buku -vv -m "not non_tox and not slow" {posargs} [testenv:nogui] basepython = {env:BASEPYTHON:py313} commands = pytest --cov buku -vv -m "not non_tox and not gui" {posargs} [testenv:pylint] basepython = {env:BASEPYTHON:py313} commands = pylint . --rc-file tests/.pylintrc --recursive yes --ignore-paths .tox/,build/,venv/ [testenv:flake8] basepython = {env:BASEPYTHON:py313} commands = python -m flake8