diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..8387ba4
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,19 @@
+# EditorConfig http://EditorConfig.org
+
+# Project Root
+root = true
+
+# Default Code Style
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{js,yaml,yml,json}]
+indent_size = 2
+
+[Makefile]
+indent_style = tab
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..15deccf
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,62 @@
+name: Tests
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+ branches:
+ - master
+
+jobs:
+ test:
+ strategy:
+ matrix:
+ ckan-version: ["2.11", "2.10"]
+ fail-fast: false
+
+
+ runs-on: ubuntu-latest
+ container:
+ # The CKAN version tag of the Solr and Postgres containers should match
+ # the one of the container the tests run on.
+ # You can switch this base image with a custom image tailored to your project
+ image: ckan/ckan-dev:${{ matrix.ckan-version }}
+ services:
+ solr:
+ image: ckan/ckan-solr:${{ matrix.ckan-version }}-solr9
+ postgres:
+ image: ckan/ckan-postgres-dev:${{ matrix.ckan-version }}
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: postgres
+ options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
+ redis:
+ image: redis:3
+
+
+ env:
+ CKAN_SQLALCHEMY_URL: postgresql://ckan_default:pass@postgres/ckan_test
+ CKAN_DATASTORE_WRITE_URL: postgresql://datastore_write:pass@postgres/datastore_test
+ CKAN_DATASTORE_READ_URL: postgresql://datastore_read:pass@postgres/datastore_test
+ CKAN_SOLR_URL: http://solr:8983/solr/ckan
+ CKAN_REDIS_URL: redis://redis:6379/1
+
+ steps:
+
+ - uses: actions/checkout@v4
+
+ - name: Install requirements
+ # Install any extra requirements your extension has here (dev requirements, other extensions etc)
+ run: |
+ pip install -e ".[dev]"
+
+ - name: Setup extension
+ # Extra initialization steps
+ run: |
+ # Replace default path to CKAN core config file with the one on the container
+ sed -i -e 's/use = config:.*/use = config:\/srv\/app\/src\/ckan\/test-core.ini/' test.ini
+ ckan -c test.ini db upgrade
+
+ - name: Run tests
+ run: pytest --ckan-ini=test.ini --cov=ckanext.bulk --disable-warnings ckanext
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..854765b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,44 @@
+.ropeproject
+node_modules
+bower_components
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+sdist/
+*.egg-info/
+.installed.cfg
+*.egg
+.copier-answers.ctb-extended.yml
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.cache
+nosetests.xml
+coverage.xml
+.benchmarks/
+
+# Sphinx documentation
+docs/_build/
diff --git a/.gitleaksignore b/.gitleaksignore
new file mode 100644
index 0000000..e69de29
diff --git a/.node-version b/.node-version
new file mode 100644
index 0000000..9a2a0e2
--- /dev/null
+++ b/.node-version
@@ -0,0 +1 @@
+v20
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..9a2a0e2
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+v20
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..341f974
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,38 @@
+default_install_hook_types:
+ - pre-commit
+ - pre-push
+ - commit-msg
+
+repos:
+ # - repo: https://github.com/thoughtworks/talisman
+ # rev: 'v1.28.0' # Update me!
+ # hooks:
+ # # both pre-commit and pre-push supported
+ # # - id: talisman-push
+ # - id: talisman-commit
+ # entry: cmd --githook pre-commit
+
+ # - repo: https://github.com/gitleaks/gitleaks
+ # rev: v8.18.4
+ # hooks:
+ # - id: gitleaks
+
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v4.6.0
+ hooks:
+ - id: end-of-file-fixer
+ stages: [pre-commit]
+ - id: trailing-whitespace
+ stages: [pre-commit]
+ - id: debug-statements
+ stages: [pre-push]
+
+ ## Ruff
+ - repo: https://github.com/charliermarsh/ruff-pre-commit
+ rev: v0.5.0
+ hooks:
+ - id: ruff
+ args: [--fix]
+ stages: [pre-commit]
+ - id: ruff-format
+ stages: [pre-commit]
diff --git a/.talismanrc b/.talismanrc
new file mode 100644
index 0000000..e69de29
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..58777e3
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 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 Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are 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.
+
+ 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.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ 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 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 work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero 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 Affero 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 Affero 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 Affero 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.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero 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 Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ 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 AGPL, see
+.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..5d73b57
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,5 @@
+include README.md
+include LICENSE
+include requirements.txt
+recursive-include ckanext/bulk *.html *.json *.js *.less *.css *.mo *.yml *.yaml
+recursive-include ckanext/bulk/migration *.ini *.py *.mako
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..d3bf8d2
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,163 @@
+## Example:
+# make prepare; make full-upgrade
+#
+## or
+# make prepare; make sync install
+#
+## `make prepare` installs/updates code for all other make-rules. It's
+## recommended to execute it periodicatlly, to pull the lates version of CDM.
+#
+## `make full-upgrade` synchronizes and installs CKAN, dependencies and current
+## extension.
+#
+## `make sync install` synchronizes and installs only dependencies. CKAN and
+## current extension are ignored.
+#
+## Add `develop=1` to install dev-requirements.txt alongside with the normal
+## requirements.
+#
+## Any extension can be synchronized/installed individually:
+# make sync-EXT install-EXT
+#
+## If `remote-EXT` definition is present, extension is pulled from the
+## specified source. If you are running `make sync-EXT install-EXT` and there
+## is no `remote-EXT` line, CDM makes an attempt to pull the extension from
+## master branch of `https://github.com/ckan/ckanext-EXT`. Most likely, such
+## extension does not exist. But git will think that you are pulling from
+## private repository and may ask your credentials or just say that you don't
+## have permissions to read from this repo.
+
+###############################################################################
+# requirements: start #
+###############################################################################
+# CKAN core supports this short syntax. But internally it's unfolds into
+## remote-ckan = https://github.com/ckan/ckan tag ckan-2.10.4
+# if you want to use CKAN fork or specific commit, use this full specification
+ckan_tag = ckan-2.10.4
+
+# items from this list are installed by `make full-upgrade` and `make sync
+# install`. If you specify remote, but did not added extension to this list, it
+# won't be installed. If you add SOMETHING to this list, but did not specify
+# remote, SOMETHING is pulled from `https://github.com/ckan/ckanext-SOMETHING
+# branch master`
+ext_list = \
+ admin-panel \
+ collection cloudstorage comments \
+ dcat \
+ editable-config \
+ files flakes \
+ geoview googleanalytics \
+ hierarchy harvest \
+ let-me-in \
+ officedocs or-facet \
+ pdfview pygments \
+ resource-indexer \
+ scheming search-tweaks spatial saml syndicate \
+ toolbelt \
+ unfold \
+ vip-portal \
+ xloader
+
+# information about extension source. Format is `ALTERNATIVE-NAME = URL TYPE
+# REF`, where
+#
+# * ALTERNATIVE: `remote` by default, but can be any string
+#
+# * NAME: name of extension. Must be exactly the same as value from `ext_list`
+#
+# * URL: repo URL. GitHub, BitBucket, GitLab or even SSH URL
+#
+# * TYPE: type of reference specified by the next part. One of: branch, commit, tag
+#
+# * REF: commit hash, branch name, tag name, depending on TYPE value. Prefer tags
+remote-admin-panel = https://github.com/mutantsan/ckanext-admin-panel commit 999183a
+# dev is an alternative. You can install it via `make full-upgrade
+# alternative=dev`. Any other prefix can be used instead of `dev`.
+dev-admin-panel = https://github.com/mutantsan/ckanext-admin-panel branch master
+
+remote-cloudstorage = https://github.com/DataShades/ckanext-cloudstorage.git tag v0.3.2
+remote-collection = https://github.com/DataShades/ckanext-collection.git tag v0.2.0a0
+remote-comments = https://github.com/DataShades/ckanext-comments.git tag v0.3.2a0
+remote-dcat = https://github.com/ckan/ckanext-dcat.git tag v1.7.0
+remote-editable-config = https://github.com/ckan/ckanext-editable-config tag v0.0.6
+remote-files = https://github.com/DataShades/ckanext-files.git tag v1.0.0a0
+remote-flakes = https://github.com/DataShades/ckanext-flakes.git tag v0.4.5
+remote-geoview = https://github.com/ckan/ckanext-geoview.git tag v0.1.0
+remote-googleanalytics = https://github.com/ckan/ckanext-googleanalytics.git tag v2.4.0
+remote-harvest = https://github.com/ckan/ckanext-harvest.git tag v1.5.6
+remote-hierarchy = https://github.com/ckan/ckanext-hierarchy.git tag v1.2.1
+remote-let-me-in = https://github.com/mutantsan/ckanext-let-me-in tag v1.0.1
+remote-officedocs = https://github.com/jqnatividad/ckanext-officedocs tag v1.1.0
+remote-or-facet = https://github.com/DataShades/ckanext-or_facet tag v0.1.1
+remote-pdfview = https://github.com/ckan/ckanext-pdfview.git tag 0.0.8
+remote-pygments = https://github.com/mutantsan/ckanext-pygments commit f4287bb
+remote-resource-indexer = https://github.com/DataShades/ckanext-resource_indexer.git tag v0.4.1
+remote-saml = https://github.com/DataShades/ckanext-saml.git tag v0.3.3
+remote-scheming = https://github.com/ckan/ckanext-scheming tag release-3.0.0
+remote-search-tweaks = https://github.com/dataShades/ckanext-search-tweaks tag v0.6.1
+remote-spatial = https://github.com/ckan/ckanext-spatial tag v2.1.1
+remote-syndicate = https://github.com/DataShades/ckanext-syndicate tag v2.2.2
+remote-toolbelt = https://github.com/DataShades/ckanext-toolbelt.git tag v0.4.24
+remote-unfold = https://github.com/mutantsan/ckanext-unfold.git tag v1.0.2
+remote-vip-portal = https://github.com/DataShades/ckanext-vip-portal.git tag v0.2.5a1
+remote-xloader = https://github.com/ckan/ckanext-xloader.git tag 1.0.1
+
+# extras installed with the extension. Produce `pip install
+# 'ckanext-googleanalytics[requirements]'`-like instructions.
+package_extras-remote-googleanalytics = requirements
+package_extras-remote-files = opendal,libcloud
+package_extras-remote-resource-indexer = pdf
+
+
+
+###############################################################################
+# requirements: end #
+###############################################################################
+
+# version of CDM pulled during `make-prepare`. Use version tag to prevent
+# undesirable udates
+_version = master
+
+# import all rules defined in `deps.mk`. This file will be pulled by `make prepare`
+-include deps.mk
+
+prepare: ## download CDM rules
+ curl -O https://raw.githubusercontent.com/DataShades/ckan-deps-installer/$(_version)/deps.mk
+
+vendor-dir = ckanext/bulk/assets/vendor
+
+vendor: ## Copy vendor libraries from node_modules/ to assets directory
+ cp node_modules/tom-select/dist/js/tom-select.{base,complete}.min.js $(vendor-dir)
+ cp node_modules/tom-select/dist/css/tom-select{,.bootstrap5}.css $(vendor-dir)
+ cp node_modules/sweetalert2/dist/sweetalert2.all.min.js $(vendor-dir)/sweetalert2.all.js
+ cp node_modules/sortablejs/Sortable.min.js $(vendor-dir)/Sortable.js
+ cp node_modules/htmx.org/dist/htmx.min.js $(vendor-dir)/htmx.js
+ cp node_modules/hyperscript.org/dist/_hyperscript.min.js $(vendor-dir)/hyperscript.js
+ cp node_modules/izimodal/css/iziModal.css $(vendor-dir)
+ cp node_modules/izimodal/js/iziModal.js $(vendor-dir)
+ cp node_modules/izitoast/dist/css/iziToast.css $(vendor-dir)
+ cp node_modules/izitoast/dist/js/iziToast.js $(vendor-dir)
+ cp node_modules/slick-carousel/slick/slick.{js,css} $(vendor-dir)
+ cp node_modules/slick-carousel/slick/slick-theme.css $(vendor-dir)
+ cp node_modules/slick-carousel/slick/ajax-loader.gif ckanext/bulk/public
+ cp node_modules/slick-carousel/slick/fonts ckanext/bulk/public/slick-fonts -r
+ cp node_modules/daterangepicker/daterangepicker.{js,css} ckanext/bulk/assets/vendor
+ cp node_modules/daterangepicker/moment.min.js ckanext/bulk/assets/vendor
+ cp node_modules/overlayscrollbars/styles/overlayscrollbars.css ckanext/bulk/assets/vendor
+ cp node_modules/overlayscrollbars/browser/overlayscrollbars.browser.es6.js ckanext/bulk/assets/vendor/overlayscrollbars.js
+
+typecheck: ## Run typechecker
+ npx pyright --pythonpath="$$(which python)"
+
+
+changelog: ## compile changelog
+ git changelog -c conventional -o CHANGELOG.md $(if $(bump),-B $(bump))
+
+test-server: ## start server for frontend testing
+ifeq ($(dirty-server),)
+ yes | ckan -c test.ini db clean
+ ckan -c test.ini db upgrade
+ yes | ckan -ctest.ini sysadmin add admin password=password123 email=admin@test.net
+else
+ ckan -c test.ini run -t
+endif
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7322e73
--- /dev/null
+++ b/README.md
@@ -0,0 +1,1199 @@
+[![Tests](https://github.com/Link Digital/ckanext-bulk/workflows/tests.yml/badge.svg)](https://github.com/Link Digital/ckanext-bulk/actions/workflows/test.yml)
+
+# ckanext-bulk
+
+Extended template of CKAN extension.
+
+## Requirements
+
+Compatibility with core CKAN versions:
+
+| CKAN version | Compatible? |
+|--------------|-------------|
+| 2.9 | no |
+| 2.10 | yes |
+| 2.11 | yes |
+| master | yes |
+
+## Create extension
+
+If you see this, most likely extension is already created. But if you want to
+create another extension, here's the example:
+
+1. Install `ckanext-toolbelt`
+ [v0.4.21](https://pypi.org/project/ckanext-toolbelt/) or newer.
+ ```sh
+ pip install -U ckanext-toolbelt
+ ```
+
+1. Generate an extension in the **current** directory:
+
+ ```sh
+ ctb make ckanext extended
+ ```
+ or specify output location using `-o`/`--output-dir` option:
+
+ ```sh
+ ctb make ckanext extended -o /tmp
+ ```
+
+ It's also possible to specify the name of extension (via positional
+ argument) and use default answers for questions(`-d`/`--use-defaults`
+ flag). In this way you don't need to answer any questions.
+
+ ```sh
+ ctb make ckanext extended my-ext -d
+ ```
+
+1. Switch to extension folder and install it with `dev` extras:
+ ```sh
+ cd ckanext-my-ext/
+ pip install -e '.[dev]'
+ ```
+
+1. Initialize git-repository inside the extension:
+ ```sh
+ git init
+ ```
+
+1. Initialize pre-commit hooks:
+ ```sh
+ pre-commit install
+ ```
+
+1. Optional. If you don't have CKAN and want to install it alongside with
+ popular extensions, run:
+ ```sh
+ make prepare
+ make full-upgrade develop=1
+ ```
+ Create config files for 1st and 3rd lavel(details explained in Configuration
+ section):
+ ```sh
+ ckan generate config default.ini
+ ckan generate config ckan.ini
+ ```
+ Link 2nd level of configuration:
+ ```sh
+ ln -snf ckanext-my-ext/config/* ./
+ ```
+ Create solr core using schema from
+ `ckanext-my-ext/config/solr/schema.xml`. Create DB.
+ Remove content of `[app:main]` from `ckan.ini`. Add `use =
+ config:project.ini` line instead and copy/adapt `Environment settings:
+ start/end` block from `project.ini`.
+ Apply DB migrations:
+ ```sh
+ ckan db upgrade
+ ckan db pending-migrations --apply
+ ```
+
+
+## Usage
+
+This guide explains how you can use the project initialized with the extended
+template and add more code to it. If you don't have Markdown viewer and don't
+like reading raw markdown source, you can start local server with this guide:
+
+```
+# you need to install the extension before running the following command
+# $ pip install -e '.[dev]'
+mkdocs serve
+```
+
+The documentation is available at [localhost:8000](http://localhost:8000/) as
+long as server is running.
+
+Additional details can be found in the source code. For example,
+`logic/action.py` contains examples and explanations of API actions that can be
+registered by extension.
+
+
+## Code
+
+Code of the extension resides inside `ckanext/bulk`.
+
+### `plugin.py`
+
+The main entry point is `plugin.py`. It extends CKAN using interfaces and every
+other file is somehow connected to `plugin.py`.
+
+If possible, avoid writing code directly inside `plugin.py`. Only small and
+clear functions should be added to it. And anything that does not fit in dozen
+lines can be moved into a separate file.
+
+Default implementation of `plugin.py` extends CKAN using 3 different
+approaches.
+
+---
+
+For simple interface, such as `IConfigurer`, it implements the interface
+directly and defines `update_config` method. This method registers assets and
+templates of the extension. Nothing complex is computed here, and there are no
+functions that are hard to read.
+
+This approach is recommended for the following interfaces: `IConfigurer`,
+`IConfigurable`, `IMiddleware`, `IFacets`.
+
+---
+
+To hook into one-mehtod interfaces that register additional functions, the
+plugin uses
+[blankets](https://docs.ckan.org/en/2.10/extensions/plugins-toolkit.html#ckan.plugins.toolkit.ckan.plugins.toolkit.blanket). When
+extension is decorated with blanket, it automatically implements corresponding
+interface and registers all public members of corresponding module.
+
+There are 7 blankets in CKAN:
+
+| Blanket | Effect |
+|---------------------|-----------------------------------------------------------------------------------------------------|
+| actions | Register all public functions from `ckanext.bulk.logic.action` as actions |
+| auth_functions | Register all public functions from `ckanext.bulk.logic.auth` as auth functions |
+| blueprints | Register all blueprints from `ckanext.bulk.views` as blueprints |
+| cli | Register all public members(`__all__`) from `ckanext.bulk.cli` as commands |
+| config_declarations | Register all declarations from `ckanext/bulk/config_declaration.yaml` |
+| helpers | Register all public functions from `ckanext.bulk.helpers` as helpers |
+| validators | Register all public functions from `ckanext.bulk.logic.validators` as validators |
+
+Because of blankets, you don't need to import views, CLI commands or actions
+into plugin. You don't even have to register `get_actions`-like function. Any
+function defined inside `ckanext.bulk.logic.action` will be
+registered as an action with the same name, if it's not prefixed with
+underscore. Imported functions are not registered as actions: you have to
+create function inside the `action` module to export it automatically.
+
+If you keep actions or other code units inside multiple files, you can create
+`get_actions`-like function, that returns all actions and pass it to the
+blanket:
+
+```python
+@tk.blanket.actions(get_actions)
+class BulkPlugin(p.SingletonPlugin):
+ ...
+```
+
+Note: `blueprints` blanket registers only subclasses of `flask.Blueprint`.
+
+Note: `cli` blanket is not very smart and will try to register every command
+directly under `ckan` CLI. If you are using `click.group` decorator, it's
+recommended to define `__all__` list inside `cli` module and specify names of
+commands/groups that must be registered by `IClick` interface.
+
+---
+
+
+Other interfaces usually are quite complex. The recommended way of implementing
+these interfaces(and custom interfaces from extensions, like IFiles) includes
+extra steps.
+
+First, create a module inside `ckanext.bulk.implementations`
+using snake-case version of the interface name. For example,
+`IPackageController` turns into `package_controller.py`, `IAdminPanel` turns
+into `admin_panel.py`.
+
+Inside this new module, define a plugin that matches the name of the interface
+without `I` prefix. Put implementation of the interface inside this plugin.
+
+```python
+
+class PackageController(SingletonPlugin):
+ implements(IPackageController, inherit=True)
+
+ def after_dataset_show(self, context, pkg_dict):
+ ...
+```
+
+Re-export implementation from `ckanext/bulk/implementations/__init__.py`
+
+```python
+from .package_controller import PackageController
+
+__all__ = [
+ "PackageController",
+]
+```
+
+And finally add this implementation as a parent class to your main plugin:
+
+```python
+from . import implementations
+
+class BulkPlugin(
+ implementations.PackageController,
+ p.SingletonPlugin,
+):
+ ...
+
+```
+
+It's quite a lot of steps, but in this way you can keep your plugin simple and
+readable.
+
+### `cli.py`
+
+Define all commands here. It's recommended to create a single `click` group
+that maches the name of the plugin and add this group to `__all__` attribute of
+the module. As result, only this group will be available as `ckan bulk` CLI command.
+
+All commands should be registered under this group or its subgroups.
+
+Members included into `__all__` attribute are registered as CLI commands by
+`cli` blanket.
+
+### `config_declaration.yaml`
+
+YAML file with [config
+declarations](https://docs.ckan.org/en/2.10/maintaining/configuration.html#config-declaration).
+
+Declare all custom configuration options here. Never use undeclared config
+options in code and provide at least basic declaration. It's also recommended
+to declare the type and default value for the config option as well.
+
+You can always dump all the options of the plugin using CKAN CLI:
+
+```sh
+ckan config declaration heh -d
+```
+
+`-d`/`--include-docs` flag adds description of the option to the output. Omit
+it if you need only names and defaults values of the option.
+
+Declarations from this file are automatically registered in CKAN by
+`config_declarations` blanket.
+
+### `config.py`
+
+This module simplifies access to config options defined by the plugin.
+
+Instead of accessing untyped options inside `tk.config`, it's recommended to
+define typed accessors inside this module. It improves a number of aspects:
+
+* config options can be accessed by shorter name: `option()` instead of
+ `tk.config["ckanext.bulk.option.name"]`.
+* accessor has specific type, while `tk.config[KEY]` is always `Any`
+* any additional processing of options value can be hidden inside the accessor
+* you can safely change the name of the config option
+
+### `helpers.py`
+
+This file contains all template helpers for the plugin.
+
+All public members defined in this module are registered as helpers by
+`helpers` blanket.
+
+### `views.py`
+
+Here you should register blueprint for the plugin. If you have multiple
+blueprints, transform `views.py` into `views/__init__.py` and add every
+blueprint as a separate submodule. You'll need to create `get_blueprints`
+function and pass it to the blanket:
+
+```python
+@tk.blanket.blueprints(get_blueprints)
+class HehPlugin(SingletonPlugin):
+ ...
+```
+
+All blueprints defined in this module are registered as blueprints by
+`blueprints` blanket.
+
+### `public/`
+
+This folder contains files that are directly accessible from browser because of
+the following line from `update_config` method of `IConfigurer` implementation:
+
+```python
+tk.add_public_directory(config_, "public")
+```
+
+### `assets/`
+
+This is the base folder for all site assets (CSS and JS files) and source files
+for them. For example, if you are using SASS or TypeScript, these files should
+also be stored inside assets folder.
+
+Assets cannot be accessed directly. You have to define [named
+asset](https://docs.ckan.org/en/2.10/contributing/frontend/assets.html) inside
+`assets/webassets.yml` and include this named asset into template using `{% asset "bulk/ASSET_NAME" %}` tag.
+
+### `templates/`
+
+This is the base folder for Jinja2 templates. Templates that override existing
+pages must replicate structure of CKAN's `templates` folder. If you are going
+to create a completely new page, prefer storing templates for it inside
+separate subfolder with the name matching the plugin name. For example,
+template for the blog page may be stored as `templates/bulk/blog/index.html`.
+
+### `logic/action.py`
+
+Define API actions here. If you are going to create a lot of actions, consider
+transforming `action.py` into `action/__init__.py` and group actions by domain
+inside separate files under this new subfolder: `action/blog.py`,
+`action/user.py`, `action/something.py`.
+
+All public members defined in this module are registered as API actions by
+`actions` blanket.
+
+### `logic/auth.py`
+
+This file contains auth functions. **Every** API action registered by your
+plugin must have dedicated auth function. You can define additional auth
+functions and use them with `tk.check_access`/`h.check_access` in views and
+templates.
+
+All public members defined in this module are registered as auth functions by
+`auth_functions` blanket.
+
+### `logic/schema.py`
+
+Validation schemas for API actions. If action accepts arguments it's
+recommended to define a schema for this action.
+
+Schemas are not registered inside CKAN. They will not conflict with existing
+schemas and you don't need to add plugin name as prefix to schemas.
+
+### `logic/validators.py`
+
+Validators used by plugin.
+
+All public members defined in this module are registered as validators by
+`validators` blanket.
+
+### `model/`
+
+Folder for all your models. Define every model in a separate file. Don't forget
+to generate migrations for the model using `ckan generate migration -p bulk -m "Migration message"` CLI command.
+
+### `schemas/`
+
+Metadata schemas for ckanext-scheming.
+
+## Configuration
+
+Extension contains `config/` folder at root level. All files related to portal
+configurations are stored here. Apart from `project.ini` with the project level
+configuration, you can also keep `licenses.json`, `resource_formats.json`,
+`who.ini`, SAML2 credentials, GoogleCloud credentials, etc. You can even store
+metadata schemas here, but historically they are kept together with the code,
+so we suggest leaving them inside `ckanext/bulk/schemas`.
+
+### `project.ini`
+
+Project specific configuration. It contains all the settings that are safe to
+keep in repository.
+
+Options that should be modified during deployment are kept inside `Environment
+settings` block. Any token/password/ID value must be replaced with placeholder:
+
+```ini
+## ckaneext-googleanalytics
+googleanalytics.id = G-TEST
+```
+
+Alternatively, you can specify interpolation string with reference to
+environment variable prefixed by `CKAN_`.
+
+```ini
+## ckanext-xloader
+ckanext.xloader.api_token = %(CKAN_XLOADER_API_TOKEN)s
+```
+
+In the example above, value of `CKAN_XLOADER_API_TOKEN` envvar will be used as
+XLoader API Token.
+
+All options that will likely remain unchanged across environments, must be
+added after `Environment settings` block.
+
+This configuration file must be used as a middle layer in 3-layers
+configuration:
+
+1. Generate `default.ini` using CKAN cli. Do not modify it.
+1. Create a symbolic link of `config/project.ini` next to
+ `default.ini`. `project.ini` will use defaults from `default.ini`.
+1. Generate `ckan.ini` in the same folder where you have `default.ini` and link
+ to `project.ini`. Replace the whole content of `[app:main]` section with
+ `use = config:project.ini`(to use `project.ini` as source for defaults) and
+ copy/adapt `Environment settings` section from `project.ini`.
+
+This approach solves the following problems:
+
+* Expected configuration can be shared across environments because you have
+ `project.ini` commited in the repo.
+* Configuration changes are applied automatically, because `project.ini` is a
+ link to git-controlled file. You don't need to modify CKAN configuration
+ manually after the deploy.
+* When upgrading to a new CKAN version with new configuration options, or when
+ secrets were compromised, you can regenerate `default.ini`. All changes from
+ `ckan.ini` and `project.ini` are kept.
+* Environment specific configuration is kept inside `ckan.ini`. You clearly
+ see, what needs to be configured individually on environment because of
+ `Environment settings` block. And you can ignored hundreds of options outside
+ this block, because they must be identicall on all environments.
+
+### `solr/`
+
+This folder contains Solr schema. Any modifications must be applied to this
+schema and then you can copy the schema into Solr configuration folder after
+deployment.
+
+In this way you can use exactly the same schema and control all the
+modifications required by different plugin.
+
+It's recommended to leave a comment with mention of plugin that requires the
+modification before the modified line.
+
+All additional files required by Solr, like specific version of Solr libraries
+can be also added here.
+
+## Included extensions
+
+This exntension includes configuration for a number of popular CKAN
+extensions. These extensions are installed when you run `make full-upgrade`.
+
+Usually, you only need to add extension name to `ckan.plugins` config
+option. If extension requires additional configuration, it will be mentioned in
+the corresponding section below.
+
+### ckanext-admin-panel
+
+Admin UI improvements. Adds panel with links to admin pages at the top of the
+page.
+
+Does not require additional configuration. Enabled by default as `admin_panel`
+plugin.
+
+### ckanext-cloudstorage
+
+Upload resource files to S3 bucket.
+
+Add `cloudstorage` to the list of enabled plugins.
+
+Add driver configuration
+```ini
+
+## ckanext-cloudstorage
+ckanext.cloudstorage.container_name =
+ckanext.cloudstorage.driver = S3
+ckanext.cloudstorage.driver_options = {"key": "", "secret": "", "host": "s3.ap-southeast-2.amazonaws.com"}
+```
+
+### ckanext-collection
+
+Utilities for building reusable interfaces for data series.
+
+Does not require additional configuration. Enabled by default as `collection`
+plugin.
+
+### ckanext-comments
+
+Comment threads that can be attached to anything(dataset, group, user,
+resource).
+
+Enable `comments` plugin and apply DB migrations `ckan db upgrade -p comments`
+to activate comments API. Thread widget must be added manually to pages. For
+example, the following block can be used to add thread to `package/read.html`
+
+
+```jinja
+{% block primary_content_inner %}
+ {{ super() }}
+ {% snippet 'comments/snippets/thread.html', subject_id=pkg.id, subject_type='package' %}
+{% endblock primary_content_inner %}
+```
+
+
+### ckanext-dcat
+
+DCAT translator for CKAN.
+
+Does not require additional configuration. Enabled by default as `dcat`
+plugin.
+
+### ckanext-editable-config
+
+API for managing CKAN configuration in runtime.
+
+Does not require additional configuration. Enabled by default as `editable_config`
+plugin.
+
+### ckanext-files
+
+File management API.
+
+Enabled by default as `files` plugin.
+
+Requires additional configuration:
+```ini
+## ckanext-files
+ckanext.files.storage.default.type = files:fs
+ckanext.files.storage.default.path = %(here)s/storage
+ckanext.files.storage.default.create_path = true
+```
+
+### ckanext-flakes
+
+API for storing arbitrary data in DB.
+
+Add `flakes` to the list of plugins and apply DB migrations: `ckan db upgrade -p flakes`
+
+### ckanext-geoview
+
+Map views for spatial data.
+
+Configure specific view type accoriding to [official
+documentation](https://github.com/ckan/ckanext-geoview?tab=readme-ov-file#available-plugins)
+
+### ckanext-googleanalytics
+
+Track user activity using GA.
+
+Add `googleanalytics` plugin and specify `googleanalytics.id` key.
+
+### ckanext-harvest
+
+Transform data from external services into CKAN datasets.
+
+Add `harvest` to the list of plugins.
+
+### ckanext-hierarchy
+
+Group/organization hierarchy.
+
+Enable `hierarchy_display hierarchy_form hierarchy_group_form` plugins. If you
+are using scheming, you may also need to update metadata schemas.
+
+### ckanext-let-me-in
+
+One-time login links generator.
+
+Does not require additional configuration. Enabled by default as `let_me_in`
+plugin.
+
+### ckanext-officedocs
+
+Views for MS Office documents.
+
+Add `officedocs_view` to the list of plugins and default views.
+
+### ckanext-or-facet
+
+Switch search facets to union logic instead of intersection.
+
+Add `or_facet` to the list of plugins.
+
+### ckanext-pdfview
+
+PDF view for resources.
+
+Enabled by default as `pdf_view`.
+
+### ckanext-pygments
+
+Text views with syntax highlighter.
+
+Add `pygments_view` to the list of plugins and default views.
+
+### ckanext-resource-indexer
+
+Add content of resources to search index.
+
+Add `resource_indexer plain_resource_indexer` to the list of plugins.
+
+### ckanext-saml
+
+SAML2 authentication.
+
+Add `saml` to the list of plugins. Apply DB migrations: `ckan db upgrade -p
+saml`. Adapt `ckanext.saml.*` options. If it's not enough, modify
+`config/saml/settings.json`.
+
+When everything is configured, pull metadata from IdP: `ckanapi action saml_idp_refresh`.
+
+### ckanext-scheming
+
+JSON/YAML definitions of metadata schemas.
+
+Add `scheming_datasets scheming_groups scheming_organizations` to the list of
+plugins.
+
+### ckanext-syndicate
+
+Push local datasets to extenal CKAN portal
+
+Add `syndicate` to the list of plugins. Configure details of remote
+portal(syndication profile) specified by `ckanext.syndicate.profile*` options.
+
+### ckanext-search-tweaks
+
+Additional features for CKAN search.
+
+Enable [plugins defined by the
+extension](https://github.com/DataShades/ckanext-search-tweaks?tab=readme-ov-file#usage)
+and add corresponding configuration
+
+### ckanext-spatial
+
+Features related to spatial search.
+
+Add `spatial_metadata spatial_query` to the list of plugins. Initialize PostGIS extension for CKAN DB
+
+If you are using Docker PostGIS image, you need to do something similar to the example below:
+
+```sh
+PG_VERSION=16
+POSTGIS_VERSION=3.4
+DB=ckan_db_name
+
+psql -U postgres -f /usr/share/postgresql/$PG_VERSION/contrib/postgis-$POSTGIS_VERSION/postgis.sql -d $DB -v ON_ERROR_ROLLBACK=1;
+psql -U postgres -f /usr/share/postgresql/$PG_VERSION/contrib/postgis-$POSTGIS_VERSION/spatial_ref_sys.sql -d $DB -v ON_ERROR_ROLLBACK=1
+```
+
+Use `config/solr/schema.xml` for solr. If you are going to use `solr-bbox`
+search backend, remove the definition of field after `solr-spatial-field`
+comment. If you are going to use `solr-spatial-field` backend, use schema as
+is. You'll also need to [add JTS
+library](https://solr.apache.org/guide/8_11/spatial-search.html#jts-and-polygons-flat)
+to `server/solr-webapp/webapp/WEB-INF/lib/` folder of your Solr service.
+
+[Extra details about search
+backend](https://docs.ckan.org/projects/ckanext-spatial/en/latest/spatial-search.html#choosing-a-backend-for-the-spatial-search).
+
+
+### ckanext-toolbelt
+
+Different helpers that are often used but are too small for individual
+extensions.
+
+Functionality of toolbelt usually does not require enabling plugins. Just
+import and use it.
+
+### ckanext-unfold
+
+Views for archives
+
+Depending on the format of archive, requirements and configuration can be
+different. Check [official
+documentaion](https://github.com/mutantsan/ckanext-unfold).
+
+### ckanext-vip-portal
+
+Restrict access to specific pages globally(for anonymous user) or individually.
+
+Add `vip_portal` to the list of enabled plugins.
+
+### ckanext-xloader
+
+Load files into DataStore tables.
+
+Add `xloader` to the list of plugins. Configure `ckanext.xloader.api_token`
+option.
+
+
+## Additional tools
+
+This extension contains a set of tools for code quality control, executing
+tasks, building assets. Some of them, like tests and benchmarks, will be
+written by you. There are some examples available inside files for such
+tools. Other, like code-style checker, already configured and you only need to
+run specific command.
+
+Here's the overview of all additional tools that are available inside this
+extension.
+
+### Git hooks: [pre-commit](https://pre-commit.com/)
+
+This extension contains git hooks that are automatically executed before making
+commit. These hooks check *modified* files and prevent commit if you are trying
+to include changes that violate project rules.
+
+Because hooks are executed before each commit, only actions that can be
+performed instantly are added to hooks.
+
+Hooks described below are executed before every commit. They check modified
+files and, if file has problems, reject the commit. You have to fix the issue,
+add fixes to index `git add ...` and run commit command once again. Some
+problems are fixed automatically, but commit is still rejected. You need to
+review auto-fixes, add them to index and repeat the commit.
+
+| Hook | Effect |
+|---------------------|-------------------------------------------------------------------------|
+| end-of-file-fixer | Ensure that file contains a single new line in the end |
+| trailing-whitespace | Ensure that there are no trailing whitespaces on every line of the file |
+| ruff | Check standard code style issues |
+| ruff-format | Format code using black-compatible rules(but faster than black) |
+
+Note: `ruff` hooks read configuration from `pyproject.toml`.
+
+In addition, as an example, before push repository is checked for presence of
+debug statemens(`print`, `breakpoint`). If you left them in code, push is
+rejected.
+
+#### Initialization
+
+```sh
+pip install -U pre-commit
+pre-commit install
+```
+
+Note: `pre-commit` dependency is added to `dev` extras of the package and
+automatically installed when you run `pip install -e '.[dev]'`. Usually you
+only need to run `pre-commit install`.
+
+This command needs to be executed when you created the extension and
+initialized the repo. In addition, this command must be executed when you clone
+the extension, because hooks are not automatically installed inside clonned
+repo.
+
+Once you executed `pre-commit install` inside the repo, hooks will be
+automatically applied. If you change configuration of hooks, changes are
+applied automatically as well. There is no need to install hooks multiple
+times.
+
+Hooks can be removed by running `pre-commit uninstall` or disabled for a single
+commit via `-n` flag: `git commit -n ...`.
+
+#### Add new hooks
+
+Choose hook from [this list](https://pre-commit.com/hooks.html). Open
+documentation of the corresponding repo and search an example of hook
+configuration.
+
+Sometimes, there will be no example, like in case of [Markdown
+lint](https://github.com/markdownlint/markdownlint). In this case, you can
+manually write configuration of the hook. First, add a new item to `repos` list
+inside `.pre-commit-config.yaml`. Add repository url to `repo` attribute of
+this new item.
+
+```yaml
+- repo: https://github.com/markdownlint/markdownlint
+```
+
+Now, choose the latest tag of the repository and set it as value of `rev`:
+
+```yaml
+- repo: https://github.com/markdownlint/markdownlint
+ rev: v0.13.0
+```
+
+Finally, open `.pre-commit-hooks.yaml` file of the [repository with
+hooks](https://github.com/markdownlint/markdownlint/blob/main/.pre-commit-hooks.yaml). It
+contains definitions of all hooks provided by the repo. Choose hook and add it
+as `{"id": HOOK_ID}` inside `hooks` attribute of the configuration.
+
+```yaml
+- repo: https://github.com/markdownlint/markdownlint
+ rev: v0.13.0
+ hooks:
+ - id: markdownlint
+```
+
+#### Security
+
+`pre-commit` configuration contains configuration for
+[gitleaks](https://github.com/gitleaks/gitleaks) and
+[talisman](https://github.com/thoughtworks/talisman).
+
+These hooks can be pretty slow so they are disabled by default. But it's
+recommended to enable at least one of them to prevent accidental commits with
+credentials.
+
+
+### Asset builder: [gulp](https://gulpjs.com/)
+
+For compiling SCSS into CSS and similar tasks, extension uses
+`gulpfile.js`. It's a relatively simple task runner for NodeJS.
+
+Note: usually, any NodeJS version after v12 can be used with the gulpfile. But
+it's recommended to use NodeJS specified in `.node-version`/`.nvmrc`. If you
+are using `fnm`/`n`/`nvm`/any other NodeJS version manager, it should
+automatically read this file and use expected version of NodeJS.
+
+#### Initialization
+
+```sh
+npm ci
+```
+
+#### Execute task
+
+All available gulp tasks can be checked using `npx gulp --tasks`. Any of the
+listed tasks can be executed as `npx gulp `, for example: `npx gulp
+build`.
+
+For simplicity, two tasks are exposed via `npm` scripts:
+
+* `watch`: wait for changes, recompile styles and include sourcemaps. `npm run
+ dev`
+* `build`: recompile and minify styles. `npm run build`
+
+#### Add task
+
+Create a function inside `gulpfile.js`. The simplest function starts from call
+to `src`, that selects a file. Then you need to chain `.pipe` calls to specify
+transformations applied to file. Finally, the last `.pipe` call should contain
+result of `dest` call, which specifies the destination directory of the
+file. The name of the file is not changed(but you can apply `.pipe` that
+renames the file).
+
+For example, here's the function that copies `gulpfile.js` into `ooops`:
+
+```js
+const cp = () => src('gulpfile.js').pipe(dest("ooops"))
+```
+
+When function is created, you need to register it as task. Assign the function
+to any attribute of `exports` object. The name of the attribute is the name of
+the task. For example, if you want to expose `cp` function defined above as
+`COPY` task:
+
+```js
+exports.COPY = cp;
+```
+
+Now you can call the task via `npx gulp COPY` and you'll see
+`ooops/gulpfile.js` when command completed.
+
+### CKAN dependency management: [CDM](https://github.com/dataShades/ckan-deps-installer)
+
+CDM is a set of Make-rules that install CKAN extensions. Normal python
+dependencies(not a CKAN extension) must be added to `install_requires` section
+inside `setup.cfg` instead of using CDM.
+
+Things that CDM does can be done via pip and requirements.txt. Generally, we
+are using CDM to hide complex commands from the person who installs or deploys
+the project.
+
+You should always run `make prepare` before using CDM. This command initializes
+and updates CDM. If you see something like `make: *** No rule to make target
+'install'. Stop.`, most likely you forget to execute `make prepare`.
+
+The recommended way of using CDM is running `make full-upgrade`. This command
+downloads CKAN source, all required extensions, switches everything to expected
+branch/tag/commit and install everything.
+
+If you are going to modify extension, you probably want to install
+dev-dependencies from `dev-requirements.txt` of CKAN and extensions. Add
+`develop=1` to achieve this:
+
+```sh
+make full-upgrade develop=1
+```
+
+This command takes a lot of time, as it reinstalls every extension and CKAN
+itself. You can make the process faster, if you want to update only specific
+part of the codebase.
+
+If you want to synchronize(switch to expected branch/tag/commit) and install
+only CKAN, run
+
+```sh
+make ckan-sync ckan-install
+```
+
+If you want to synchronize and install all extensions(but not the CKAN), run
+
+```sh
+make sync install
+```
+
+If you want to synchronize and install just a single extension, find it's name
+inside `ext_list` variable of `Makefile`(you need to use the exact value,
+including letter case, hyphens and underscores). Then run the next command
+replacing `NAME` with the name of extension:
+
+```sh
+make sync-NAME install-NAME
+```
+
+
+#### Initialization
+
+```sh
+make prepare
+```
+
+#### Upgrading CKAN
+
+Modify `ckan_tag` inside `Makefile`, using new version tag and run `make
+full-upgrade`.
+
+#### Add dependency
+
+Modify `Makefile`:
+
+* add `remote-NAME` record replacing `NAME` with the name of new
+ dependency. Record is composed of the repo URL, reference type(`tag`,
+ `commit`, `branch`), and value of the reference.
+* add `NAME` to `ext_list`. `NAME` added to `ext_list` must be exactly the same
+ as name used in `remote-NAME`.
+* If you need extras(`pip install ckanext-something[extra1,extra2]`), specify
+ them as `package_extras-remote-NAME = extra1,extra2` after all `remote-`
+ lines.
+
+Run `make full-upgrade`.
+
+#### Use different version of the dependency on certain environments
+
+If you are using branch `master` on PROD, but want to test branch `develop` on
+DEV or locally, you can add *alternative* remotes.
+
+Let's assume you already have `remote-NAME = https://github/url branch master`
+inside Makefile. This is the default version of dependency, that is used by
+`full-upgrade` and `sync` make-rules.
+
+Now, add `dev-NAME = https://github/url branch develop` to Makefile. The main
+point here, you need to replace `remote-` prefix, with `dev-` prefix. You can
+also change URL of the repo, type of the reference or reference value(in the
+example branch `master` changed to `develop`).
+
+From this moment you can add `alternative=dev` to any command:
+
+```sh
+make full-upgrade alternative=dev
+make sync install alternative=dev
+make sync-NAME install-NAME alternative=dev
+```
+
+When `alternative=...` is added, makefile tries to install dependency using
+`-` prefix(`dev-` in our case) instead of `remote-`. If
+dependency with such prefix is found, it will be installed. If there is no such
+dependency, default version with `remote-` prefix is used. That's why all
+dependencies that do not have `dev-` version are still available.
+
+You can add as many alternatives as you want:
+```sh
+dev-NAME = https://github/url branch develop
+uat-NAME = https://github/url branch develop
+local1-NAME = https://github/url branch develop
+local2-NAME = https://github/url branch develop
+super-local-NAME = https://github/url branch develop
+```
+
+Every alternative is used only when you run make-rule with corresponding value
+of `alternative=...` argument.
+
+### Typechecker: [pyright](https://microsoft.github.io/pyright/)
+
+Extension uses `pyright` to verify correctness of types. To run the checker,
+use `npx pyright` or `make typecheck` command
+
+Typechecker is not included into git hooks because it is not fast enough. But
+you should always check types before the commit: any type error is as bad for
+the project as any other code-style issue, or even more serious. Developer may
+rely on typing system to simplify and optimize the code, so using uncertain or
+invalid types is a bad habit.
+
+There are 3 recommendations regarding typing:
+
+* every function must use typed parameters and typed output if it's different
+ from `None`.
+* generics/containers must include specification for the items. I.e, `list`
+ and `dict` are not allowed, use `list[Any]` and `dict[str, Any]` instead.
+* `Any` is allowed, but not recommended. Prefer using specific type, union or
+ generic.
+
+
+```python
+
+## GOOD
+def sum(a: int, b: int) -> int:
+ return a + b
+
+## BAD: result should be specified, even if it's inferred
+def sum(a: int, b: int):
+ return a + b
+
+## GOOD: result is `None`, so you can omit specification of return value
+def remove(path: str):
+ os.path.unlink(path)
+
+## BAD: incomplete generic type should be avoided.
+## It's better to use `list[Any]` instead of `list`.
+def sort(items: list):
+ items.sort()
+
+## BAD: use union `list[Any] | dict[str, Any]`
+def sort(items: Any):
+ if isinstance(items, list):
+ items.sort()
+ elif isinstance(items, dict)
+ ...
+ else:
+ raise TypeError
+```
+
+#### Initialization
+
+```sh
+npm ci
+```
+
+#### Configuration
+
+Pyright configuration is managed by `[tool.pyright]` section of
+`pyproject.toml`.
+
+### Code-style checker and formatter: [ruff](https://docs.astral.sh/ruff/)
+
+Extension uses `ruff` as linter and auto-formatter. Ruff contains
+implementation of various code-checkers and can also do the same things as
+`black` or `isort`.
+
+#### Check the code
+
+```sh
+ruff check .
+```
+
+#### Fix problems(not every problem can be fixed automatically)
+
+```sh
+ruff check --fix .
+```
+
+#### Format the code
+
+```sh
+ruff format .
+```
+
+#### Configuration
+
+Ruff configuration is managed by `[tool.ruff.*]` sections of `pyproject.toml`.
+
+
+### Unit tests: [pytest](https://docs.pytest.org/)
+
+Majority of tests for the extension is written using `pytest`.
+
+`ckanext/bulk/tests` contains examples of tests for standard
+operations. Every `test_*.py` file contains tests. Every `conftest.py` file
+defines fixtures that are available for modules on the same level and child
+modules.
+
+`ckanext/bulk/tests/benchmarks` contains benchmarks. They
+are written in the same way as normal tests, but we are using them to measure
+code performance. By default, all benchmarks are excluded from selection when
+pytest in running. You need to run benchmarks explicitely using `-m benchmark`
+argument of `pytest` command.
+
+```sh
+pytest -m benchmark
+```
+
+The bigger project grows, the more risks appear when you update something or
+add a new functionality. Even though tests do not guarantee that nothing is
+broken, they can help a lot. When you forget about certain feature, if it's
+covered by test, you'll likely notice when it stop working. And upgrading CKAN
+core becomes much more predictable when you have tests for main parts of you
+project.
+
+If possible, try achieving 100% test coverage. To measure current coverage, use
+
+```sh
+## print coverage to terminal
+pytest --cov=ckanext.bulk
+
+## generate HTML report at htmlcov/index.html
+pytest --cov=ckanext.bulk --cov-report html
+```
+
+#### Run tests
+
+Run all tests
+
+```sh
+pytest
+```
+
+Run tests from `ckanext/bulk/tests/test_plugin.py`
+
+```sh
+pytest ckanext/bulk/tests/test_plugin.py
+```
+
+Run only tests that failed during previous test session
+
+```sh
+pytest --lf
+```
+
+Stop execution after first failed test
+
+```sh
+pytest -x
+```
+
+Run only tests that contain `hello` and `world` in their full path. Full path
+contains filepath, class and test name: `ckanext/bulk/tests/test_smth.py:TestSmth:test_smth`
+
+```sh
+pytest -k "hello and world"
+```
+
+#### Produce coverage report
+
+```sh
+pytest --cov=ckanext.bulk
+```
+
+#### Run benchmarks
+
+```sh
+pytest -m benchmark
+```
+
+#### Configuration
+
+Pytest configuration is managed by `[tool.pytest.ini_options]` section of
+`pyproject.toml`.
+
+### End-to-end tests: [cypress](https://www.cypress.io/)
+
+You can test functions, action, views using pytest. But testing JS modules
+requires a different approach. And you may find writing e2e tests simpler with
+cypress, that pytest, because you can visualize the process.
+
+Cypress is used by this extension to perform testing in browser. Cypress opens
+application in a real browser and visits different pages, so you need a running
+CKAN application to run cyppress tests.
+
+You can use any application that is served on localhost:5000 and has `admin`
+user with password `password123`. There is a make-rule that starts such server
+using `test.ini` and creates required user. As it uses `test.ini`, you have to
+configure test environment before using it.
+
+```sh
+make test-server
+```
+
+With test server started in a separate terminal, you can run e2e tests in
+headless mode(without opening the browser):
+
+```sh
+npx cypress run
+```
+
+But if you are not familiar with cypress, you may find running tests inside
+interactive session more convenient:
+
+```sh
+npx cypress open
+```
+
+#### Write tests
+
+Tests are defined inside `cypress/e2e/` directory. You'll find examples there.
+
+#### Run tests
+
+```sh
+npx cypress run
+```
+
+#### Initialization
+
+```sh
+npm ci
+make test-server
+```
diff --git a/ckanext/__init__.py b/ckanext/__init__.py
new file mode 100644
index 0000000..6d83202
--- /dev/null
+++ b/ckanext/__init__.py
@@ -0,0 +1,9 @@
+# this is a namespace package
+try:
+ import pkg_resources
+
+ pkg_resources.declare_namespace(__name__)
+except ImportError:
+ import pkgutil
+
+ __path__ = pkgutil.extend_path(__path__, __name__)
diff --git a/ckanext/bulk/__init__.py b/ckanext/bulk/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/ckanext/bulk/assets/.gitignore b/ckanext/bulk/assets/.gitignore
new file mode 100644
index 0000000..e69de29
diff --git a/ckanext/bulk/assets/scripts/bulk-datepicker.js b/ckanext/bulk/assets/scripts/bulk-datepicker.js
new file mode 100644
index 0000000..7244782
--- /dev/null
+++ b/ckanext/bulk/assets/scripts/bulk-datepicker.js
@@ -0,0 +1,26 @@
+/**
+ * Daterangepicker adapter.
+ * https://www.daterangepicker.com/
+ */
+ckan.module("bulk-datepicker", function ($) {
+ return {
+ options: {},
+
+ initialize() {
+ // stop execution if dependency is missing.
+ if (typeof $.fn.daterangepicker === "undefined") {
+ // reporting the source of the problem is always a good idea.
+ console.error(
+ "[bulk-datepicker] daterangepicker library is not loaded",
+ );
+ return;
+ }
+
+ const options = this.sandbox["bulk"].nestedOptions(
+ this.options,
+ );
+
+ this.el.daterangepicker(options);
+ },
+ };
+});
diff --git a/ckanext/bulk/assets/scripts/bulk-izi-modal.js b/ckanext/bulk/assets/scripts/bulk-izi-modal.js
new file mode 100644
index 0000000..806f149
--- /dev/null
+++ b/ckanext/bulk/assets/scripts/bulk-izi-modal.js
@@ -0,0 +1,25 @@
+/**
+ * iziModal adapter.
+ * https://izimodal.marcelodolza.com
+ */
+ckan.module("bulk-izi-modal", function ($) {
+ return {
+ options: {},
+
+ initialize() {
+ // stop execution if dependency is missing.
+ if (typeof $.fn.iziModal === "undefined") {
+ // reporting the source of the problem is always a good idea.
+ console.error(
+ "[bulk-izi-modal] iziModal library is not loaded",
+ );
+ return;
+ }
+
+ this.modal = this.$("[data-izi-modal]").iziModal(this.options);
+ this.trigger = this.$("[data-izi-trigger]");
+
+ this.trigger.on("click", () => this.modal.iziModal("open"));
+ },
+ };
+});
diff --git a/ckanext/bulk/assets/scripts/bulk-izi-toast.js b/ckanext/bulk/assets/scripts/bulk-izi-toast.js
new file mode 100644
index 0000000..1143152
--- /dev/null
+++ b/ckanext/bulk/assets/scripts/bulk-izi-toast.js
@@ -0,0 +1,22 @@
+/**
+ * iziToast adapter.
+ * https://izitoast.marcelodolza.com
+ */
+ckan.module("bulk-izi-toast", function ($) {
+ return {
+ options: {},
+
+ initialize() {
+ // stop execution if dependency is missing.
+ if (typeof iziToast === "undefined") {
+ // reporting the source of the problem is always a good idea.
+ console.error(
+ "[bulk-izi-toast] iziToast library is not loaded",
+ );
+ return;
+ }
+
+ this.el.on("click", () => iziToast.show(this.options));
+ },
+ };
+});
diff --git a/ckanext/bulk/assets/scripts/bulk-scrollbar.js b/ckanext/bulk/assets/scripts/bulk-scrollbar.js
new file mode 100644
index 0000000..cd89555
--- /dev/null
+++ b/ckanext/bulk/assets/scripts/bulk-scrollbar.js
@@ -0,0 +1,26 @@
+/**
+ * OverlayScrollbars adapter.
+ * https://kingsora.github.io/OverlayScrollbars/
+ */
+ckan.module("bulk-scrollbar", function ($) {
+ return {
+ options: {},
+
+ initialize() {
+ // stop execution if dependency is missing.
+ if (typeof OverlayScrollbarsGlobal === "undefined") {
+ // reporting the source of the problem is always a good idea.
+ console.error(
+ "[bulk-scrollbar] OverlayScrollbars library is not loaded",
+ );
+ return;
+ }
+
+ const options = this.sandbox["bulk"].nestedOptions(
+ this.options,
+ );
+
+ OverlayScrollbarsGlobal.OverlayScrollbars(this.el[0], options)
+ },
+ };
+});
diff --git a/ckanext/bulk/assets/scripts/bulk-slick.js b/ckanext/bulk/assets/scripts/bulk-slick.js
new file mode 100644
index 0000000..db1e667
--- /dev/null
+++ b/ckanext/bulk/assets/scripts/bulk-slick.js
@@ -0,0 +1,22 @@
+/**
+ * Slick adapter.
+ * https://kenwheeler.github.io/slick/
+ */
+ckan.module("bulk-slick", function ($) {
+ return {
+ options: {},
+
+ initialize() {
+ // stop execution if dependency is missing.
+ if (typeof $.fn.slick === "undefined") {
+ // reporting the source of the problem is always a good idea.
+ console.error(
+ "[bulk-slick] slick library is not loaded",
+ );
+ return;
+ }
+
+ this.el.slick(this.options);
+ },
+ };
+});
diff --git a/ckanext/bulk/assets/scripts/bulk-sortable.js b/ckanext/bulk/assets/scripts/bulk-sortable.js
new file mode 100644
index 0000000..746b597
--- /dev/null
+++ b/ckanext/bulk/assets/scripts/bulk-sortable.js
@@ -0,0 +1,22 @@
+/**
+ * SortableJS adapter.
+ * https://sortablejs.github.io/Sortable/
+ */
+ckan.module("bulk-sortable", function () {
+ return {
+ options: {},
+
+ initialize() {
+ // stop execution if dependency is missing.
+ if (typeof Sortable === "undefined") {
+ // reporting the source of the problem is always a good idea.
+ console.error(
+ "[bulk-sortable] SortableJS library is not loaded",
+ );
+ return;
+ }
+
+ Sortable.create(this.el[0]);
+ },
+ };
+});
diff --git a/ckanext/bulk/assets/scripts/bulk-swal.js b/ckanext/bulk/assets/scripts/bulk-swal.js
new file mode 100644
index 0000000..a847e88
--- /dev/null
+++ b/ckanext/bulk/assets/scripts/bulk-swal.js
@@ -0,0 +1,25 @@
+/**
+ * SweetAlert2 adapter.
+ * https://sweetalert2.github.io/
+ */
+ckan.module("bulk-swal", function () {
+ return {
+ options: {},
+
+ initialize() {
+ // stop execution if dependency is missing.
+ if (typeof Swal === "undefined") {
+ // reporting the source of the problem is always a good idea.
+ console.error(
+ "[bulk-swal] SweetAlert library is not loaded",
+ );
+ return;
+ }
+
+ const options = this.sandbox["bulk"].nestedOptions(
+ this.options,
+ );
+ this.el.on("click", () => Swal.fire(options));
+ },
+ };
+});
diff --git a/ckanext/bulk/assets/scripts/bulk-tom-select.js b/ckanext/bulk/assets/scripts/bulk-tom-select.js
new file mode 100644
index 0000000..b9870ec
--- /dev/null
+++ b/ckanext/bulk/assets/scripts/bulk-tom-select.js
@@ -0,0 +1,38 @@
+/**
+ * TomSelect adapter.
+ * https://tom-select.js.org/
+ */
+ckan.module("bulk-tom-select", function () {
+
+ return {
+ // any attribute with `data-module-` prefix transforms into camelized
+ // option. `data-module-hello-world="1"` becomes `helloWorld: 1`. Case
+ // transformation happens only after hyphen. This is used to pass nested
+ // options. For example, `data-module-hello-world_bye-world` becomes
+ // `helloWorld_byeWorld`. Then options are processed by
+ // `this.sandbox.bulk.nestedOptions` and we receive
+ // `{helloWorld: {byeWorld: ...}}`.
+ options: {
+
+ },
+
+ initialize() {
+ // stop execution if dependency is missing.
+ if (typeof TomSelect === "undefined") {
+ // reporting the source of the problem is always a good idea.
+ console.error("[bulk-tom-select] TomSelect library is not loaded");
+ return
+ }
+
+ // tom-select has a number of nested options. We are using
+ // `nestedOptions` helper defined inside `bulk.js` to
+ // convert flat options of CKAN JS module into nested object.
+ const options = this.sandbox["bulk"].nestedOptions(this.options);
+
+ // in this case there is no value in keeping the reference to the
+ // widget. But if you are going to extend this module, sharing
+ // information between methods through `this` is a good choice.
+ this.widget = new TomSelect(this.el, options);
+ }
+ }
+})
diff --git a/ckanext/bulk/assets/scripts/bulk.js b/ckanext/bulk/assets/scripts/bulk.js
new file mode 100644
index 0000000..a6642a3
--- /dev/null
+++ b/ckanext/bulk/assets/scripts/bulk.js
@@ -0,0 +1,43 @@
+/**
+ * Code executed on every page.
+ *
+ * Avoid using this function and try extracting logic into CKAN JS modules.
+ */
+jQuery(function () {
+ // register plugin helpers inside Sandbox object, available as `this.sandbox`
+ // inside every module instance.
+ ckan.sandbox.extend({
+ "bulk": {
+ /**
+ * Transform `{hello_world_prop: 1}` into `{hello:{world:{prop: 1}}}`
+ */
+ nestedOptions(options) {
+ const nested = {};
+
+ for (let name in options) {
+ if (typeof name !== "string") continue;
+
+ const path = name.split("_");
+ const prop = path.pop();
+ const target = path.reduce((container, part) => {
+ container[part] = container[part] || {};
+ return container[part];
+ }, nested);
+ target[prop] = options[name];
+ }
+
+ return nested;
+ },
+ },
+ });
+
+ // initialize CKAN modules inside fragments loaded by HTMX
+ if (typeof htmx !== "undefined") {
+ htmx.on("htmx:afterSettle", function (event) {
+ var elements = event.target.querySelectorAll("[data-module]");
+ for (let node of elements) {
+ ckan.module.initializeElement(node);
+ }
+ });
+ }
+});
diff --git a/ckanext/bulk/assets/scss/_cssvars.scss b/ckanext/bulk/assets/scss/_cssvars.scss
new file mode 100644
index 0000000..24010a4
--- /dev/null
+++ b/ckanext/bulk/assets/scss/_cssvars.scss
@@ -0,0 +1,20 @@
+@use "sass:map";
+@use "variables" as v;
+
+// if you never use variable inside SASS expression, consider using
+// CSS-variables instead. CSS-variables are great for dynamic theme changes and
+// do not visibly affect rendering performance in majority of scenarios.
+:root {
+ --bulk-mobile-font-size: 14px;
+ --bulk-desktop-font-size: 16px;
+
+ --bulk-font-main: "Noto Sans", serif;
+ --bulk-font-heading: "Bitter", serif;
+
+ --bulk-color-brand: #{map.get(v.$pallette, "blue")};
+ --bulk-color-accent: #{map.get(v.$pallette, "red")};
+ --bulk-color-default: #{map.get(v.$pallette, "white")};
+ --bulk-color-link: #{map.get(v.$pallette, "navy")};
+ --bulk-color-text: #{map.get(v.$pallette, "black")};
+ --bulk-color-outline: #{map.get(v.$pallette, "gray")};
+}
diff --git a/ckanext/bulk/assets/scss/_fonts.scss b/ckanext/bulk/assets/scss/_fonts.scss
new file mode 100644
index 0000000..7126e59
--- /dev/null
+++ b/ckanext/bulk/assets/scss/_fonts.scss
@@ -0,0 +1,2 @@
+@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=Bitter:ital,wght@0,100..900;1,100..900&display=swap');
diff --git a/ckanext/bulk/assets/scss/_footer.scss b/ckanext/bulk/assets/scss/_footer.scss
new file mode 100644
index 0000000..9c2f379
--- /dev/null
+++ b/ckanext/bulk/assets/scss/_footer.scss
@@ -0,0 +1,11 @@
+@use "mixins" as m;
+@use "utils" as u;
+@use "variables" as v;
+
+.site-footer {
+ .nav .nav-item .nav-link {
+
+ &:hover, &:focus, &:active {
+ }
+ }
+}
diff --git a/ckanext/bulk/assets/scss/_global.scss b/ckanext/bulk/assets/scss/_global.scss
new file mode 100644
index 0000000..c365242
--- /dev/null
+++ b/ckanext/bulk/assets/scss/_global.scss
@@ -0,0 +1,20 @@
+@use "sass:map";
+@use "mixins" as m;
+@use "variables" as v;
+
+html {
+ font-size: var(--bulk-mobile-font-size);
+
+ @include m.breakpoint(md) {
+ font-size: var(--bulk-desktop-font-size);
+ }
+}
+
+body {
+ font-family: var(--bulk-font-main);
+}
+
+:focus,
+:focus-visible {
+ outline: var(--bulk-color-outline) auto 1px;
+}
diff --git a/ckanext/bulk/assets/scss/_header.scss b/ckanext/bulk/assets/scss/_header.scss
new file mode 100644
index 0000000..f2b5a8c
--- /dev/null
+++ b/ckanext/bulk/assets/scss/_header.scss
@@ -0,0 +1,16 @@
+@use "mixins" as m;
+@use "utils" as u;
+@use "variables" as v;
+
+.account-masthead {
+ .account {
+ ul li {
+ a {
+ }
+ }
+ }
+}
+
+.navbar {
+
+}
diff --git a/ckanext/bulk/assets/scss/_mixins.scss b/ckanext/bulk/assets/scss/_mixins.scss
new file mode 100644
index 0000000..b0c66f1
--- /dev/null
+++ b/ckanext/bulk/assets/scss/_mixins.scss
@@ -0,0 +1,14 @@
+@use "sass:map";
+@use "variables" as v;
+
+@mixin breakpoint($size) {
+ @media (min-width: #{map.get(v.$breakpoints, $size)}) {
+ @content;
+ }
+}
+
+@mixin text-default {
+ font-size: 1rem;
+ font-weight: 400;
+ line-height: 1.5rem;
+}
diff --git a/ckanext/bulk/assets/scss/_utils.scss b/ckanext/bulk/assets/scss/_utils.scss
new file mode 100644
index 0000000..fc50871
--- /dev/null
+++ b/ckanext/bulk/assets/scss/_utils.scss
@@ -0,0 +1,5 @@
+@use "sass:math";
+
+@function rem($value) {
+ @return math.div($value, 16px) * 1rem;
+}
diff --git a/ckanext/bulk/assets/scss/_variables.scss b/ckanext/bulk/assets/scss/_variables.scss
new file mode 100644
index 0000000..5d7a464
--- /dev/null
+++ b/ckanext/bulk/assets/scss/_variables.scss
@@ -0,0 +1,21 @@
+@use "sass:map";
+
+// Use maps to group related values that are not used often.
+$breakpoints: (
+ xs: 0,
+ sm: 576px,
+ md: 768px,
+ lg: 992px,
+ xl: 1200px,
+);
+
+// use map for values that are accessed only once or with values that are
+// processed in a loop.
+$pallette: (
+ "red": #ff0000,
+ "blue": #0000ff,
+ "navy": #000080,
+ "white": #ffffff,
+ "black": #000000,
+ "gray": #cccccc,
+);
diff --git a/ckanext/bulk/assets/scss/_vendor.scss b/ckanext/bulk/assets/scss/_vendor.scss
new file mode 100644
index 0000000..81ddd71
--- /dev/null
+++ b/ckanext/bulk/assets/scss/_vendor.scss
@@ -0,0 +1,9 @@
+@use "tom-select/src/scss/tom-select";
+// @use "tom-select/src/scss/tom-select.default";
+@use "sweetalert2/src/scss/theming";
+
+@use "slick-carousel/slick/slick";
+@use "slick-carousel/slick/slick-theme" with (
+ $slick-font-path: "/slick-fonts/",
+ $slick-loader-path: "/",
+);
diff --git a/ckanext/bulk/assets/scss/bulk.scss b/ckanext/bulk/assets/scss/bulk.scss
new file mode 100644
index 0000000..ea98212
--- /dev/null
+++ b/ckanext/bulk/assets/scss/bulk.scss
@@ -0,0 +1,28 @@
+@use "vendor";
+@use "fonts";
+@use "cssvars";
+
+@use "global";
+
+// Elements ///////////////////////////////////////////////////////////////////
+@use "elements/breadcrumb";
+@use "elements/buttons";
+@use "elements/forms";
+@use "elements/pagination";
+@use "elements/tables";
+
+// Global components //////////////////////////////////////////////////////////
+@use "header";
+@use "footer";
+
+// Specific pages /////////////////////////////////////////////////////////////
+@use "pages/search";
+// @use "pages/group";
+// @use "pages/organization";
+// @use "pages/dataset";
+// @use "pages/admin";
+// @use "pages/home";
+
+// Forms //////////////////////////////////////////////////////////////////////
+// @use "forms/package_form";
+// @use "forms/user_edit";
diff --git a/ckanext/bulk/assets/scss/elements/_breadcrumb.scss b/ckanext/bulk/assets/scss/elements/_breadcrumb.scss
new file mode 100644
index 0000000..b37585b
--- /dev/null
+++ b/ckanext/bulk/assets/scss/elements/_breadcrumb.scss
@@ -0,0 +1,25 @@
+.toolbar {
+ .breadcrumb {
+ li {
+ a {
+ }
+
+ &.home {
+ i {
+ }
+
+ span {
+ }
+ }
+
+ &.active {
+ a {
+ }
+ }
+
+ // arrow >
+ &:before {
+ }
+ }
+ }
+}
diff --git a/ckanext/bulk/assets/scss/elements/_buttons.scss b/ckanext/bulk/assets/scss/elements/_buttons.scss
new file mode 100644
index 0000000..6739074
--- /dev/null
+++ b/ckanext/bulk/assets/scss/elements/_buttons.scss
@@ -0,0 +1,26 @@
+@use "mixins" as m;
+@use "utils" as u;
+@use "variables" as v;
+
+.btn {
+ &.btn-primary {
+ &:hover,
+ &:active,
+ &:focus {
+
+ }
+ }
+
+
+ &.btn-default {
+ }
+
+ &.btn-link {
+ }
+
+ &.btn-white {
+ }
+
+ &.btn-sm {
+ }
+}
diff --git a/ckanext/bulk/assets/scss/elements/_forms.scss b/ckanext/bulk/assets/scss/elements/_forms.scss
new file mode 100644
index 0000000..72279d2
--- /dev/null
+++ b/ckanext/bulk/assets/scss/elements/_forms.scss
@@ -0,0 +1,12 @@
+@use "mixins" as m;
+@use "utils" as u;
+@use "variables" as v;
+
+.form-label {
+}
+
+.control-required {
+}
+
+.form-control {
+}
diff --git a/ckanext/bulk/assets/scss/elements/_pagination.scss b/ckanext/bulk/assets/scss/elements/_pagination.scss
new file mode 100644
index 0000000..66bdcb7
--- /dev/null
+++ b/ckanext/bulk/assets/scss/elements/_pagination.scss
@@ -0,0 +1,5 @@
+.pagination-wrapper,
+.dataTables_paginate {
+ .pagination {
+ }
+}
diff --git a/ckanext/bulk/assets/scss/elements/_tables.scss b/ckanext/bulk/assets/scss/elements/_tables.scss
new file mode 100644
index 0000000..1b94bac
--- /dev/null
+++ b/ckanext/bulk/assets/scss/elements/_tables.scss
@@ -0,0 +1,21 @@
+.table {
+
+ thead {
+
+ tr th,
+ tr td {
+ }
+ }
+
+ tbody {
+ }
+
+ tr {
+
+ th,
+ td {
+ }
+
+ }
+
+}
diff --git a/ckanext/bulk/assets/scss/pages/_search.scss b/ckanext/bulk/assets/scss/pages/_search.scss
new file mode 100644
index 0000000..e69de29
diff --git a/ckanext/bulk/assets/styles/bulk.css b/ckanext/bulk/assets/styles/bulk.css
new file mode 100644
index 0000000..e69de29
diff --git a/ckanext/bulk/assets/vendor/Sortable.js b/ckanext/bulk/assets/vendor/Sortable.js
new file mode 100644
index 0000000..bb99533
--- /dev/null
+++ b/ckanext/bulk/assets/vendor/Sortable.js
@@ -0,0 +1,2 @@
+/*! Sortable 1.15.2 - MIT | git://github.com/SortableJS/Sortable.git */
+!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;tt.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&p(t,e)||o&&t===n)return t}while(t!==n&&(t=(i=t).host&&i!==document&&i.host.nodeType?i.host:i.parentNode))}var i;return null}var g,m=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(m," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(m," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function v(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function b(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Bt(t){V&&V.parentNode[K]._isOutsideThisEl(t.target)}function Ft(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Pt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==Ft.supportPointer&&"PointerEvent"in window&&!u,emptyInsertThreshold:5};for(n in W.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in kt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&Nt,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),Dt.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,x())}function jt(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Ht(t){t.draggable=!1}function Lt(){Tt=!1}function Kt(t){return setTimeout(t,0)}function Wt(t){return clearTimeout(t)}Ft.prototype={constructor:Ft,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(mt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,V):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){xt.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&xt.push(o)}}(o),!V&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||tt===l)){if(ot=j(l),rt=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return q({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),G("filter",n,{evt:e}),void(i&&e.cancelable&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return q({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),G("filter",n,{evt:e}),!0}))return void(i&&e.cancelable&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!V&&n.parentNode===r&&(o=X(n),Q=r,Z=(V=n).parentNode,J=V.nextSibling,tt=n,lt=a.group,ct={target:Ft.dragged=V,clientX:(e||t).clientX,clientY:(e||t).clientY},ft=ct.clientX-o.left,pt=ct.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,V.style["will-change"]="all",o=function(){G("delayEnded",i,{evt:t}),Ft.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(V.draggable=!0),i._triggerDragStart(t,e),q({sortable:i,name:"choose",originalEvent:t}),k(V,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){b(V,t.trim(),Ht)}),h(l,"dragover",Yt),h(l,"mousemove",Yt),h(l,"touchmove",Yt),h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,V.draggable=!0),G("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():Ft.eventCanceled?this._onDrop():(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){V&&Ht(V),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;f(t,"mouseup",this._disableDelayedDrag),f(t,"touchend",this._disableDelayedDrag),f(t,"touchcancel",this._disableDelayedDrag),f(t,"mousemove",this._delayedDragTouchMoveHandler),f(t,"touchmove",this._delayedDragTouchMoveHandler),f(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(V,"dragend",this),h(Q,"dragstart",this._onDragStart));try{document.selection?Kt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;wt=!1,Q&&V?(G("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Bt),n=this.options,t||k(V,n.dragClass,!1),k(V,n.ghostClass,!0),Ft.active=this,t&&this._appendGhost(),q({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(ut){this._lastX=ut.clientX,this._lastY=ut.clientY,Rt();for(var t=document.elementFromPoint(ut.clientX,ut.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(ut.clientX,ut.clientY))!==e;)e=t;if(V.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:ut.clientX,clientY:ut.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=(t=e).parentNode);Xt()}},_onTouchMove:function(t){if(ct){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=$&&v($,!0),a=$&&r&&r.a,l=$&&r&&r.d,e=Mt&&yt&&E(yt),a=(i.clientX-ct.clientX+o.x)/(a||1)+(e?e[0]-Ct[0]:0)/(a||1),l=(i.clientY-ct.clientY+o.y)/(l||1)+(e?e[1]-Ct[1]:0)/(l||1);if(!Ft.active&&!wt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))D.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>D.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,$),e?t.clientX<_.left-10||t.clientY' +
+ '' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ ' ' +
+ '
' +
+ '';
+
+ this.parentEl = (options.parentEl && $(options.parentEl).length) ? $(options.parentEl) : $(this.parentEl);
+ this.container = $(options.template).appendTo(this.parentEl);
+
+ //
+ // handle all the possible options overriding defaults
+ //
+
+ if (typeof options.locale === 'object') {
+
+ if (typeof options.locale.direction === 'string')
+ this.locale.direction = options.locale.direction;
+
+ if (typeof options.locale.format === 'string')
+ this.locale.format = options.locale.format;
+
+ if (typeof options.locale.separator === 'string')
+ this.locale.separator = options.locale.separator;
+
+ if (typeof options.locale.daysOfWeek === 'object')
+ this.locale.daysOfWeek = options.locale.daysOfWeek.slice();
+
+ if (typeof options.locale.monthNames === 'object')
+ this.locale.monthNames = options.locale.monthNames.slice();
+
+ if (typeof options.locale.firstDay === 'number')
+ this.locale.firstDay = options.locale.firstDay;
+
+ if (typeof options.locale.applyLabel === 'string')
+ this.locale.applyLabel = options.locale.applyLabel;
+
+ if (typeof options.locale.cancelLabel === 'string')
+ this.locale.cancelLabel = options.locale.cancelLabel;
+
+ if (typeof options.locale.weekLabel === 'string')
+ this.locale.weekLabel = options.locale.weekLabel;
+
+ if (typeof options.locale.customRangeLabel === 'string'){
+ //Support unicode chars in the custom range name.
+ var elem = document.createElement('textarea');
+ elem.innerHTML = options.locale.customRangeLabel;
+ var rangeHtml = elem.value;
+ this.locale.customRangeLabel = rangeHtml;
+ }
+ }
+ this.container.addClass(this.locale.direction);
+
+ if (typeof options.startDate === 'string')
+ this.startDate = moment(options.startDate, this.locale.format);
+
+ if (typeof options.endDate === 'string')
+ this.endDate = moment(options.endDate, this.locale.format);
+
+ if (typeof options.minDate === 'string')
+ this.minDate = moment(options.minDate, this.locale.format);
+
+ if (typeof options.maxDate === 'string')
+ this.maxDate = moment(options.maxDate, this.locale.format);
+
+ if (typeof options.startDate === 'object')
+ this.startDate = moment(options.startDate);
+
+ if (typeof options.endDate === 'object')
+ this.endDate = moment(options.endDate);
+
+ if (typeof options.minDate === 'object')
+ this.minDate = moment(options.minDate);
+
+ if (typeof options.maxDate === 'object')
+ this.maxDate = moment(options.maxDate);
+
+ // sanity check for bad options
+ if (this.minDate && this.startDate.isBefore(this.minDate))
+ this.startDate = this.minDate.clone();
+
+ // sanity check for bad options
+ if (this.maxDate && this.endDate.isAfter(this.maxDate))
+ this.endDate = this.maxDate.clone();
+
+ if (typeof options.applyButtonClasses === 'string')
+ this.applyButtonClasses = options.applyButtonClasses;
+
+ if (typeof options.applyClass === 'string') //backwards compat
+ this.applyButtonClasses = options.applyClass;
+
+ if (typeof options.cancelButtonClasses === 'string')
+ this.cancelButtonClasses = options.cancelButtonClasses;
+
+ if (typeof options.cancelClass === 'string') //backwards compat
+ this.cancelButtonClasses = options.cancelClass;
+
+ if (typeof options.maxSpan === 'object')
+ this.maxSpan = options.maxSpan;
+
+ if (typeof options.dateLimit === 'object') //backwards compat
+ this.maxSpan = options.dateLimit;
+
+ if (typeof options.opens === 'string')
+ this.opens = options.opens;
+
+ if (typeof options.drops === 'string')
+ this.drops = options.drops;
+
+ if (typeof options.showWeekNumbers === 'boolean')
+ this.showWeekNumbers = options.showWeekNumbers;
+
+ if (typeof options.showISOWeekNumbers === 'boolean')
+ this.showISOWeekNumbers = options.showISOWeekNumbers;
+
+ if (typeof options.buttonClasses === 'string')
+ this.buttonClasses = options.buttonClasses;
+
+ if (typeof options.buttonClasses === 'object')
+ this.buttonClasses = options.buttonClasses.join(' ');
+
+ if (typeof options.showDropdowns === 'boolean')
+ this.showDropdowns = options.showDropdowns;
+
+ if (typeof options.minYear === 'number')
+ this.minYear = options.minYear;
+
+ if (typeof options.maxYear === 'number')
+ this.maxYear = options.maxYear;
+
+ if (typeof options.showCustomRangeLabel === 'boolean')
+ this.showCustomRangeLabel = options.showCustomRangeLabel;
+
+ if (typeof options.singleDatePicker === 'boolean') {
+ this.singleDatePicker = options.singleDatePicker;
+ if (this.singleDatePicker)
+ this.endDate = this.startDate.clone();
+ }
+
+ if (typeof options.timePicker === 'boolean')
+ this.timePicker = options.timePicker;
+
+ if (typeof options.timePickerSeconds === 'boolean')
+ this.timePickerSeconds = options.timePickerSeconds;
+
+ if (typeof options.timePickerIncrement === 'number')
+ this.timePickerIncrement = options.timePickerIncrement;
+
+ if (typeof options.timePicker24Hour === 'boolean')
+ this.timePicker24Hour = options.timePicker24Hour;
+
+ if (typeof options.autoApply === 'boolean')
+ this.autoApply = options.autoApply;
+
+ if (typeof options.autoUpdateInput === 'boolean')
+ this.autoUpdateInput = options.autoUpdateInput;
+
+ if (typeof options.linkedCalendars === 'boolean')
+ this.linkedCalendars = options.linkedCalendars;
+
+ if (typeof options.isInvalidDate === 'function')
+ this.isInvalidDate = options.isInvalidDate;
+
+ if (typeof options.isCustomDate === 'function')
+ this.isCustomDate = options.isCustomDate;
+
+ if (typeof options.alwaysShowCalendars === 'boolean')
+ this.alwaysShowCalendars = options.alwaysShowCalendars;
+
+ // update day names order to firstDay
+ if (this.locale.firstDay != 0) {
+ var iterator = this.locale.firstDay;
+ while (iterator > 0) {
+ this.locale.daysOfWeek.push(this.locale.daysOfWeek.shift());
+ iterator--;
+ }
+ }
+
+ var start, end, range;
+
+ //if no start/end dates set, check if an input element contains initial values
+ if (typeof options.startDate === 'undefined' && typeof options.endDate === 'undefined') {
+ if ($(this.element).is(':text')) {
+ var val = $(this.element).val(),
+ split = val.split(this.locale.separator);
+
+ start = end = null;
+
+ if (split.length == 2) {
+ start = moment(split[0], this.locale.format);
+ end = moment(split[1], this.locale.format);
+ } else if (this.singleDatePicker && val !== "") {
+ start = moment(val, this.locale.format);
+ end = moment(val, this.locale.format);
+ }
+ if (start !== null && end !== null) {
+ this.setStartDate(start);
+ this.setEndDate(end);
+ }
+ }
+ }
+
+ if (typeof options.ranges === 'object') {
+ for (range in options.ranges) {
+
+ if (typeof options.ranges[range][0] === 'string')
+ start = moment(options.ranges[range][0], this.locale.format);
+ else
+ start = moment(options.ranges[range][0]);
+
+ if (typeof options.ranges[range][1] === 'string')
+ end = moment(options.ranges[range][1], this.locale.format);
+ else
+ end = moment(options.ranges[range][1]);
+
+ // If the start or end date exceed those allowed by the minDate or maxSpan
+ // options, shorten the range to the allowable period.
+ if (this.minDate && start.isBefore(this.minDate))
+ start = this.minDate.clone();
+
+ var maxDate = this.maxDate;
+ if (this.maxSpan && maxDate && start.clone().add(this.maxSpan).isAfter(maxDate))
+ maxDate = start.clone().add(this.maxSpan);
+ if (maxDate && end.isAfter(maxDate))
+ end = maxDate.clone();
+
+ // If the end of the range is before the minimum or the start of the range is
+ // after the maximum, don't display this range option at all.
+ if ((this.minDate && end.isBefore(this.minDate, this.timepicker ? 'minute' : 'day'))
+ || (maxDate && start.isAfter(maxDate, this.timepicker ? 'minute' : 'day')))
+ continue;
+
+ //Support unicode chars in the range names.
+ var elem = document.createElement('textarea');
+ elem.innerHTML = range;
+ var rangeHtml = elem.value;
+
+ this.ranges[rangeHtml] = [start, end];
+ }
+
+ var list = '
';
+ for (range in this.ranges) {
+ list += '
' + range + '
';
+ }
+ if (this.showCustomRangeLabel) {
+ list += '
' + this.locale.customRangeLabel + '
';
+ }
+ list += '
';
+ this.container.find('.ranges').prepend(list);
+ }
+
+ if (typeof cb === 'function') {
+ this.callback = cb;
+ }
+
+ if (!this.timePicker) {
+ this.startDate = this.startDate.startOf('day');
+ this.endDate = this.endDate.endOf('day');
+ this.container.find('.calendar-time').hide();
+ }
+
+ //can't be used together for now
+ if (this.timePicker && this.autoApply)
+ this.autoApply = false;
+
+ if (this.autoApply) {
+ this.container.addClass('auto-apply');
+ }
+
+ if (typeof options.ranges === 'object')
+ this.container.addClass('show-ranges');
+
+ if (this.singleDatePicker) {
+ this.container.addClass('single');
+ this.container.find('.drp-calendar.left').addClass('single');
+ this.container.find('.drp-calendar.left').show();
+ this.container.find('.drp-calendar.right').hide();
+ if (!this.timePicker && this.autoApply) {
+ this.container.addClass('auto-apply');
+ }
+ }
+
+ if ((typeof options.ranges === 'undefined' && !this.singleDatePicker) || this.alwaysShowCalendars) {
+ this.container.addClass('show-calendar');
+ }
+
+ this.container.addClass('opens' + this.opens);
+
+ //apply CSS classes and labels to buttons
+ this.container.find('.applyBtn, .cancelBtn').addClass(this.buttonClasses);
+ if (this.applyButtonClasses.length)
+ this.container.find('.applyBtn').addClass(this.applyButtonClasses);
+ if (this.cancelButtonClasses.length)
+ this.container.find('.cancelBtn').addClass(this.cancelButtonClasses);
+ this.container.find('.applyBtn').html(this.locale.applyLabel);
+ this.container.find('.cancelBtn').html(this.locale.cancelLabel);
+
+ //
+ // event listeners
+ //
+
+ this.container.find('.drp-calendar')
+ .on('click.daterangepicker', '.prev', $.proxy(this.clickPrev, this))
+ .on('click.daterangepicker', '.next', $.proxy(this.clickNext, this))
+ .on('mousedown.daterangepicker', 'td.available', $.proxy(this.clickDate, this))
+ .on('mouseenter.daterangepicker', 'td.available', $.proxy(this.hoverDate, this))
+ .on('change.daterangepicker', 'select.yearselect', $.proxy(this.monthOrYearChanged, this))
+ .on('change.daterangepicker', 'select.monthselect', $.proxy(this.monthOrYearChanged, this))
+ .on('change.daterangepicker', 'select.hourselect,select.minuteselect,select.secondselect,select.ampmselect', $.proxy(this.timeChanged, this));
+
+ this.container.find('.ranges')
+ .on('click.daterangepicker', 'li', $.proxy(this.clickRange, this));
+
+ this.container.find('.drp-buttons')
+ .on('click.daterangepicker', 'button.applyBtn', $.proxy(this.clickApply, this))
+ .on('click.daterangepicker', 'button.cancelBtn', $.proxy(this.clickCancel, this));
+
+ if (this.element.is('input') || this.element.is('button')) {
+ this.element.on({
+ 'click.daterangepicker': $.proxy(this.show, this),
+ 'focus.daterangepicker': $.proxy(this.show, this),
+ 'keyup.daterangepicker': $.proxy(this.elementChanged, this),
+ 'keydown.daterangepicker': $.proxy(this.keydown, this) //IE 11 compatibility
+ });
+ } else {
+ this.element.on('click.daterangepicker', $.proxy(this.toggle, this));
+ this.element.on('keydown.daterangepicker', $.proxy(this.toggle, this));
+ }
+
+ //
+ // if attached to a text input, set the initial value
+ //
+
+ this.updateElement();
+
+ };
+
+ DateRangePicker.prototype = {
+
+ constructor: DateRangePicker,
+
+ setStartDate: function(startDate) {
+ if (typeof startDate === 'string')
+ this.startDate = moment(startDate, this.locale.format);
+
+ if (typeof startDate === 'object')
+ this.startDate = moment(startDate);
+
+ if (!this.timePicker)
+ this.startDate = this.startDate.startOf('day');
+
+ if (this.timePicker && this.timePickerIncrement)
+ this.startDate.minute(Math.round(this.startDate.minute() / this.timePickerIncrement) * this.timePickerIncrement);
+
+ if (this.minDate && this.startDate.isBefore(this.minDate)) {
+ this.startDate = this.minDate.clone();
+ if (this.timePicker && this.timePickerIncrement)
+ this.startDate.minute(Math.round(this.startDate.minute() / this.timePickerIncrement) * this.timePickerIncrement);
+ }
+
+ if (this.maxDate && this.startDate.isAfter(this.maxDate)) {
+ this.startDate = this.maxDate.clone();
+ if (this.timePicker && this.timePickerIncrement)
+ this.startDate.minute(Math.floor(this.startDate.minute() / this.timePickerIncrement) * this.timePickerIncrement);
+ }
+
+ if (!this.isShowing)
+ this.updateElement();
+
+ this.updateMonthsInView();
+ },
+
+ setEndDate: function(endDate) {
+ if (typeof endDate === 'string')
+ this.endDate = moment(endDate, this.locale.format);
+
+ if (typeof endDate === 'object')
+ this.endDate = moment(endDate);
+
+ if (!this.timePicker)
+ this.endDate = this.endDate.endOf('day');
+
+ if (this.timePicker && this.timePickerIncrement)
+ this.endDate.minute(Math.round(this.endDate.minute() / this.timePickerIncrement) * this.timePickerIncrement);
+
+ if (this.endDate.isBefore(this.startDate))
+ this.endDate = this.startDate.clone();
+
+ if (this.maxDate && this.endDate.isAfter(this.maxDate))
+ this.endDate = this.maxDate.clone();
+
+ if (this.maxSpan && this.startDate.clone().add(this.maxSpan).isBefore(this.endDate))
+ this.endDate = this.startDate.clone().add(this.maxSpan);
+
+ this.previousRightTime = this.endDate.clone();
+
+ this.container.find('.drp-selected').html(this.startDate.format(this.locale.format) + this.locale.separator + this.endDate.format(this.locale.format));
+
+ if (!this.isShowing)
+ this.updateElement();
+
+ this.updateMonthsInView();
+ },
+
+ isInvalidDate: function() {
+ return false;
+ },
+
+ isCustomDate: function() {
+ return false;
+ },
+
+ updateView: function() {
+ if (this.timePicker) {
+ this.renderTimePicker('left');
+ this.renderTimePicker('right');
+ if (!this.endDate) {
+ this.container.find('.right .calendar-time select').prop('disabled', true).addClass('disabled');
+ } else {
+ this.container.find('.right .calendar-time select').prop('disabled', false).removeClass('disabled');
+ }
+ }
+ if (this.endDate)
+ this.container.find('.drp-selected').html(this.startDate.format(this.locale.format) + this.locale.separator + this.endDate.format(this.locale.format));
+ this.updateMonthsInView();
+ this.updateCalendars();
+ this.updateFormInputs();
+ },
+
+ updateMonthsInView: function() {
+ if (this.endDate) {
+
+ //if both dates are visible already, do nothing
+ if (!this.singleDatePicker && this.leftCalendar.month && this.rightCalendar.month &&
+ (this.startDate.format('YYYY-MM') == this.leftCalendar.month.format('YYYY-MM') || this.startDate.format('YYYY-MM') == this.rightCalendar.month.format('YYYY-MM'))
+ &&
+ (this.endDate.format('YYYY-MM') == this.leftCalendar.month.format('YYYY-MM') || this.endDate.format('YYYY-MM') == this.rightCalendar.month.format('YYYY-MM'))
+ ) {
+ return;
+ }
+
+ this.leftCalendar.month = this.startDate.clone().date(2);
+ if (!this.linkedCalendars && (this.endDate.month() != this.startDate.month() || this.endDate.year() != this.startDate.year())) {
+ this.rightCalendar.month = this.endDate.clone().date(2);
+ } else {
+ this.rightCalendar.month = this.startDate.clone().date(2).add(1, 'month');
+ }
+
+ } else {
+ if (this.leftCalendar.month.format('YYYY-MM') != this.startDate.format('YYYY-MM') && this.rightCalendar.month.format('YYYY-MM') != this.startDate.format('YYYY-MM')) {
+ this.leftCalendar.month = this.startDate.clone().date(2);
+ this.rightCalendar.month = this.startDate.clone().date(2).add(1, 'month');
+ }
+ }
+ if (this.maxDate && this.linkedCalendars && !this.singleDatePicker && this.rightCalendar.month > this.maxDate) {
+ this.rightCalendar.month = this.maxDate.clone().date(2);
+ this.leftCalendar.month = this.maxDate.clone().date(2).subtract(1, 'month');
+ }
+ },
+
+ updateCalendars: function() {
+
+ if (this.timePicker) {
+ var hour, minute, second;
+ if (this.endDate) {
+ hour = parseInt(this.container.find('.left .hourselect').val(), 10);
+ minute = parseInt(this.container.find('.left .minuteselect').val(), 10);
+ if (isNaN(minute)) {
+ minute = parseInt(this.container.find('.left .minuteselect option:last').val(), 10);
+ }
+ second = this.timePickerSeconds ? parseInt(this.container.find('.left .secondselect').val(), 10) : 0;
+ if (!this.timePicker24Hour) {
+ var ampm = this.container.find('.left .ampmselect').val();
+ if (ampm === 'PM' && hour < 12)
+ hour += 12;
+ if (ampm === 'AM' && hour === 12)
+ hour = 0;
+ }
+ } else {
+ hour = parseInt(this.container.find('.right .hourselect').val(), 10);
+ minute = parseInt(this.container.find('.right .minuteselect').val(), 10);
+ if (isNaN(minute)) {
+ minute = parseInt(this.container.find('.right .minuteselect option:last').val(), 10);
+ }
+ second = this.timePickerSeconds ? parseInt(this.container.find('.right .secondselect').val(), 10) : 0;
+ if (!this.timePicker24Hour) {
+ var ampm = this.container.find('.right .ampmselect').val();
+ if (ampm === 'PM' && hour < 12)
+ hour += 12;
+ if (ampm === 'AM' && hour === 12)
+ hour = 0;
+ }
+ }
+ this.leftCalendar.month.hour(hour).minute(minute).second(second);
+ this.rightCalendar.month.hour(hour).minute(minute).second(second);
+ }
+
+ this.renderCalendar('left');
+ this.renderCalendar('right');
+
+ //highlight any predefined range matching the current start and end dates
+ this.container.find('.ranges li').removeClass('active');
+ if (this.endDate == null) return;
+
+ this.calculateChosenLabel();
+ },
+
+ renderCalendar: function(side) {
+
+ //
+ // Build the matrix of dates that will populate the calendar
+ //
+
+ var calendar = side == 'left' ? this.leftCalendar : this.rightCalendar;
+ var month = calendar.month.month();
+ var year = calendar.month.year();
+ var hour = calendar.month.hour();
+ var minute = calendar.month.minute();
+ var second = calendar.month.second();
+ var daysInMonth = moment([year, month]).daysInMonth();
+ var firstDay = moment([year, month, 1]);
+ var lastDay = moment([year, month, daysInMonth]);
+ var lastMonth = moment(firstDay).subtract(1, 'month').month();
+ var lastYear = moment(firstDay).subtract(1, 'month').year();
+ var daysInLastMonth = moment([lastYear, lastMonth]).daysInMonth();
+ var dayOfWeek = firstDay.day();
+
+ //initialize a 6 rows x 7 columns array for the calendar
+ var calendar = [];
+ calendar.firstDay = firstDay;
+ calendar.lastDay = lastDay;
+
+ for (var i = 0; i < 6; i++) {
+ calendar[i] = [];
+ }
+
+ //populate the calendar with date objects
+ var startDay = daysInLastMonth - dayOfWeek + this.locale.firstDay + 1;
+ if (startDay > daysInLastMonth)
+ startDay -= 7;
+
+ if (dayOfWeek == this.locale.firstDay)
+ startDay = daysInLastMonth - 6;
+
+ var curDate = moment([lastYear, lastMonth, startDay, 12, minute, second]);
+
+ var col, row;
+ for (var i = 0, col = 0, row = 0; i < 42; i++, col++, curDate = moment(curDate).add(24, 'hour')) {
+ if (i > 0 && col % 7 === 0) {
+ col = 0;
+ row++;
+ }
+ calendar[row][col] = curDate.clone().hour(hour).minute(minute).second(second);
+ curDate.hour(12);
+
+ if (this.minDate && calendar[row][col].format('YYYY-MM-DD') == this.minDate.format('YYYY-MM-DD') && calendar[row][col].isBefore(this.minDate) && side == 'left') {
+ calendar[row][col] = this.minDate.clone();
+ }
+
+ if (this.maxDate && calendar[row][col].format('YYYY-MM-DD') == this.maxDate.format('YYYY-MM-DD') && calendar[row][col].isAfter(this.maxDate) && side == 'right') {
+ calendar[row][col] = this.maxDate.clone();
+ }
+
+ }
+
+ //make the calendar object available to hoverDate/clickDate
+ if (side == 'left') {
+ this.leftCalendar.calendar = calendar;
+ } else {
+ this.rightCalendar.calendar = calendar;
+ }
+
+ //
+ // Display the calendar
+ //
+
+ var minDate = side == 'left' ? this.minDate : this.startDate;
+ var maxDate = this.maxDate;
+ var selected = side == 'left' ? this.startDate : this.endDate;
+ var arrow = this.locale.direction == 'ltr' ? {left: 'chevron-left', right: 'chevron-right'} : {left: 'chevron-right', right: 'chevron-left'};
+
+ var html = '
';
+ html += '';
+ html += '
';
+
+ // add empty cell for week number
+ if (this.showWeekNumbers || this.showISOWeekNumbers)
+ html += '
';
+
+ if ((!minDate || minDate.isBefore(calendar.firstDay)) && (!this.linkedCalendars || side == 'left')) {
+ html += '
';
+ } else {
+ html += '
';
+ }
+
+ var dateHtml = this.locale.monthNames[calendar[1][1].month()] + calendar[1][1].format(" YYYY");
+
+ if (this.showDropdowns) {
+ var currentMonth = calendar[1][1].month();
+ var currentYear = calendar[1][1].year();
+ var maxYear = (maxDate && maxDate.year()) || (this.maxYear);
+ var minYear = (minDate && minDate.year()) || (this.minYear);
+ var inMinYear = currentYear == minYear;
+ var inMaxYear = currentYear == maxYear;
+
+ var monthHtml = '";
+
+ var yearHtml = '';
+
+ dateHtml = monthHtml + yearHtml;
+ }
+
+ html += '
' + dateHtml + '
';
+ if ((!maxDate || maxDate.isAfter(calendar.lastDay)) && (!this.linkedCalendars || side == 'right' || this.singleDatePicker)) {
+ html += '
';
+ } else {
+ html += '
';
+ }
+
+ html += '
';
+ html += '
';
+
+ // add week number label
+ if (this.showWeekNumbers || this.showISOWeekNumbers)
+ html += '
';
+ html += '';
+ html += '';
+
+ //adjust maxDate to reflect the maxSpan setting in order to
+ //grey out end dates beyond the maxSpan
+ if (this.endDate == null && this.maxSpan) {
+ var maxLimit = this.startDate.clone().add(this.maxSpan).endOf('day');
+ if (!maxDate || maxLimit.isBefore(maxDate)) {
+ maxDate = maxLimit;
+ }
+ }
+
+ for (var row = 0; row < 6; row++) {
+ html += '
';
+
+ // add week number
+ if (this.showWeekNumbers)
+ html += '
' + calendar[row][0].week() + '
';
+ else if (this.showISOWeekNumbers)
+ html += '
' + calendar[row][0].isoWeek() + '
';
+
+ for (var col = 0; col < 7; col++) {
+
+ var classes = [];
+
+ //highlight today's date
+ if (calendar[row][col].isSame(new Date(), "day"))
+ classes.push('today');
+
+ //highlight weekends
+ if (calendar[row][col].isoWeekday() > 5)
+ classes.push('weekend');
+
+ //grey out the dates in other months displayed at beginning and end of this calendar
+ if (calendar[row][col].month() != calendar[1][1].month())
+ classes.push('off', 'ends');
+
+ //don't allow selection of dates before the minimum date
+ if (this.minDate && calendar[row][col].isBefore(this.minDate, 'day'))
+ classes.push('off', 'disabled');
+
+ //don't allow selection of dates after the maximum date
+ if (maxDate && calendar[row][col].isAfter(maxDate, 'day'))
+ classes.push('off', 'disabled');
+
+ //don't allow selection of date if a custom function decides it's invalid
+ if (this.isInvalidDate(calendar[row][col]))
+ classes.push('off', 'disabled');
+
+ //highlight the currently selected start date
+ if (calendar[row][col].format('YYYY-MM-DD') == this.startDate.format('YYYY-MM-DD'))
+ classes.push('active', 'start-date');
+
+ //highlight the currently selected end date
+ if (this.endDate != null && calendar[row][col].format('YYYY-MM-DD') == this.endDate.format('YYYY-MM-DD'))
+ classes.push('active', 'end-date');
+
+ //highlight dates in-between the selected dates
+ if (this.endDate != null && calendar[row][col] > this.startDate && calendar[row][col] < this.endDate)
+ classes.push('in-range');
+
+ //apply custom classes for this date
+ var isCustom = this.isCustomDate(calendar[row][col]);
+ if (isCustom !== false) {
+ if (typeof isCustom === 'string')
+ classes.push(isCustom);
+ else
+ Array.prototype.push.apply(classes, isCustom);
+ }
+
+ var cname = '', disabled = false;
+ for (var i = 0; i < classes.length; i++) {
+ cname += classes[i] + ' ';
+ if (classes[i] == 'disabled')
+ disabled = true;
+ }
+ if (!disabled)
+ cname += 'available';
+
+ html += '
' + calendar[row][col].date() + '
';
+
+ }
+ html += '
';
+ }
+
+ html += '';
+ html += '
';
+
+ this.container.find('.drp-calendar.' + side + ' .calendar-table').html(html);
+
+ },
+
+ renderTimePicker: function(side) {
+
+ // Don't bother updating the time picker if it's currently disabled
+ // because an end date hasn't been clicked yet
+ if (side == 'right' && !this.endDate) return;
+
+ var html, selected, minDate, maxDate = this.maxDate;
+
+ if (this.maxSpan && (!this.maxDate || this.startDate.clone().add(this.maxSpan).isBefore(this.maxDate)))
+ maxDate = this.startDate.clone().add(this.maxSpan);
+
+ if (side == 'left') {
+ selected = this.startDate.clone();
+ minDate = this.minDate;
+ } else if (side == 'right') {
+ selected = this.endDate.clone();
+ minDate = this.startDate;
+
+ //Preserve the time already selected
+ var timeSelector = this.container.find('.drp-calendar.right .calendar-time');
+ if (timeSelector.html() != '') {
+
+ selected.hour(!isNaN(selected.hour()) ? selected.hour() : timeSelector.find('.hourselect option:selected').val());
+ selected.minute(!isNaN(selected.minute()) ? selected.minute() : timeSelector.find('.minuteselect option:selected').val());
+ selected.second(!isNaN(selected.second()) ? selected.second() : timeSelector.find('.secondselect option:selected').val());
+
+ if (!this.timePicker24Hour) {
+ var ampm = timeSelector.find('.ampmselect option:selected').val();
+ if (ampm === 'PM' && selected.hour() < 12)
+ selected.hour(selected.hour() + 12);
+ if (ampm === 'AM' && selected.hour() === 12)
+ selected.hour(0);
+ }
+
+ }
+
+ if (selected.isBefore(this.startDate))
+ selected = this.startDate.clone();
+
+ if (maxDate && selected.isAfter(maxDate))
+ selected = maxDate.clone();
+
+ }
+
+ //
+ // hours
+ //
+
+ html = ' ';
+
+ //
+ // minutes
+ //
+
+ html += ': ';
+
+ //
+ // seconds
+ //
+
+ if (this.timePickerSeconds) {
+ html += ': ';
+ }
+
+ //
+ // AM/PM
+ //
+
+ if (!this.timePicker24Hour) {
+ html += '';
+ }
+
+ this.container.find('.drp-calendar.' + side + ' .calendar-time').html(html);
+
+ },
+
+ updateFormInputs: function() {
+
+ if (this.singleDatePicker || (this.endDate && (this.startDate.isBefore(this.endDate) || this.startDate.isSame(this.endDate)))) {
+ this.container.find('button.applyBtn').prop('disabled', false);
+ } else {
+ this.container.find('button.applyBtn').prop('disabled', true);
+ }
+
+ },
+
+ move: function() {
+ var parentOffset = { top: 0, left: 0 },
+ containerTop,
+ drops = this.drops;
+
+ var parentRightEdge = $(window).width();
+ if (!this.parentEl.is('body')) {
+ parentOffset = {
+ top: this.parentEl.offset().top - this.parentEl.scrollTop(),
+ left: this.parentEl.offset().left - this.parentEl.scrollLeft()
+ };
+ parentRightEdge = this.parentEl[0].clientWidth + this.parentEl.offset().left;
+ }
+
+ switch (drops) {
+ case 'auto':
+ containerTop = this.element.offset().top + this.element.outerHeight() - parentOffset.top;
+ if (containerTop + this.container.outerHeight() >= this.parentEl[0].scrollHeight) {
+ containerTop = this.element.offset().top - this.container.outerHeight() - parentOffset.top;
+ drops = 'up';
+ }
+ break;
+ case 'up':
+ containerTop = this.element.offset().top - this.container.outerHeight() - parentOffset.top;
+ break;
+ default:
+ containerTop = this.element.offset().top + this.element.outerHeight() - parentOffset.top;
+ break;
+ }
+
+ // Force the container to it's actual width
+ this.container.css({
+ top: 0,
+ left: 0,
+ right: 'auto'
+ });
+ var containerWidth = this.container.outerWidth();
+
+ this.container.toggleClass('drop-up', drops == 'up');
+
+ if (this.opens == 'left') {
+ var containerRight = parentRightEdge - this.element.offset().left - this.element.outerWidth();
+ if (containerWidth + containerRight > $(window).width()) {
+ this.container.css({
+ top: containerTop,
+ right: 'auto',
+ left: 9
+ });
+ } else {
+ this.container.css({
+ top: containerTop,
+ right: containerRight,
+ left: 'auto'
+ });
+ }
+ } else if (this.opens == 'center') {
+ var containerLeft = this.element.offset().left - parentOffset.left + this.element.outerWidth() / 2
+ - containerWidth / 2;
+ if (containerLeft < 0) {
+ this.container.css({
+ top: containerTop,
+ right: 'auto',
+ left: 9
+ });
+ } else if (containerLeft + containerWidth > $(window).width()) {
+ this.container.css({
+ top: containerTop,
+ left: 'auto',
+ right: 0
+ });
+ } else {
+ this.container.css({
+ top: containerTop,
+ left: containerLeft,
+ right: 'auto'
+ });
+ }
+ } else {
+ var containerLeft = this.element.offset().left - parentOffset.left;
+ if (containerLeft + containerWidth > $(window).width()) {
+ this.container.css({
+ top: containerTop,
+ left: 'auto',
+ right: 0
+ });
+ } else {
+ this.container.css({
+ top: containerTop,
+ left: containerLeft,
+ right: 'auto'
+ });
+ }
+ }
+ },
+
+ show: function(e) {
+ if (this.isShowing) return;
+
+ // Create a click proxy that is private to this instance of datepicker, for unbinding
+ this._outsideClickProxy = $.proxy(function(e) { this.outsideClick(e); }, this);
+
+ // Bind global datepicker mousedown for hiding and
+ $(document)
+ .on('mousedown.daterangepicker', this._outsideClickProxy)
+ // also support mobile devices
+ .on('touchend.daterangepicker', this._outsideClickProxy)
+ // also explicitly play nice with Bootstrap dropdowns, which stopPropagation when clicking them
+ .on('click.daterangepicker', '[data-toggle=dropdown]', this._outsideClickProxy)
+ // and also close when focus changes to outside the picker (eg. tabbing between controls)
+ .on('focusin.daterangepicker', this._outsideClickProxy);
+
+ // Reposition the picker if the window is resized while it's open
+ $(window).on('resize.daterangepicker', $.proxy(function(e) { this.move(e); }, this));
+
+ this.oldStartDate = this.startDate.clone();
+ this.oldEndDate = this.endDate.clone();
+ this.previousRightTime = this.endDate.clone();
+
+ this.updateView();
+ this.container.show();
+ this.move();
+ this.element.trigger('show.daterangepicker', this);
+ this.isShowing = true;
+ },
+
+ hide: function(e) {
+ if (!this.isShowing) return;
+
+ //incomplete date selection, revert to last values
+ if (!this.endDate) {
+ this.startDate = this.oldStartDate.clone();
+ this.endDate = this.oldEndDate.clone();
+ }
+
+ //if a new date range was selected, invoke the user callback function
+ if (!this.startDate.isSame(this.oldStartDate) || !this.endDate.isSame(this.oldEndDate))
+ this.callback(this.startDate.clone(), this.endDate.clone(), this.chosenLabel);
+
+ //if picker is attached to a text input, update it
+ this.updateElement();
+
+ $(document).off('.daterangepicker');
+ $(window).off('.daterangepicker');
+ this.container.hide();
+ this.element.trigger('hide.daterangepicker', this);
+ this.isShowing = false;
+ },
+
+ toggle: function(e) {
+ if (this.isShowing) {
+ this.hide();
+ } else {
+ this.show();
+ }
+ },
+
+ outsideClick: function(e) {
+ var target = $(e.target);
+ // if the page is clicked anywhere except within the daterangerpicker/button
+ // itself then call this.hide()
+ if (
+ // ie modal dialog fix
+ e.type == "focusin" ||
+ target.closest(this.element).length ||
+ target.closest(this.container).length ||
+ target.closest('.calendar-table').length
+ ) return;
+ this.hide();
+ this.element.trigger('outsideClick.daterangepicker', this);
+ },
+
+ showCalendars: function() {
+ this.container.addClass('show-calendar');
+ this.move();
+ this.element.trigger('showCalendar.daterangepicker', this);
+ },
+
+ hideCalendars: function() {
+ this.container.removeClass('show-calendar');
+ this.element.trigger('hideCalendar.daterangepicker', this);
+ },
+
+ clickRange: function(e) {
+ var label = e.target.getAttribute('data-range-key');
+ this.chosenLabel = label;
+ if (label == this.locale.customRangeLabel) {
+ this.showCalendars();
+ } else {
+ var dates = this.ranges[label];
+ this.startDate = dates[0];
+ this.endDate = dates[1];
+
+ if (!this.timePicker) {
+ this.startDate.startOf('day');
+ this.endDate.endOf('day');
+ }
+
+ if (!this.alwaysShowCalendars)
+ this.hideCalendars();
+ this.clickApply();
+ }
+ },
+
+ clickPrev: function(e) {
+ var cal = $(e.target).parents('.drp-calendar');
+ if (cal.hasClass('left')) {
+ this.leftCalendar.month.subtract(1, 'month');
+ if (this.linkedCalendars)
+ this.rightCalendar.month.subtract(1, 'month');
+ } else {
+ this.rightCalendar.month.subtract(1, 'month');
+ }
+ this.updateCalendars();
+ },
+
+ clickNext: function(e) {
+ var cal = $(e.target).parents('.drp-calendar');
+ if (cal.hasClass('left')) {
+ this.leftCalendar.month.add(1, 'month');
+ } else {
+ this.rightCalendar.month.add(1, 'month');
+ if (this.linkedCalendars)
+ this.leftCalendar.month.add(1, 'month');
+ }
+ this.updateCalendars();
+ },
+
+ hoverDate: function(e) {
+
+ //ignore dates that can't be selected
+ if (!$(e.target).hasClass('available')) return;
+
+ var title = $(e.target).attr('data-title');
+ var row = title.substr(1, 1);
+ var col = title.substr(3, 1);
+ var cal = $(e.target).parents('.drp-calendar');
+ var date = cal.hasClass('left') ? this.leftCalendar.calendar[row][col] : this.rightCalendar.calendar[row][col];
+
+ //highlight the dates between the start date and the date being hovered as a potential end date
+ var leftCalendar = this.leftCalendar;
+ var rightCalendar = this.rightCalendar;
+ var startDate = this.startDate;
+ if (!this.endDate) {
+ this.container.find('.drp-calendar tbody td').each(function(index, el) {
+
+ //skip week numbers, only look at dates
+ if ($(el).hasClass('week')) return;
+
+ var title = $(el).attr('data-title');
+ var row = title.substr(1, 1);
+ var col = title.substr(3, 1);
+ var cal = $(el).parents('.drp-calendar');
+ var dt = cal.hasClass('left') ? leftCalendar.calendar[row][col] : rightCalendar.calendar[row][col];
+
+ if ((dt.isAfter(startDate) && dt.isBefore(date)) || dt.isSame(date, 'day')) {
+ $(el).addClass('in-range');
+ } else {
+ $(el).removeClass('in-range');
+ }
+
+ });
+ }
+
+ },
+
+ clickDate: function(e) {
+
+ if (!$(e.target).hasClass('available')) return;
+
+ var title = $(e.target).attr('data-title');
+ var row = title.substr(1, 1);
+ var col = title.substr(3, 1);
+ var cal = $(e.target).parents('.drp-calendar');
+ var date = cal.hasClass('left') ? this.leftCalendar.calendar[row][col] : this.rightCalendar.calendar[row][col];
+
+ //
+ // this function needs to do a few things:
+ // * alternate between selecting a start and end date for the range,
+ // * if the time picker is enabled, apply the hour/minute/second from the select boxes to the clicked date
+ // * if autoapply is enabled, and an end date was chosen, apply the selection
+ // * if single date picker mode, and time picker isn't enabled, apply the selection immediately
+ // * if one of the inputs above the calendars was focused, cancel that manual input
+ //
+
+ if (this.endDate || date.isBefore(this.startDate, 'day')) { //picking start
+ if (this.timePicker) {
+ var hour = parseInt(this.container.find('.left .hourselect').val(), 10);
+ if (!this.timePicker24Hour) {
+ var ampm = this.container.find('.left .ampmselect').val();
+ if (ampm === 'PM' && hour < 12)
+ hour += 12;
+ if (ampm === 'AM' && hour === 12)
+ hour = 0;
+ }
+ var minute = parseInt(this.container.find('.left .minuteselect').val(), 10);
+ if (isNaN(minute)) {
+ minute = parseInt(this.container.find('.left .minuteselect option:last').val(), 10);
+ }
+ var second = this.timePickerSeconds ? parseInt(this.container.find('.left .secondselect').val(), 10) : 0;
+ date = date.clone().hour(hour).minute(minute).second(second);
+ }
+ this.endDate = null;
+ this.setStartDate(date.clone());
+ } else if (!this.endDate && date.isBefore(this.startDate)) {
+ //special case: clicking the same date for start/end,
+ //but the time of the end date is before the start date
+ this.setEndDate(this.startDate.clone());
+ } else { // picking end
+ if (this.timePicker) {
+ var hour = parseInt(this.container.find('.right .hourselect').val(), 10);
+ if (!this.timePicker24Hour) {
+ var ampm = this.container.find('.right .ampmselect').val();
+ if (ampm === 'PM' && hour < 12)
+ hour += 12;
+ if (ampm === 'AM' && hour === 12)
+ hour = 0;
+ }
+ var minute = parseInt(this.container.find('.right .minuteselect').val(), 10);
+ if (isNaN(minute)) {
+ minute = parseInt(this.container.find('.right .minuteselect option:last').val(), 10);
+ }
+ var second = this.timePickerSeconds ? parseInt(this.container.find('.right .secondselect').val(), 10) : 0;
+ date = date.clone().hour(hour).minute(minute).second(second);
+ }
+ this.setEndDate(date.clone());
+ if (this.autoApply) {
+ this.calculateChosenLabel();
+ this.clickApply();
+ }
+ }
+
+ if (this.singleDatePicker) {
+ this.setEndDate(this.startDate);
+ if (!this.timePicker && this.autoApply)
+ this.clickApply();
+ }
+
+ this.updateView();
+
+ //This is to cancel the blur event handler if the mouse was in one of the inputs
+ e.stopPropagation();
+
+ },
+
+ calculateChosenLabel: function () {
+ var customRange = true;
+ var i = 0;
+ for (var range in this.ranges) {
+ if (this.timePicker) {
+ var format = this.timePickerSeconds ? "YYYY-MM-DD HH:mm:ss" : "YYYY-MM-DD HH:mm";
+ //ignore times when comparing dates if time picker seconds is not enabled
+ if (this.startDate.format(format) == this.ranges[range][0].format(format) && this.endDate.format(format) == this.ranges[range][1].format(format)) {
+ customRange = false;
+ this.chosenLabel = this.container.find('.ranges li:eq(' + i + ')').addClass('active').attr('data-range-key');
+ break;
+ }
+ } else {
+ //ignore times when comparing dates if time picker is not enabled
+ if (this.startDate.format('YYYY-MM-DD') == this.ranges[range][0].format('YYYY-MM-DD') && this.endDate.format('YYYY-MM-DD') == this.ranges[range][1].format('YYYY-MM-DD')) {
+ customRange = false;
+ this.chosenLabel = this.container.find('.ranges li:eq(' + i + ')').addClass('active').attr('data-range-key');
+ break;
+ }
+ }
+ i++;
+ }
+ if (customRange) {
+ if (this.showCustomRangeLabel) {
+ this.chosenLabel = this.container.find('.ranges li:last').addClass('active').attr('data-range-key');
+ } else {
+ this.chosenLabel = null;
+ }
+ this.showCalendars();
+ }
+ },
+
+ clickApply: function(e) {
+ this.hide();
+ this.element.trigger('apply.daterangepicker', this);
+ },
+
+ clickCancel: function(e) {
+ this.startDate = this.oldStartDate;
+ this.endDate = this.oldEndDate;
+ this.hide();
+ this.element.trigger('cancel.daterangepicker', this);
+ },
+
+ monthOrYearChanged: function(e) {
+ var isLeft = $(e.target).closest('.drp-calendar').hasClass('left'),
+ leftOrRight = isLeft ? 'left' : 'right',
+ cal = this.container.find('.drp-calendar.'+leftOrRight);
+
+ // Month must be Number for new moment versions
+ var month = parseInt(cal.find('.monthselect').val(), 10);
+ var year = cal.find('.yearselect').val();
+
+ if (!isLeft) {
+ if (year < this.startDate.year() || (year == this.startDate.year() && month < this.startDate.month())) {
+ month = this.startDate.month();
+ year = this.startDate.year();
+ }
+ }
+
+ if (this.minDate) {
+ if (year < this.minDate.year() || (year == this.minDate.year() && month < this.minDate.month())) {
+ month = this.minDate.month();
+ year = this.minDate.year();
+ }
+ }
+
+ if (this.maxDate) {
+ if (year > this.maxDate.year() || (year == this.maxDate.year() && month > this.maxDate.month())) {
+ month = this.maxDate.month();
+ year = this.maxDate.year();
+ }
+ }
+
+ if (isLeft) {
+ this.leftCalendar.month.month(month).year(year);
+ if (this.linkedCalendars)
+ this.rightCalendar.month = this.leftCalendar.month.clone().add(1, 'month');
+ } else {
+ this.rightCalendar.month.month(month).year(year);
+ if (this.linkedCalendars)
+ this.leftCalendar.month = this.rightCalendar.month.clone().subtract(1, 'month');
+ }
+ this.updateCalendars();
+ },
+
+ timeChanged: function(e) {
+
+ var cal = $(e.target).closest('.drp-calendar'),
+ isLeft = cal.hasClass('left');
+
+ var hour = parseInt(cal.find('.hourselect').val(), 10);
+ var minute = parseInt(cal.find('.minuteselect').val(), 10);
+ if (isNaN(minute)) {
+ minute = parseInt(cal.find('.minuteselect option:last').val(), 10);
+ }
+ var second = this.timePickerSeconds ? parseInt(cal.find('.secondselect').val(), 10) : 0;
+
+ if (!this.timePicker24Hour) {
+ var ampm = cal.find('.ampmselect').val();
+ if (ampm === 'PM' && hour < 12)
+ hour += 12;
+ if (ampm === 'AM' && hour === 12)
+ hour = 0;
+ }
+
+ if (isLeft) {
+ var start = this.startDate.clone();
+ start.hour(hour);
+ start.minute(minute);
+ start.second(second);
+ this.setStartDate(start);
+ if (this.singleDatePicker) {
+ this.endDate = this.startDate.clone();
+ } else if (this.endDate && this.endDate.format('YYYY-MM-DD') == start.format('YYYY-MM-DD') && this.endDate.isBefore(start)) {
+ this.setEndDate(start.clone());
+ }
+ } else if (this.endDate) {
+ var end = this.endDate.clone();
+ end.hour(hour);
+ end.minute(minute);
+ end.second(second);
+ this.setEndDate(end);
+ }
+
+ //update the calendars so all clickable dates reflect the new time component
+ this.updateCalendars();
+
+ //update the form inputs above the calendars with the new time
+ this.updateFormInputs();
+
+ //re-render the time pickers because changing one selection can affect what's enabled in another
+ this.renderTimePicker('left');
+ this.renderTimePicker('right');
+
+ },
+
+ elementChanged: function() {
+ if (!this.element.is('input')) return;
+ if (!this.element.val().length) return;
+
+ var dateString = this.element.val().split(this.locale.separator),
+ start = null,
+ end = null;
+
+ if (dateString.length === 2) {
+ start = moment(dateString[0], this.locale.format);
+ end = moment(dateString[1], this.locale.format);
+ }
+
+ if (this.singleDatePicker || start === null || end === null) {
+ start = moment(this.element.val(), this.locale.format);
+ end = start;
+ }
+
+ if (!start.isValid() || !end.isValid()) return;
+
+ this.setStartDate(start);
+ this.setEndDate(end);
+ this.updateView();
+ },
+
+ keydown: function(e) {
+ //hide on tab or enter
+ if ((e.keyCode === 9) || (e.keyCode === 13)) {
+ this.hide();
+ }
+
+ //hide on esc and prevent propagation
+ if (e.keyCode === 27) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.hide();
+ }
+ },
+
+ updateElement: function() {
+ if (this.element.is('input') && this.autoUpdateInput) {
+ var newValue = this.startDate.format(this.locale.format);
+ if (!this.singleDatePicker) {
+ newValue += this.locale.separator + this.endDate.format(this.locale.format);
+ }
+ if (newValue !== this.element.val()) {
+ this.element.val(newValue).trigger('change');
+ }
+ }
+ },
+
+ remove: function() {
+ this.container.remove();
+ this.element.off('.daterangepicker');
+ this.element.removeData();
+ }
+
+ };
+
+ $.fn.daterangepicker = function(options, callback) {
+ var implementOptions = $.extend(true, {}, $.fn.daterangepicker.defaultOptions, options);
+ this.each(function() {
+ var el = $(this);
+ if (el.data('daterangepicker'))
+ el.data('daterangepicker').remove();
+ el.data('daterangepicker', new DateRangePicker(el, implementOptions, callback));
+ });
+ return this;
+ };
+
+ return DateRangePicker;
+
+}));
diff --git a/ckanext/bulk/assets/vendor/htmx.js b/ckanext/bulk/assets/vendor/htmx.js
new file mode 100644
index 0000000..de5f0f1
--- /dev/null
+++ b/ckanext/bulk/assets/vendor/htmx.js
@@ -0,0 +1 @@
+(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){var r=dr(e,t||"post");return r.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.12"};var r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R};var w=["get","post","put","delete","patch"];var i=w.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");var S=e("head"),q=e("title"),H=e("svg",true);function e(e,t){return new RegExp("<"+e+"(\\s[^>]*>|>)([\\s\\S]*?)<\\/"+e+">",!!t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function L(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=L(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function s(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function N(e){return/"+n+"",0);var a=i.querySelector("template").content;if(Q.config.allowScriptTags){oe(a.querySelectorAll("script"),function(e){if(Q.config.inlineScriptNonce){e.nonce=Q.config.inlineScriptNonce}e.htmxExecuted=navigator.userAgent.indexOf("Firefox")===-1})}else{oe(a.querySelectorAll("script"),function(e){_(e)})}return a}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return s("
'},e.settings.render),n.addEventListener("scroll",(()=>{e.settings.shouldLoadMore.call(e)&&c(e.lastValue)&&(l||(l=!0,e.load.call(e,e.lastValue)))}))}))})),de}))
+var tomSelect=function(e,t){return new TomSelect(e,t)}
+//# sourceMappingURL=tom-select.complete.min.js.map
diff --git a/ckanext/bulk/assets/vendor/tom-select.css b/ckanext/bulk/assets/vendor/tom-select.css
new file mode 100644
index 0000000..da6dd36
--- /dev/null
+++ b/ckanext/bulk/assets/vendor/tom-select.css
@@ -0,0 +1,412 @@
+/**
+ * tom-select.css (v2.3.0)
+ * Copyright (c) contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
+ * file except in compliance with the License. You may obtain a copy of the License at:
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
+ * ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ *
+ */
+.ts-control {
+ border: 1px solid #d0d0d0;
+ padding: 8px 8px;
+ width: 100%;
+ overflow: hidden;
+ position: relative;
+ z-index: 1;
+ box-sizing: border-box;
+ box-shadow: none;
+ border-radius: 3px;
+ display: flex;
+ flex-wrap: wrap;
+}
+.ts-wrapper.multi.has-items .ts-control {
+ padding: calc(8px - 2px - 0) 8px calc(8px - 2px - 3px - 0);
+}
+.full .ts-control {
+ background-color: #fff;
+}
+.disabled .ts-control, .disabled .ts-control * {
+ cursor: default !important;
+}
+.focus .ts-control {
+ box-shadow: none;
+}
+.ts-control > * {
+ vertical-align: baseline;
+ display: inline-block;
+}
+.ts-wrapper.multi .ts-control > div {
+ cursor: pointer;
+ margin: 0 3px 3px 0;
+ padding: 2px 6px;
+ background: #f2f2f2;
+ color: #303030;
+ border: 0 solid #d0d0d0;
+}
+.ts-wrapper.multi .ts-control > div.active {
+ background: #e8e8e8;
+ color: #303030;
+ border: 0 solid #cacaca;
+}
+.ts-wrapper.multi.disabled .ts-control > div, .ts-wrapper.multi.disabled .ts-control > div.active {
+ color: #7d7d7d;
+ background: white;
+ border: 0 solid white;
+}
+.ts-control > input {
+ flex: 1 1 auto;
+ min-width: 7rem;
+ display: inline-block !important;
+ padding: 0 !important;
+ min-height: 0 !important;
+ max-height: none !important;
+ max-width: 100% !important;
+ margin: 0 !important;
+ text-indent: 0 !important;
+ border: 0 none !important;
+ background: none !important;
+ line-height: inherit !important;
+ -webkit-user-select: auto !important;
+ -moz-user-select: auto !important;
+ -ms-user-select: auto !important;
+ user-select: auto !important;
+ box-shadow: none !important;
+}
+.ts-control > input::-ms-clear {
+ display: none;
+}
+.ts-control > input:focus {
+ outline: none !important;
+}
+.has-items .ts-control > input {
+ margin: 0 4px !important;
+}
+.ts-control.rtl {
+ text-align: right;
+}
+.ts-control.rtl.single .ts-control:after {
+ left: 15px;
+ right: auto;
+}
+.ts-control.rtl .ts-control > input {
+ margin: 0 4px 0 -2px !important;
+}
+.disabled .ts-control {
+ opacity: 0.5;
+ background-color: #fafafa;
+}
+.input-hidden .ts-control > input {
+ opacity: 0;
+ position: absolute;
+ left: -10000px;
+}
+
+.ts-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ width: 100%;
+ z-index: 10;
+ border: 1px solid #d0d0d0;
+ background: #fff;
+ margin: 0.25rem 0 0;
+ border-top: 0 none;
+ box-sizing: border-box;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ border-radius: 0 0 3px 3px;
+}
+.ts-dropdown [data-selectable] {
+ cursor: pointer;
+ overflow: hidden;
+}
+.ts-dropdown [data-selectable] .highlight {
+ background: rgba(125, 168, 208, 0.2);
+ border-radius: 1px;
+}
+.ts-dropdown .option,
+.ts-dropdown .optgroup-header,
+.ts-dropdown .no-results,
+.ts-dropdown .create {
+ padding: 5px 8px;
+}
+.ts-dropdown .option, .ts-dropdown [data-disabled], .ts-dropdown [data-disabled] [data-selectable].option {
+ cursor: inherit;
+ opacity: 0.5;
+}
+.ts-dropdown [data-selectable].option {
+ opacity: 1;
+ cursor: pointer;
+}
+.ts-dropdown .optgroup:first-child .optgroup-header {
+ border-top: 0 none;
+}
+.ts-dropdown .optgroup-header {
+ color: #303030;
+ background: #fff;
+ cursor: default;
+}
+.ts-dropdown .active {
+ background-color: #f5fafd;
+ color: #495c68;
+}
+.ts-dropdown .active.create {
+ color: #495c68;
+}
+.ts-dropdown .create {
+ color: rgba(48, 48, 48, 0.5);
+}
+.ts-dropdown .spinner {
+ display: inline-block;
+ width: 30px;
+ height: 30px;
+ margin: 5px 8px;
+}
+.ts-dropdown .spinner::after {
+ content: " ";
+ display: block;
+ width: 24px;
+ height: 24px;
+ margin: 3px;
+ border-radius: 50%;
+ border: 5px solid #d0d0d0;
+ border-color: #d0d0d0 transparent #d0d0d0 transparent;
+ animation: lds-dual-ring 1.2s linear infinite;
+}
+@keyframes lds-dual-ring {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+.ts-dropdown-content {
+ overflow: hidden auto;
+ max-height: 200px;
+ scroll-behavior: smooth;
+}
+
+.ts-wrapper.plugin-drag_drop .ts-dragging {
+ color: transparent !important;
+}
+.ts-wrapper.plugin-drag_drop .ts-dragging > * {
+ visibility: hidden !important;
+}
+
+.plugin-checkbox_options:not(.rtl) .option input {
+ margin-right: 0.5rem;
+}
+
+.plugin-checkbox_options.rtl .option input {
+ margin-left: 0.5rem;
+}
+
+/* stylelint-disable function-name-case */
+.plugin-clear_button {
+ --ts-pr-clear-button: 1em;
+}
+.plugin-clear_button .clear-button {
+ opacity: 0;
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ right: calc(8px - 6px);
+ margin-right: 0 !important;
+ background: transparent !important;
+ transition: opacity 0.5s;
+ cursor: pointer;
+}
+.plugin-clear_button.form-select .clear-button, .plugin-clear_button.single .clear-button {
+ right: max(var(--ts-pr-caret), 8px);
+}
+.plugin-clear_button.focus.has-items .clear-button, .plugin-clear_button:not(.disabled):hover.has-items .clear-button {
+ opacity: 1;
+}
+
+.ts-wrapper .dropdown-header {
+ position: relative;
+ padding: 10px 8px;
+ border-bottom: 1px solid #d0d0d0;
+ background: color-mix(#fff, #d0d0d0, 85%);
+ border-radius: 3px 3px 0 0;
+}
+.ts-wrapper .dropdown-header-close {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ color: #303030;
+ opacity: 0.4;
+ margin-top: -12px;
+ line-height: 20px;
+ font-size: 20px !important;
+}
+.ts-wrapper .dropdown-header-close:hover {
+ color: black;
+}
+
+.plugin-dropdown_input.focus.dropdown-active .ts-control {
+ box-shadow: none;
+ border: 1px solid #d0d0d0;
+}
+.plugin-dropdown_input .dropdown-input {
+ border: 1px solid #d0d0d0;
+ border-width: 0 0 1px;
+ display: block;
+ padding: 8px 8px;
+ box-shadow: none;
+ width: 100%;
+ background: transparent;
+}
+.plugin-dropdown_input .items-placeholder {
+ border: 0 none !important;
+ box-shadow: none !important;
+ width: 100%;
+}
+.plugin-dropdown_input.has-items .items-placeholder, .plugin-dropdown_input.dropdown-active .items-placeholder {
+ display: none !important;
+}
+
+.ts-wrapper.plugin-input_autogrow.has-items .ts-control > input {
+ min-width: 0;
+}
+.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input {
+ flex: none;
+ min-width: 4px;
+}
+.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input::-ms-input-placeholder {
+ color: transparent;
+}
+.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input::placeholder {
+ color: transparent;
+}
+
+.ts-dropdown.plugin-optgroup_columns .ts-dropdown-content {
+ display: flex;
+}
+.ts-dropdown.plugin-optgroup_columns .optgroup {
+ border-right: 1px solid #f2f2f2;
+ border-top: 0 none;
+ flex-grow: 1;
+ flex-basis: 0;
+ min-width: 0;
+}
+.ts-dropdown.plugin-optgroup_columns .optgroup:last-child {
+ border-right: 0 none;
+}
+.ts-dropdown.plugin-optgroup_columns .optgroup::before {
+ display: none;
+}
+.ts-dropdown.plugin-optgroup_columns .optgroup-header {
+ border-top: 0 none;
+}
+
+.ts-wrapper.plugin-remove_button .item {
+ display: inline-flex;
+ align-items: center;
+}
+.ts-wrapper.plugin-remove_button .item .remove {
+ color: inherit;
+ text-decoration: none;
+ vertical-align: middle;
+ display: inline-block;
+ padding: 0 6px;
+ border-radius: 0 2px 2px 0;
+ box-sizing: border-box;
+}
+.ts-wrapper.plugin-remove_button .item .remove:hover {
+ background: rgba(0, 0, 0, 0.05);
+}
+.ts-wrapper.plugin-remove_button.disabled .item .remove:hover {
+ background: none;
+}
+.ts-wrapper.plugin-remove_button .remove-single {
+ position: absolute;
+ right: 0;
+ top: 0;
+ font-size: 23px;
+}
+
+.ts-wrapper.plugin-remove_button:not(.rtl) .item {
+ padding-right: 0 !important;
+}
+.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
+ border-left: 1px solid #d0d0d0;
+ margin-left: 6px;
+}
+.ts-wrapper.plugin-remove_button:not(.rtl) .item.active .remove {
+ border-left-color: #cacaca;
+}
+.ts-wrapper.plugin-remove_button:not(.rtl).disabled .item .remove {
+ border-left-color: white;
+}
+
+.ts-wrapper.plugin-remove_button.rtl .item {
+ padding-left: 0 !important;
+}
+.ts-wrapper.plugin-remove_button.rtl .item .remove {
+ border-right: 1px solid #d0d0d0;
+ margin-right: 6px;
+}
+.ts-wrapper.plugin-remove_button.rtl .item.active .remove {
+ border-right-color: #cacaca;
+}
+.ts-wrapper.plugin-remove_button.rtl.disabled .item .remove {
+ border-right-color: white;
+}
+
+:root {
+ --ts-pr-clear-button: 0;
+ --ts-pr-caret: 0;
+ --ts-pr-min: .75rem;
+}
+
+.ts-wrapper.single .ts-control, .ts-wrapper.single .ts-control input {
+ cursor: pointer;
+}
+
+.ts-control:not(.rtl) {
+ padding-right: max(var(--ts-pr-min), var(--ts-pr-clear-button) + var(--ts-pr-caret)) !important;
+}
+
+.ts-control.rtl {
+ padding-left: max(var(--ts-pr-min), var(--ts-pr-clear-button) + var(--ts-pr-caret)) !important;
+}
+
+.ts-wrapper {
+ position: relative;
+}
+
+.ts-dropdown,
+.ts-control,
+.ts-control input {
+ color: #303030;
+ font-family: inherit;
+ font-size: 13px;
+ line-height: 18px;
+}
+
+.ts-control,
+.ts-wrapper.single.input-active .ts-control {
+ background: #fff;
+ cursor: text;
+}
+
+.ts-hidden-accessible {
+ border: 0 !important;
+ clip: rect(0 0 0 0) !important;
+ -webkit-clip-path: inset(50%) !important;
+ clip-path: inset(50%) !important;
+ overflow: hidden !important;
+ padding: 0 !important;
+ position: absolute !important;
+ width: 1px !important;
+ white-space: nowrap !important;
+}
+/*# sourceMappingURL=tom-select.css.map */
\ No newline at end of file
diff --git a/ckanext/bulk/assets/webassets.yml b/ckanext/bulk/assets/webassets.yml
new file mode 100644
index 0000000..5a58e26
--- /dev/null
+++ b/ckanext/bulk/assets/webassets.yml
@@ -0,0 +1,51 @@
+bulk-js:
+ filter: rjsmin
+ output: ckanext-bulk/%(version)s-bulk.js
+ contents:
+ # - vendor/tom-select.base.min.js # slim version of tom-select
+ - vendor/tom-select.complete.min.js # tom-select with popular plugins
+
+ - vendor/Sortable.js
+ - vendor/sweetalert2.all.js
+ - vendor/hyperscript.js
+ - vendor/htmx.js
+ - vendor/iziModal.js
+ - vendor/iziToast.js
+ - vendor/slick.js
+ - vendor/daterangepicker.js
+ - vendor/moment.min.js
+ - vendor/overlayscrollbars.js
+
+ - scripts/bulk.js # global plugin scripts
+
+ - scripts/bulk-tom-select.js # tom-select adapter
+ - scripts/bulk-swal.js # sweetalert adapter
+ - scripts/bulk-sortable.js # sortablejs adapter
+ - scripts/bulk-izi-modal.js # iziModal adapter
+ - scripts/bulk-izi-toast.js # iziToast adapter
+ - scripts/bulk-datepicker.js # daterangepicker adapter
+ - scripts/bulk-scrollbar.js # overlay scrollbars adapter
+
+ extra:
+ preload:
+ - base/main
+
+bulk-css:
+ filter: cssrewrite
+ output: ckanext-bulk/%(version)s-bulk.css
+ contents:
+
+ ## source for these styles are included into _vendor.scss
+ # - vendor/tom-select.css
+
+ ## bootstrap styles cannot be included into SCSS, so you can use the static
+ ## version below.
+ # - vendor/tom-select.bootstrap5.css
+
+ - vendor/iziModal.css
+ - vendor/iziToast.css
+ - vendor/daterangepicker.css
+ - vendor/overlayscrollbars.css
+
+ # the main plugin theme
+ - styles/bulk.css
diff --git a/ckanext/bulk/cli.py b/ckanext/bulk/cli.py
new file mode 100644
index 0000000..47aaf7e
--- /dev/null
+++ b/ckanext/bulk/cli.py
@@ -0,0 +1,72 @@
+"""CLI commands for ckanext-bulk.
+
+Commands added to `__all__` attribute are added to main CKAN CLI.
+
+Example:
+ ```sh
+ ckan bulk --help
+ ```
+"""
+
+from __future__ import annotations
+
+import click
+
+from ckan import model
+
+__all__ = ["bulk"]
+
+
+# Register CLI group. Code of the group is executed whenever subcommand of this
+# group is invoked. Usually, group body remains empty. But if all subcommands
+# contain identical initialization process, consider describing it inside
+# group.
+@click.group(short_help="bulk CLI.")
+@click.pass_context
+def bulk(ctx: click.Context):
+ """CLI commands of bulk plugin."""
+ ctx.meta["bulk"] = "bulk"
+
+
+# Command decorated with `bulk.command()` decorator is
+# registered inside the group. Name of the command matches name of the function
+# with underscores replaced by hyphens. You can pass string into `.command()`
+# to use a different name of the command.
+#
+# `click.argument` defines positional argument of the command. `click.option`
+# defines optional flag with the specified short and long names.
+#
+# `click.pass_context` passes shared contex object to the command. Example
+# below uses it to read data set by initialization code from group body.
+@bulk.command()
+@click.argument("name", default="bulk")
+@click.option("-v", "--verbose", count=True, help="Increase verbosity")
+@click.pass_context
+def command(ctx: click.Context, name: str, verbose: int):
+ """Nunc porta vulputate tellus."""
+ msg = f"Hello, {name or ctx.meta['bulk']}"
+ if verbose:
+ msg += "!" * verbose
+
+ click.echo(msg)
+
+
+# Command can execute arbitrary code. If you are writing command that processes
+# a lot of records, wrap iteration over records into `click.progressbar`. It
+# results in progressbar that shows estimated time required to complete the command.
+#
+# When printing something, always use `click.echo` for plain output and
+# `click.secho` for styled(colored) output. Never use built-in `print`
+# function. It's allowed to use logging, but most likely, you don't need and
+# `click.echo` is much better choice.
+@bulk.command()
+def count_users():
+ """Iterate over users and count something."""
+ q = model.Session.query(model.User)
+ total = 0
+
+ with click.progressbar(q, q.count()) as bar:
+ for _user in bar:
+ total += 1
+
+ click.secho(f"Result: {click.style(total, bold=True)}!")
diff --git a/ckanext/bulk/config.py b/ckanext/bulk/config.py
new file mode 100644
index 0000000..f217c98
--- /dev/null
+++ b/ckanext/bulk/config.py
@@ -0,0 +1,18 @@
+"""Config getters of bulk plugin."""
+
+from __future__ import annotations
+
+import ckan.plugins.toolkit as tk
+
+OPTION = "ckanext.bulk.option.name"
+MULTI = "ckanext.bulk.multivalued.option"
+
+
+def option() -> int:
+ """Integer placerat tristique nisl."""
+ return tk.config[OPTION]
+
+
+def multivalued() -> list[str]:
+ """Another option that will be parsed as a list of words."""
+ return tk.config[MULTI]
diff --git a/ckanext/bulk/config_declaration.yaml b/ckanext/bulk/config_declaration.yaml
new file mode 100644
index 0000000..732f4f9
--- /dev/null
+++ b/ckanext/bulk/config_declaration.yaml
@@ -0,0 +1,16 @@
+version: 1
+groups:
+ - annotation: bulk configuration
+ options:
+
+ - key: ckanext.bulk.option.name
+ default: 10
+ type: int
+ description: |
+ Integer placerat tristique nisl.
+
+ - key: ckanext.bulk.multivalued.option
+ type: list
+ editable: true
+ description: |
+ Another option that will be parsed as a list of words.
diff --git a/ckanext/bulk/helpers.py b/ckanext/bulk/helpers.py
new file mode 100644
index 0000000..acb48eb
--- /dev/null
+++ b/ckanext/bulk/helpers.py
@@ -0,0 +1,15 @@
+"""Template helpers of the bulk plugin.
+
+All non-private functions defined here are registered inside `tk.h` collection.
+"""
+
+from __future__ import annotations
+
+
+def bulk_hello() -> str:
+ """Greet the user.
+
+ Returns:
+ greeting with the plugin name.
+ """
+ return "Hello, bulk!"
diff --git a/ckanext/bulk/i18n/.gitignore b/ckanext/bulk/i18n/.gitignore
new file mode 100644
index 0000000..e69de29
diff --git a/ckanext/bulk/implementations/__init__.py b/ckanext/bulk/implementations/__init__.py
new file mode 100644
index 0000000..7d055a9
--- /dev/null
+++ b/ckanext/bulk/implementations/__init__.py
@@ -0,0 +1,5 @@
+from .package_controller import PackageController
+
+__all__ = [
+ "PackageController",
+]
diff --git a/ckanext/bulk/implementations/package_controller.py b/ckanext/bulk/implementations/package_controller.py
new file mode 100644
index 0000000..d6dae25
--- /dev/null
+++ b/ckanext/bulk/implementations/package_controller.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+from typing import Any
+
+import ckan.plugins as p
+from ckan import types
+
+
+class PackageController(p.SingletonPlugin):
+ """Customize dataset lifecycle."""
+
+ p.implements(p.IPackageController, inherit=True)
+
+ def after_dataset_show(
+ self,
+ context: types.Context,
+ pkg_dict: dict[str, Any],
+ ) -> None:
+ """Add fake data."""
+ pkg_dict["fake"] = 42
+
+ def before_dataset_search(self, search_params: dict[str, Any]) -> dict[str, Any]:
+ """Improve search filters."""
+ return search_params
diff --git a/ckanext/bulk/logic/__init__.py b/ckanext/bulk/logic/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/ckanext/bulk/logic/action.py b/ckanext/bulk/logic/action.py
new file mode 100644
index 0000000..e41a44b
--- /dev/null
+++ b/ckanext/bulk/logic/action.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+from typing import Any
+
+import ckan.plugins.toolkit as tk
+from ckan import model
+from ckan.logic import validate
+from ckan.types import Context
+
+from ckanext.bulk.model import Something
+
+from . import schema
+
+
+@tk.side_effect_free
+@validate(schema.get_sum)
+def bulk_get_sum(context: Context, data_dict: dict[str, Any]):
+ """Produce a sum of left and right.
+
+ Args:
+ left (int): firt argument
+ right (int): second argument
+
+ Returns:
+ operation details
+ """
+ tk.check_access("bulk_get_sum", context, data_dict)
+
+ return {
+ "left": data_dict["left"],
+ "right": data_dict["right"],
+ "sum": data_dict["left"] + data_dict["right"],
+ }
+
+
+@validate(schema.something_create)
+def bulk_something_create(context: Context, data_dict: dict[str, Any]):
+ """Create something object.
+
+ Args:
+ hello (str): aliquam erat volutpat
+ world: (str): nullam tempus
+ plugin_data (dict[str, Any], optional): aliquam feugiat tellus ut neque
+
+ Returns:
+ details of the new something object
+ """
+ tk.check_access("bulk_something_create", context, data_dict)
+
+ smth = Something(
+ hello=data_dict["hello"],
+ world=data_dict["world"],
+ plugin_data=data_dict.get("plugin_data", {}),
+ )
+ model.Session.add(smth)
+ model.Session.commit()
+
+ return smth.dictize(context)
diff --git a/ckanext/bulk/logic/auth.py b/ckanext/bulk/logic/auth.py
new file mode 100644
index 0000000..b1782e0
--- /dev/null
+++ b/ckanext/bulk/logic/auth.py
@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+from typing import Any
+
+import ckan.plugins.toolkit as tk
+from ckan.types import Context
+
+
+@tk.auth_allow_anonymous_access
+def bulk_get_sum(context: Context, data_dict: dict[str, Any]):
+ """Any user can compute sum."""
+ return {"success": True}
+
+
+def bulk_something_create(context: Context, data_dict: dict[str, Any]):
+ """Authenticated user can create something."""
+ return {"success": True}
diff --git a/ckanext/bulk/logic/schema.py b/ckanext/bulk/logic/schema.py
new file mode 100644
index 0000000..5d38f34
--- /dev/null
+++ b/ckanext/bulk/logic/schema.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from ckan import types
+from ckan.logic.schema import validator_args
+
+
+@validator_args
+def get_sum(
+ convert_int: types.Validator,
+ not_empty: types.Validator,
+) -> types.Schema:
+ """Schema for bulk_get_sum action."""
+ return {
+ "left": [not_empty, convert_int],
+ "right": [not_empty, convert_int],
+ }
+
+
+@validator_args
+def something_create(
+ not_empty: types.Validator,
+ unicode_safe: types.Validator,
+ ignore_empty: types.Validator,
+ convert_to_json_if_string: types.Validator,
+ dict_only: types.Validator,
+) -> types.Schema:
+ """Schema for bulk_something_create action."""
+ return {
+ "hello": [not_empty, unicode_safe],
+ "world": [not_empty, unicode_safe],
+ "plugin_data": [ignore_empty, convert_to_json_if_string, dict_only],
+ }
diff --git a/ckanext/bulk/logic/validators.py b/ckanext/bulk/logic/validators.py
new file mode 100644
index 0000000..9dee55c
--- /dev/null
+++ b/ckanext/bulk/logic/validators.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from typing import Any
+
+import ckan.plugins.toolkit as tk
+from ckan import types
+
+
+def bulk_required(value: Any):
+ """Verify that value is not empty."""
+ if not value or value is tk.missing:
+ raise tk.Invalid("Required")
+
+ return value
+
+
+def bulk_complex_validator(
+ key: types.FlattenKey,
+ data: types.FlattenDataDict,
+ errors: types.FlattenErrorDict,
+ context: types.Context,
+):
+ """Verify that value is not empty."""
+ if not data[key]:
+ errors[key].append("Required")
+ raise tk.StopOnError
diff --git a/ckanext/bulk/migration/bulk/README b/ckanext/bulk/migration/bulk/README
new file mode 100644
index 0000000..2500aa1
--- /dev/null
+++ b/ckanext/bulk/migration/bulk/README
@@ -0,0 +1 @@
+Generic single-database configuration.
diff --git a/ckanext/bulk/migration/bulk/alembic.ini b/ckanext/bulk/migration/bulk/alembic.ini
new file mode 100644
index 0000000..7c3afe3
--- /dev/null
+++ b/ckanext/bulk/migration/bulk/alembic.ini
@@ -0,0 +1,74 @@
+# A generic, single database configuration.
+
+[alembic]
+# path to migration scripts
+script_location = %(here)s
+
+# template used to generate migration files
+# file_template = %%(rev)s_%%(slug)s
+
+# timezone to use when rendering the date
+# within the migration file as well as the filename.
+# string value is passed to dateutil.tz.gettz()
+# leave blank for localtime
+# timezone =
+
+# max length of characters to apply to the
+# "slug" field
+#truncate_slug_length = 40
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+# set to 'true' to allow .pyc and .pyo files without
+# a source .py file to be detected as revisions in the
+# versions/ directory
+# sourceless = false
+
+# version location specification; this defaults
+# to ckanext-bulk/ckanext/bulk/migration/bulk/versions. When using multiple version
+# directories, initial revisions must be specified with --version-path
+# version_locations = %(here)s/bar %(here)s/bat ckanext-bulk/ckanext/bulk/migration/bulk/versions
+
+# the output encoding used when revision files
+# are written from script.py.mako
+# output_encoding = utf-8
+
+sqlalchemy.url = driver://user:pass@localhost/dbname
+
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/ckanext/bulk/migration/bulk/env.py b/ckanext/bulk/migration/bulk/env.py
new file mode 100644
index 0000000..fbd1402
--- /dev/null
+++ b/ckanext/bulk/migration/bulk/env.py
@@ -0,0 +1,94 @@
+import os
+from typing import Any
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+
+from ckan.model.meta import metadata
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+# fileConfig(config.config_file_name)
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+target_metadata = metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+name = os.path.basename(os.path.dirname(__file__))
+
+
+def include_object(
+ object: Any,
+ object_name: Any,
+ type_: Any,
+ reflected: Any,
+ compare_to: Any,
+) -> bool:
+ if type_ == "table":
+ return object_name.startswith(name)
+ return True
+
+
+def run_migrations_offline():
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+ """
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ version_table=f"{name}_alembic_version",
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online():
+ """Run migrations in 'online' mode.
+
+ In this scenario we need to create an Engine
+ and associate a connection with the context.
+
+ """
+ connectable = engine_from_config(
+ config.get_section(config.config_ini_section),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+
+ with connectable.connect() as connection:
+ context.configure(
+ connection=connection,
+ target_metadata=target_metadata,
+ version_table=f"{name}_alembic_version",
+ include_object=include_object,
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/ckanext/bulk/migration/bulk/script.py.mako b/ckanext/bulk/migration/bulk/script.py.mako
new file mode 100644
index 0000000..2c01563
--- /dev/null
+++ b/ckanext/bulk/migration/bulk/script.py.mako
@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
diff --git a/ckanext/bulk/migration/bulk/versions/001_cc1a832108c5_create_something_table.py b/ckanext/bulk/migration/bulk/versions/001_cc1a832108c5_create_something_table.py
new file mode 100644
index 0000000..513cd31
--- /dev/null
+++ b/ckanext/bulk/migration/bulk/versions/001_cc1a832108c5_create_something_table.py
@@ -0,0 +1,31 @@
+"""Create something table.
+
+Revision ID: cc1a832108c5
+Revises:
+Create Date: 2024-07-22 15:44:28
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects.postgresql import JSONB
+
+# revision identifiers, used by Alembic.
+revision = "cc1a832108c5"
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ "bulk_something",
+ sa.Column("id", sa.UnicodeText, primary_key=True),
+ sa.Column("hello", sa.UnicodeText, nullable=False, server_default=""),
+ sa.Column("world", sa.UnicodeText, nullable=False),
+ sa.Column("plugin_data", JSONB, server_default="{}"),
+ )
+
+
+def downgrade():
+ op.drop_table("bulk_something")
diff --git a/ckanext/bulk/model/__init__.py b/ckanext/bulk/model/__init__.py
new file mode 100644
index 0000000..ee23557
--- /dev/null
+++ b/ckanext/bulk/model/__init__.py
@@ -0,0 +1,5 @@
+from .something import Something
+
+__all__ = [
+ "Something",
+]
diff --git a/ckanext/bulk/model/base.py b/ckanext/bulk/model/base.py
new file mode 100644
index 0000000..fe90884
--- /dev/null
+++ b/ckanext/bulk/model/base.py
@@ -0,0 +1,11 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+import ckan.plugins.toolkit as tk
+
+Base = tk.BaseModel
+
+
+def now():
+ return datetime.now(timezone.utc)
diff --git a/ckanext/bulk/model/something.py b/ckanext/bulk/model/something.py
new file mode 100644
index 0000000..805eb96
--- /dev/null
+++ b/ckanext/bulk/model/something.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+import copy
+from typing import Any
+
+import sqlalchemy as sa
+from sqlalchemy.dialects.postgresql import JSONB
+from sqlalchemy.orm import Mapped
+
+from ckan.lib.dictization import table_dictize
+from ckan.model.types import make_uuid
+
+from .base import Base
+
+
+class Something(Base): # type: ignore
+ """Model with details or something."""
+
+ # define columns as a `__table__` attribute. It simplifies typing and you
+ # can copy this definition almost unchanged into alembic migration.
+ __table__ = sa.Table(
+ "bulk_something",
+ Base.metadata,
+ sa.Column("id", sa.UnicodeText, primary_key=True, default=make_uuid),
+ sa.Column("hello", sa.Text, nullable=False, default=""),
+ sa.Column("world", sa.Text, nullable=False),
+ sa.Column("plugin_data", JSONB, default=dict, server_default="{}"),
+ )
+
+ # typed models. You'll use it - you'll love it.
+ id: Mapped[str]
+
+ hello: Mapped[str]
+ world: Mapped[str]
+
+ plugin_data: Mapped[dict[str, Any]]
+
+ def dictize(self, context: Any) -> dict[str, Any]:
+ result = table_dictize(self, context)
+
+ plugin_data = result.pop("plugin_data")
+ if context.get("include_plugin_data"):
+ result["plugin_data"] = copy.deepcopy(plugin_data)
+
+ return result
+
+ @classmethod
+ def by_hello(cls, hello: str, world: str | None = None) -> sa.sql.Select:
+ stmt = sa.select(cls).where(
+ cls.hello == hello,
+ )
+
+ if world:
+ stmt = stmt.where(cls.world == world)
+
+ return stmt
diff --git a/ckanext/bulk/plugin.py b/ckanext/bulk/plugin.py
new file mode 100644
index 0000000..9474898
--- /dev/null
+++ b/ckanext/bulk/plugin.py
@@ -0,0 +1,85 @@
+"""Definition of the main plugin.
+
+If you have multiple plugins, it's recommended to create `plugins` module and
+put every plugin into a separate submodule. Or even create a separate extension
+for every plugin, if they are not really related.
+
+It's possible to put multiple plugins into single module, but it may affect a
+bit order of plugin in CKAN v2.10 because of implementation details of plugins
+core. If order of plugins does not change anything, it's safe to define plugins
+in the same file.
+
+Blankets decorating the plugin are used to provide default implementation of
+corresponding interfaces. When blanket is applied, it reads content from the
+certain module and register it using CKAN Interface.
+
+Source module for blanket is defined by CKAN recommendations. `cli` blanket
+reads content from `cli.py`, `actions` blanket reads content from
+`logic/action.py`, etc.
+
+If you have a different project structure, you have two alternatives. Blankets
+accept either a collection of items for corresponding interface, or a function
+that can produce such collection.
+
+Example:
+ ```python
+ def get_actions():
+ return {"bulk_something": something}
+
+ helpers = {"bulk_hello": hello}
+
+ @tk.blanket.actions(get_actions)
+ @tk.blanket.helpers(helpers)
+ class BulkPlugin(...):
+ ...
+ ```
+"""
+
+from __future__ import annotations
+
+import ckan.plugins as p
+import ckan.plugins.toolkit as tk
+from ckan.common import CKANConfig
+
+from . import implementations
+
+
+@tk.blanket.actions
+@tk.blanket.auth_functions
+@tk.blanket.blueprints
+@tk.blanket.cli
+@tk.blanket.config_declarations
+@tk.blanket.helpers
+@tk.blanket.validators
+class BulkPlugin(
+ # implementations are extracted to separate modules to keep main plugin
+ # definition as lean as possible
+ implementations.PackageController,
+ # don't forget to extend SingletonPlugin. Due to internal
+ # implementation details, it must be extended directly by the plugin
+ p.SingletonPlugin,
+):
+ """Main entrypoint of the bulk plugin.
+
+ This plugin does nothing yet, but it will definitely grow into beautiful
+ and useful plugin one day.
+ """
+
+ # it's still possible to implement interfaces directly inside the main
+ # plugin. But do it only if implementation is really straightforward and
+ # compact
+ p.implements(p.IConfigurer)
+
+ # IConfigurer
+ def update_config(self, config_: CKANConfig):
+ """Modify CKAN configuration."""
+ # register templates of the plugin
+ tk.add_template_directory(config_, "templates")
+
+ # every file from the public directory can be accessed directly from
+ # the browser. Use this for public images, site logos, downloadable
+ # documents.
+ tk.add_public_directory(config_, "public")
+
+ # register assets folder. You must add `webassets.yml` into this folder
+ tk.add_resource("assets", "bulk")
diff --git a/ckanext/bulk/public/.gitignore b/ckanext/bulk/public/.gitignore
new file mode 100644
index 0000000..e69de29
diff --git a/ckanext/bulk/public/ajax-loader.gif b/ckanext/bulk/public/ajax-loader.gif
new file mode 100644
index 0000000..e0e6e97
Binary files /dev/null and b/ckanext/bulk/public/ajax-loader.gif differ
diff --git a/ckanext/bulk/public/slick-fonts/slick.eot b/ckanext/bulk/public/slick-fonts/slick.eot
new file mode 100644
index 0000000..2cbab9c
Binary files /dev/null and b/ckanext/bulk/public/slick-fonts/slick.eot differ
diff --git a/ckanext/bulk/public/slick-fonts/slick.svg b/ckanext/bulk/public/slick-fonts/slick.svg
new file mode 100644
index 0000000..b36a66a
--- /dev/null
+++ b/ckanext/bulk/public/slick-fonts/slick.svg
@@ -0,0 +1,14 @@
+
+
+
diff --git a/ckanext/bulk/public/slick-fonts/slick.ttf b/ckanext/bulk/public/slick-fonts/slick.ttf
new file mode 100644
index 0000000..9d03461
Binary files /dev/null and b/ckanext/bulk/public/slick-fonts/slick.ttf differ
diff --git a/ckanext/bulk/public/slick-fonts/slick.woff b/ckanext/bulk/public/slick-fonts/slick.woff
new file mode 100644
index 0000000..8ee9972
Binary files /dev/null and b/ckanext/bulk/public/slick-fonts/slick.woff differ
diff --git a/ckanext/bulk/schemas/dataset.yaml b/ckanext/bulk/schemas/dataset.yaml
new file mode 100644
index 0000000..4391431
--- /dev/null
+++ b/ckanext/bulk/schemas/dataset.yaml
@@ -0,0 +1,117 @@
+scheming_version: 2
+dataset_type: dataset
+about: A reimplementation of the default CKAN dataset schema
+about_url: http://github.com/ckan/ckanext-scheming
+
+dataset_fields:
+
+- field_name: title
+ label: Title
+ preset: title
+ form_placeholder: eg. A descriptive title
+
+- field_name: name
+ label: URL
+ preset: dataset_slug
+ form_placeholder: eg. my-dataset
+
+- field_name: gcs-poster
+ label: GCS Poster
+ display_snippet: files_object.html
+ form_snippet: files_file_selector.html
+ file_selector:
+ # upload_action: files_gcs_create
+ filters:
+ storage: gcs
+ validators: |
+ ignore_empty
+ files_accept_file_with_storage(gcs)
+
+
+- field_name: filebin-poster
+ label: Filebin Poster
+ display_snippet: files_object.html
+ form_snippet: files_file_selector.html
+ files_object_class: img-thumbnail
+ file_selector:
+ # upload_action: files_filebin_create
+ filters:
+ storage: filebin
+ validators: |
+ ignore_empty
+ files_accept_file_with_storage(filebin)
+ files_transfer_ownership("package")
+
+- field_name: notes
+ label: Description
+ form_snippet: markdown.html
+ form_placeholder: eg. Some useful notes about the data
+
+- field_name: tag_string
+ label: Tags
+ preset: tag_string_autocomplete
+ form_placeholder: eg. economy, mental health, government
+
+- field_name: license_id
+ label: License
+ form_snippet: license.html
+ help_text: License definitions and additional information can be found at http://opendefinition.org/
+
+- field_name: owner_org
+ label: Organization
+ preset: dataset_organization
+
+- field_name: url
+ label: Source
+ form_placeholder: http://example.com/dataset.json
+ display_property: foaf:homepage
+ display_snippet: link.html
+
+- field_name: version
+ label: Version
+ validators: ignore_missing unicode_safe package_version_validator
+ form_placeholder: '1.0'
+
+- field_name: author
+ label: Author
+ form_placeholder: Joe Bloggs
+ display_property: dc:creator
+
+- field_name: author_email
+ label: Author Email
+ form_placeholder: joe@example.com
+ display_property: dc:creator
+ display_snippet: email.html
+ display_email_name_field: author
+
+- field_name: maintainer
+ label: Maintainer
+ form_placeholder: Joe Bloggs
+ display_property: dc:contributor
+
+- field_name: maintainer_email
+ label: Maintainer Email
+ form_placeholder: joe@example.com
+ display_property: dc:contributor
+ display_snippet: email.html
+ display_email_name_field: maintainer
+
+
+resource_fields:
+
+- field_name: url
+ label: URL
+ preset: resource_url_upload
+
+- field_name: name
+ label: Name
+ form_placeholder: eg. January 2011 Gold Prices
+
+- field_name: description
+ label: Description
+ form_snippet: markdown.html
+ form_placeholder: Some useful notes about the data
+
+- field_name: format
+ label: Format
+ preset: resource_format_autocomplete
diff --git a/ckanext/bulk/schemas/group.yaml b/ckanext/bulk/schemas/group.yaml
new file mode 100644
index 0000000..a6e7399
--- /dev/null
+++ b/ckanext/bulk/schemas/group.yaml
@@ -0,0 +1,27 @@
+scheming_version: 2
+group_type: group
+about_url: http://github.com/ckan/ckanext-scheming
+fields:
+
+ - field_name: title
+ label: Name
+ validators: ignore_missing unicode_safe
+ form_snippet: large_text.html
+ form_attrs:
+ data-module: slug-preview-target
+ form_placeholder: My Organization
+
+ - field_name: name
+ label: URL
+ validators: not_empty unicode_safe name_validator group_name_validator
+ form_snippet: slug.html
+ form_placeholder: my-organization
+
+ - field_name: notes
+ label: Description
+ form_snippet: markdown.html
+ form_placeholder: A little information about my organization...
+
+ - field_name: url
+ label: Image URL
+ form_placeholder: http://example.com/my-image.jpg
diff --git a/ckanext/bulk/schemas/organization.yaml b/ckanext/bulk/schemas/organization.yaml
new file mode 100644
index 0000000..81e241d
--- /dev/null
+++ b/ckanext/bulk/schemas/organization.yaml
@@ -0,0 +1,27 @@
+scheming_version: 2
+organization_type: organization
+about_url: http://github.com/ckan/ckanext-scheming
+fields:
+
+ - field_name: title
+ label: Name
+ validators: ignore_missing unicode_safe
+ form_snippet: large_text.html
+ form_attrs:
+ data-module: slug-preview-target
+ form_placeholder: My Organization
+
+ - field_name: name
+ label: URL
+ validators: not_empty unicode_safe name_validator group_name_validator
+ form_snippet: slug.html
+ form_placeholder: my-organization
+
+ - field_name: notes
+ label: Description
+ form_snippet: markdown.html
+ form_placeholder: A little information about my organization...
+
+ - field_name: url
+ label: Image URL
+ form_placeholder: http://example.com/my-image.jpg
diff --git a/ckanext/bulk/schemas/presets.yaml b/ckanext/bulk/schemas/presets.yaml
new file mode 100644
index 0000000..9a8024d
--- /dev/null
+++ b/ckanext/bulk/schemas/presets.yaml
@@ -0,0 +1,11 @@
+scheming_presets_version: 2
+about: plugin-specific presets
+about_url: http://github.com/ckan/ckanext-scheming#preset
+presets:
+
+ - preset_name: bulk_custom_preset
+ values:
+ validators: if_empty_same_as(name) unicode_safe
+ form_snippet: large_text.html
+ form_attrs:
+ data-module: slug-preview-target
diff --git a/ckanext/bulk/templates/.gitignore b/ckanext/bulk/templates/.gitignore
new file mode 100644
index 0000000..e69de29
diff --git a/ckanext/bulk/templates/bulk/complex.html b/ckanext/bulk/templates/bulk/complex.html
new file mode 100644
index 0000000..b712d4e
--- /dev/null
+++ b/ckanext/bulk/templates/bulk/complex.html
@@ -0,0 +1,115 @@
+{% extends "page.html" %}
+
+{% block primary_content_inner %}
+
Hello, {{ word }}!
+
+ Nibh tellus molestie nunc, non blandit massa enim nec dui nunc mattis
+ enim ut tellus elementum sagittis vitae et leo duis ut diam quam
+ nulla. Urna neque viverra justo, nec?
+