mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-03-23 07:55:05 +00:00
Initial version.
Features: - Upload PDF files let them analyze - Manage meta data and items - See processing in webapp
This commit is contained in:
parent
6154e6a387
commit
831cd8b655
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,3 +1,5 @@
|
||||
target/
|
||||
dev.conf
|
||||
elm-stuff
|
||||
elm-stuff
|
||||
result
|
||||
_site/
|
674
LICENSE.txt
Normal file
674
LICENSE.txt
Normal file
@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
10
README.md
10
README.md
@ -1 +1,11 @@
|
||||
<img align="right" src="./artwork/logo-only.svg" height="150px" style="padding-left: 20px"/>
|
||||
|
||||
# Docspell
|
||||
|
||||
|
||||
Docspell is a personal document organizer. You'll need a scanner to
|
||||
convert your papers into PDF files. Docspell can then assist in
|
||||
organizing the resulting mess :wink:.
|
||||
|
||||
See the [microsite](https://eikek.github.io/docspell/) for more
|
||||
information.
|
||||
|
BIN
artwork/icon.png
Normal file
BIN
artwork/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.8 KiB |
217
build.sbt
217
build.sbt
@ -1,39 +1,43 @@
|
||||
import com.github.eikek.sbt.openapi._
|
||||
import scala.sys.process._
|
||||
import com.typesafe.sbt.SbtGit.GitKeys._
|
||||
import docspell.build._
|
||||
|
||||
val sharedSettings = Seq(
|
||||
organization := "com.github.eikek",
|
||||
scalaVersion := "2.13.0",
|
||||
scalaVersion := "2.13.1",
|
||||
scalacOptions ++= Seq(
|
||||
"-deprecation",
|
||||
"-encoding", "UTF-8",
|
||||
"-language:higherKinds",
|
||||
"-language:postfixOps",
|
||||
"-feature",
|
||||
"-Xfatal-warnings", // fail when there are warnings
|
||||
"-Werror", // fail when there are warnings
|
||||
"-unchecked",
|
||||
"-Xlint",
|
||||
"-Xlint:_",
|
||||
"-Ywarn-dead-code",
|
||||
"-Ywarn-numeric-widen",
|
||||
"-Ywarn-value-discard"
|
||||
),
|
||||
scalacOptions in (Compile, console) := Seq()
|
||||
scalacOptions in (Compile, console) :=
|
||||
(scalacOptions.value.filter(o => !o.contains("Xlint")) ++ Seq("-Xlint:_,-unused")),
|
||||
scalacOptions in (Test, console) :=
|
||||
(scalacOptions.value.filter(o => !o.contains("Xlint")) ++ Seq("-Xlint:_,-unused"))
|
||||
)
|
||||
|
||||
val testSettings = Seq(
|
||||
testFrameworks += new TestFramework("minitest.runner.Framework"),
|
||||
libraryDependencies ++= Dependencies.miniTest
|
||||
libraryDependencies ++= Dependencies.miniTest ++ Dependencies.logging.map(_ % Test)
|
||||
)
|
||||
|
||||
val elmSettings = Seq(
|
||||
Compile/resourceGenerators += (Def.task {
|
||||
Compile/resourceGenerators += Def.task {
|
||||
compileElm(streams.value.log
|
||||
, (Compile/baseDirectory).value
|
||||
, (Compile/resourceManaged).value
|
||||
, name.value
|
||||
, version.value)
|
||||
}).taskValue,
|
||||
}.taskValue,
|
||||
watchSources += Watched.WatchSource(
|
||||
(Compile/sourceDirectory).value/"elm"
|
||||
, FileFilter.globFilter("*.elm")
|
||||
@ -42,14 +46,14 @@ val elmSettings = Seq(
|
||||
)
|
||||
|
||||
val webjarSettings = Seq(
|
||||
Compile/resourceGenerators += (Def.task {
|
||||
Compile/resourceGenerators += Def.task {
|
||||
copyWebjarResources(Seq((sourceDirectory in Compile).value/"webjar")
|
||||
, (Compile/resourceManaged).value
|
||||
, name.value
|
||||
, version.value
|
||||
, streams.value.log
|
||||
)
|
||||
}).taskValue,
|
||||
}.taskValue,
|
||||
watchSources += Watched.WatchSource(
|
||||
(Compile / sourceDirectory).value/"webjar"
|
||||
, FileFilter.globFilter("*.js") || FileFilter.globFilter("*.css")
|
||||
@ -57,7 +61,7 @@ val webjarSettings = Seq(
|
||||
)
|
||||
)
|
||||
|
||||
val debianSettings = Seq(
|
||||
def debianSettings(cfgFile: String) = Seq(
|
||||
maintainer := "Eike Kettner <eike.kettner@posteo.de>",
|
||||
packageSummary := description.value,
|
||||
packageDescription := description.value,
|
||||
@ -66,9 +70,9 @@ val debianSettings = Seq(
|
||||
if (!conf.exists) {
|
||||
sys.error(s"File $conf not found")
|
||||
}
|
||||
conf -> "conf/docspell.conf"
|
||||
conf -> s"conf/$cfgFile.conf"
|
||||
},
|
||||
bashScriptExtraDefines += """addJava "-Dconfig.file=${app_home}/../conf/docspell.conf""""
|
||||
bashScriptExtraDefines += s"""addJava "-Dconfig.file=$${app_home}/../conf/$cfgFile.conf""""
|
||||
)
|
||||
|
||||
val buildInfoSettings = Seq(
|
||||
@ -77,7 +81,47 @@ val buildInfoSettings = Seq(
|
||||
buildInfoOptions += BuildInfoOption.BuildTime
|
||||
)
|
||||
|
||||
val openapiScalaSettings = Seq(
|
||||
openapiScalaConfig := ScalaConfig().withJson(ScalaJson.circeSemiauto).
|
||||
addMapping(CustomMapping.forType({
|
||||
case TypeDef("LocalDateTime", _) =>
|
||||
TypeDef("Timestamp", Imports("docspell.common.Timestamp"))
|
||||
})).
|
||||
addMapping(CustomMapping.forFormatType({
|
||||
case "ident" => field =>
|
||||
field.copy(typeDef = TypeDef("Ident", Imports("docspell.common.Ident")))
|
||||
case "collectivestate" => field =>
|
||||
field.copy(typeDef = TypeDef("CollectiveState", Imports("docspell.common.CollectiveState")))
|
||||
case "userstate" => field =>
|
||||
field.copy(typeDef = TypeDef("UserState", Imports("docspell.common.UserState")))
|
||||
case "password" => field =>
|
||||
field.copy(typeDef = TypeDef("Password", Imports("docspell.common.Password")))
|
||||
case "contactkind" => field =>
|
||||
field.copy(typeDef = TypeDef("ContactKind", Imports("docspell.common.ContactKind")))
|
||||
case "direction" => field =>
|
||||
field.copy(typeDef = TypeDef("Direction", Imports("docspell.common.Direction")))
|
||||
case "priority" => field =>
|
||||
field.copy(typeDef = TypeDef("Priority", Imports("docspell.common.Priority")))
|
||||
case "jobstate" => field =>
|
||||
field.copy(typeDef = TypeDef("JobState", Imports("docspell.common.JobState")))
|
||||
case "loglevel" => field =>
|
||||
field.copy(typeDef = TypeDef("LogLevel", Imports("docspell.common.LogLevel")))
|
||||
case "mimetype" => field =>
|
||||
field.copy(typeDef = TypeDef("MimeType", Imports("docspell.common.MimeType")))
|
||||
case "itemstate" => field =>
|
||||
field.copy(typeDef = TypeDef("ItemState", Imports("docspell.common.ItemState")))
|
||||
case "nertag" => field =>
|
||||
field.copy(typeDef = TypeDef("NerTag", Imports("docspell.common.NerTag")))
|
||||
case "language" => field =>
|
||||
field.copy(typeDef = TypeDef("Language", Imports("docspell.common.Language")))
|
||||
}))
|
||||
)
|
||||
|
||||
val reStartSettings = Seq(
|
||||
javaOptions in reStart ++= Seq(s"-Dconfig.file=${(LocalRootProject/baseDirectory).value/"dev.conf"}")
|
||||
)
|
||||
|
||||
// --- Modules
|
||||
|
||||
val common = project.in(file("modules/common")).
|
||||
settings(sharedSettings).
|
||||
@ -85,7 +129,10 @@ val common = project.in(file("modules/common")).
|
||||
settings(
|
||||
name := "docspell-common",
|
||||
libraryDependencies ++=
|
||||
Dependencies.fs2
|
||||
Dependencies.fs2 ++
|
||||
Dependencies.circe ++
|
||||
Dependencies.loggingApi ++
|
||||
Dependencies.pureconfig.map(_ % "optional")
|
||||
)
|
||||
|
||||
val store = project.in(file("modules/store")).
|
||||
@ -96,16 +143,31 @@ val store = project.in(file("modules/store")).
|
||||
libraryDependencies ++=
|
||||
Dependencies.doobie ++
|
||||
Dependencies.bitpeace ++
|
||||
Dependencies.tika ++
|
||||
Dependencies.fs2 ++
|
||||
Dependencies.databases ++
|
||||
Dependencies.flyway ++
|
||||
Dependencies.loggingApi
|
||||
)
|
||||
).dependsOn(common)
|
||||
|
||||
val text = project.in(file("modules/text")).
|
||||
enablePlugins(NerModelsPlugin).
|
||||
settings(sharedSettings).
|
||||
settings(testSettings).
|
||||
settings(NerModelsPlugin.nerClassifierSettings).
|
||||
settings(
|
||||
name := "docspell-text",
|
||||
libraryDependencies ++=
|
||||
Dependencies.fs2 ++
|
||||
Dependencies.tika ++
|
||||
Dependencies.stanfordNlpCore
|
||||
).dependsOn(common)
|
||||
|
||||
val restapi = project.in(file("modules/restapi")).
|
||||
enablePlugins(OpenApiSchema).
|
||||
settings(sharedSettings).
|
||||
settings(testSettings).
|
||||
settings(openapiScalaSettings).
|
||||
settings(
|
||||
name := "docspell-restapi",
|
||||
libraryDependencies ++=
|
||||
@ -113,22 +175,21 @@ val restapi = project.in(file("modules/restapi")).
|
||||
openapiTargetLanguage := Language.Scala,
|
||||
openapiPackage := Pkg("docspell.restapi.model"),
|
||||
openapiSpec := (Compile/resourceDirectory).value/"docspell-openapi.yml",
|
||||
openapiScalaConfig := ScalaConfig().withJson(ScalaJson.circeSemiauto)
|
||||
)
|
||||
).dependsOn(common)
|
||||
|
||||
val joexapi = project.in(file("modules/joexapi")).
|
||||
enablePlugins(OpenApiSchema).
|
||||
settings(sharedSettings).
|
||||
settings(testSettings).
|
||||
settings(openapiScalaSettings).
|
||||
settings(
|
||||
name := "docspell-joexapi",
|
||||
libraryDependencies ++=
|
||||
Dependencies.circe,
|
||||
openapiTargetLanguage := Language.Scala,
|
||||
openapiPackage := Pkg("docspell.joexapi.model"),
|
||||
openapiSpec := (Compile/resourceDirectory).value/"joex-openapi.yml",
|
||||
openapiScalaConfig := ScalaConfig().withJson(ScalaJson.circeSemiauto)
|
||||
)
|
||||
openapiSpec := (Compile/resourceDirectory).value/"joex-openapi.yml"
|
||||
).dependsOn(common)
|
||||
|
||||
val joex = project.in(file("modules/joex")).
|
||||
enablePlugins(BuildInfoPlugin
|
||||
@ -137,7 +198,7 @@ val joex = project.in(file("modules/joex")).
|
||||
, SystemdPlugin).
|
||||
settings(sharedSettings).
|
||||
settings(testSettings).
|
||||
settings(debianSettings).
|
||||
settings(debianSettings("docspell-joex")).
|
||||
settings(buildInfoSettings).
|
||||
settings(
|
||||
name := "docspell-joex",
|
||||
@ -147,11 +208,12 @@ val joex = project.in(file("modules/joex")).
|
||||
Dependencies.circe ++
|
||||
Dependencies.pureconfig ++
|
||||
Dependencies.loggingApi ++
|
||||
Dependencies.logging,
|
||||
Dependencies.logging.map(_ % Runtime),
|
||||
addCompilerPlugin(Dependencies.kindProjectorPlugin),
|
||||
addCompilerPlugin(Dependencies.betterMonadicFor),
|
||||
buildInfoPackage := "docspell.joex"
|
||||
).dependsOn(store, joexapi, restapi)
|
||||
buildInfoPackage := "docspell.joex",
|
||||
reStart/javaOptions ++= Seq(s"-Dconfig.file=${(LocalRootProject/baseDirectory).value/"dev.conf"}")
|
||||
).dependsOn(store, text, joexapi, restapi)
|
||||
|
||||
val backend = project.in(file("modules/backend")).
|
||||
settings(sharedSettings).
|
||||
@ -160,7 +222,9 @@ val backend = project.in(file("modules/backend")).
|
||||
name := "docspell-backend",
|
||||
libraryDependencies ++=
|
||||
Dependencies.loggingApi ++
|
||||
Dependencies.fs2
|
||||
Dependencies.fs2 ++
|
||||
Dependencies.bcrypt ++
|
||||
Dependencies.http4sClient
|
||||
).dependsOn(store)
|
||||
|
||||
val webapp = project.in(file("modules/webapp")).
|
||||
@ -183,7 +247,7 @@ val restserver = project.in(file("modules/restserver")).
|
||||
, SystemdPlugin).
|
||||
settings(sharedSettings).
|
||||
settings(testSettings).
|
||||
settings(debianSettings).
|
||||
settings(debianSettings("docspell-server")).
|
||||
settings(buildInfoSettings).
|
||||
settings(
|
||||
name := "docspell-restserver",
|
||||
@ -194,39 +258,122 @@ val restserver = project.in(file("modules/restserver")).
|
||||
Dependencies.yamusca ++
|
||||
Dependencies.webjars ++
|
||||
Dependencies.loggingApi ++
|
||||
Dependencies.logging,
|
||||
Dependencies.logging.map(_ % Runtime),
|
||||
addCompilerPlugin(Dependencies.kindProjectorPlugin),
|
||||
addCompilerPlugin(Dependencies.betterMonadicFor),
|
||||
buildInfoPackage := "docspell.restserver",
|
||||
Compile/sourceGenerators += (Def.task {
|
||||
Compile/sourceGenerators += Def.task {
|
||||
createWebjarSource(Dependencies.webjars, (Compile/sourceManaged).value)
|
||||
}).taskValue,
|
||||
Compile/unmanagedResourceDirectories ++= Seq((Compile/resourceDirectory).value.getParentFile/"templates")
|
||||
}.taskValue,
|
||||
Compile/resourceGenerators += Def.task {
|
||||
copyWebjarResources(Seq((restapi/Compile/resourceDirectory).value/"docspell-openapi.yml")
|
||||
, (Compile/resourceManaged).value
|
||||
, name.value
|
||||
, version.value
|
||||
, streams.value.log)
|
||||
}.taskValue,
|
||||
Compile/unmanagedResourceDirectories ++= Seq((Compile/resourceDirectory).value.getParentFile/"templates"),
|
||||
reStart/javaOptions ++= Seq(s"-Dconfig.file=${(LocalRootProject/baseDirectory).value/"dev.conf"}")
|
||||
).dependsOn(restapi, joexapi, backend, webapp)
|
||||
|
||||
val microsite = project.in(file("modules/microsite")).
|
||||
enablePlugins(MicrositesPlugin).
|
||||
settings(sharedSettings).
|
||||
settings(
|
||||
name := "docspell-microsite",
|
||||
publishArtifact := false,
|
||||
scalacOptions -= "-Yno-imports",
|
||||
scalacOptions ~= { _ filterNot (_ startsWith "-Ywarn") },
|
||||
scalacOptions ~= { _ filterNot (_ startsWith "-Xlint") },
|
||||
scalaVersion := "2.12.9",
|
||||
skip in publish := true,
|
||||
micrositeFooterText := Some(
|
||||
"""
|
||||
|<p>© 2019 <a href="https://github.com/eikek/docspell">Docspell, v{{site.version}}</a></p>
|
||||
|""".stripMargin
|
||||
),
|
||||
micrositeName := "Docspell",
|
||||
micrositeDescription := "Docspell – A Document Organizer",
|
||||
micrositeBaseUrl := "/docspell",
|
||||
micrositeAuthor := "eikek",
|
||||
micrositeGithubOwner := "eikek",
|
||||
micrositeGithubRepo := "docspell",
|
||||
micrositeGitterChannel := false,
|
||||
micrositeFavicons := Seq(microsites.MicrositeFavicon("favicon.png", "96x96")),
|
||||
micrositeShareOnSocial := false,
|
||||
micrositeHighlightLanguages ++= Seq("json", "javascript"),
|
||||
micrositePalette := Map(
|
||||
"brand-primary" -> "#5d000a", // link color
|
||||
"brand-secondary" -> "#172651", //sidebar background
|
||||
"brand-tertiary" -> "#495680", //main brand background
|
||||
"gray-dark" -> "#050913", //header font color
|
||||
"gray" -> "#131f43", //font color
|
||||
"gray-light" -> "#E3E2E3",
|
||||
"gray-lighter" -> "#f8fbff", //body background
|
||||
"white-color" -> "#FFFFFF"),
|
||||
fork in tut := true,
|
||||
scalacOptions in Tut ~= (_.filterNot(Set("-Ywarn-unused-import", "-Ywarn-dead-code", "-Werror"))),
|
||||
resourceGenerators in Tut += Def.task {
|
||||
val conf1 = (resourceDirectory in (restserver, Compile)).value / "reference.conf"
|
||||
val conf2 = (resourceDirectory in (joex, Compile)).value / "reference.conf"
|
||||
val out1 = resourceManaged.value/"main"/"jekyll"/"_includes"/"server.conf"
|
||||
val out2 = resourceManaged.value/"main"/"jekyll"/"_includes"/"joex.conf"
|
||||
streams.value.log.info(s"Copying reference.conf: $conf1 -> $out1, $conf2 -> $out2")
|
||||
IO.write(out1, "{% raw %}\n")
|
||||
IO.append(out1, IO.readBytes(conf1))
|
||||
IO.write(out1, "\n{% endraw %}", append = true)
|
||||
IO.write(out2, "{% raw %}\n")
|
||||
IO.append(out2, IO.readBytes(conf2))
|
||||
IO.write(out2, "\n{% endraw %}", append = true)
|
||||
val oa1 = (resourceDirectory in (restapi, Compile)).value/"docspell-openapi.yml"
|
||||
val oaout = resourceManaged.value/"main"/"jekyll"/"openapi"/"docspell-openapi.yml"
|
||||
IO.copy(Seq(oa1 -> oaout))
|
||||
Seq(out1, out2, oaout)
|
||||
}.taskValue,
|
||||
resourceGenerators in Tut += Def.task {
|
||||
val staticDoc = (restapi/Compile/openapiStaticDoc).value
|
||||
val target = resourceManaged.value/"main"/"jekyll"/"openapi"/"docspell-openapi.html"
|
||||
IO.copy(Seq(staticDoc -> target))
|
||||
Seq(target)
|
||||
}.taskValue,
|
||||
micrositeCompilingDocsTool := WithTut //WithMdoc
|
||||
// mdocIn := sourceDirectory.value / "main" / "tut"
|
||||
)
|
||||
|
||||
val root = project.in(file(".")).
|
||||
settings(sharedSettings).
|
||||
settings(
|
||||
name := "docspell-root"
|
||||
).
|
||||
aggregate(common, store, joexapi, joex, backend, webapp, restapi, restserver)
|
||||
aggregate(common
|
||||
, text
|
||||
, store
|
||||
, joexapi
|
||||
, joex
|
||||
, backend
|
||||
, webapp
|
||||
, restapi
|
||||
, restserver
|
||||
, microsite)
|
||||
|
||||
|
||||
|
||||
// --- helpers
|
||||
|
||||
def copyWebjarResources(src: Seq[File], base: File, artifact: String, version: String, logger: Logger): Seq[File] = {
|
||||
val targetDir = base/"META-INF"/"resources"/"webjars"/artifact/version
|
||||
logger.info(s"Copy webjar resources from ${src.size} files/directories.")
|
||||
src.flatMap { dir =>
|
||||
if (dir.isDirectory) {
|
||||
val files = (dir ** "*").filter(_.isFile).get pair Path.relativeTo(dir)
|
||||
files.map { case (f, name) =>
|
||||
val target = targetDir/name
|
||||
logger.info(s"Copy $f -> $target")
|
||||
IO.createDirectories(Seq(target.getParentFile))
|
||||
IO.copy(Seq(f -> target))
|
||||
target
|
||||
}
|
||||
} else {
|
||||
val target = targetDir/dir.name
|
||||
logger.info(s"Copy $dir -> $target")
|
||||
IO.createDirectories(Seq(target.getParentFile))
|
||||
IO.copy(Seq(dir -> target))
|
||||
Seq(target)
|
||||
@ -255,3 +402,9 @@ def createWebjarSource(wj: Seq[ModuleID], out: File): Seq[File] = {
|
||||
IO.write(target, content)
|
||||
Seq(target)
|
||||
}
|
||||
|
||||
|
||||
addCommandAlias("make", ";root/openapiCodegen ;root/test:compile")
|
||||
addCommandAlias("make-zip", ";restserver/universal:packageBin ;joex/universal:packageBin")
|
||||
addCommandAlias("make-deb", ";restserver/debian:packageBin ;joex/debian:packageBin")
|
||||
addCommandAlias("make-pkg", ";make-zip ;make-deb")
|
||||
|
110
doc/dev.md
110
doc/dev.md
@ -1,110 +0,0 @@
|
||||
# Development Documentation
|
||||
|
||||
|
||||
## initial thoughts
|
||||
|
||||
* First there is a web app, where user can login, look at their
|
||||
documents etc
|
||||
* User can do queries and edit document meta data
|
||||
* User can manage upload endpoints
|
||||
|
||||
Upload endpoints allow to receive "items". There are the following
|
||||
different options:
|
||||
|
||||
1. Upload a single item by uploading one file.
|
||||
2. Upload a single item by uploading a zip file.
|
||||
3. Upload multiple items by uploading a zip file (one entry = one
|
||||
item)
|
||||
|
||||
Files are received and stored in the database, always. Only if a size
|
||||
constraint is not fulfilled the response is an error. Files are marked
|
||||
as `RECEIVED`. Idea is that most files are valid, so are saved
|
||||
anyways.
|
||||
|
||||
Then a job for a new item is inserted into the processing queue and
|
||||
processing begins eventually.
|
||||
|
||||
External processes access the queue on the same database and take jobs
|
||||
for processing.
|
||||
|
||||
Processing:
|
||||
|
||||
1. check mimetype and error if not supported
|
||||
- want to use the servers mimetype instead of advertised one from
|
||||
the client
|
||||
2. extract text and other meta data
|
||||
3. do some analysis
|
||||
4. tag item/set meta data
|
||||
5. encrypt files + text, if configured
|
||||
|
||||
If an error occurs, it can be inspected in the "queue screen". The web
|
||||
app shows notifications in this case. User can download the file and
|
||||
remove it. Otherwise, files will be deleted after some period. Errors
|
||||
are also counted per source, so one can decide whether to block a
|
||||
source.
|
||||
|
||||
Once processing is done, the item is put in the INBOX.
|
||||
|
||||
## Modules
|
||||
|
||||
### processor
|
||||
|
||||
### backend
|
||||
|
||||
### store
|
||||
|
||||
### backend server
|
||||
|
||||
### webapp
|
||||
|
||||
## Flow
|
||||
|
||||
|
||||
1. webapp: calls rest route
|
||||
2. server:
|
||||
1. convert json -> data
|
||||
2. choose backend operation
|
||||
3. backend: execute logic
|
||||
1. store: load or save from/to db
|
||||
4. server:
|
||||
1. convert data -> json
|
||||
|
||||
|
||||
backend:
|
||||
- need better name
|
||||
- contains all logic encoded as operations
|
||||
- operation: A -> Either[E, B]
|
||||
- middleware translates userId -> required data
|
||||
- e.g. userId -> public key
|
||||
- operations can fail
|
||||
- common error class is used
|
||||
- can be converted to json easily
|
||||
|
||||
|
||||
New Items:
|
||||
|
||||
1. upload endpoint
|
||||
2. server:
|
||||
1. convert json->data
|
||||
3. store: add job to queue
|
||||
4. processor:
|
||||
1. eventually takes the job
|
||||
2. execute job
|
||||
3. notify about result
|
||||
|
||||
|
||||
Processors
|
||||
|
||||
- multiple processors possible
|
||||
- multiple backend servers possible
|
||||
- separate processes
|
||||
- register on database
|
||||
- unique id
|
||||
- url
|
||||
- servers and processors
|
||||
- once a job is added to the queue notify all processors
|
||||
- take all registered urls from db
|
||||
- call them, skip failing ones
|
||||
- processors wake up and take next job based on their config
|
||||
- first free processor gets a new job
|
||||
- once done, notify registered backend server
|
@ -1 +0,0 @@
|
||||
# Installation and Setup
|
69
doc/user.md
69
doc/user.md
@ -1,69 +0,0 @@
|
||||
# User Documentation
|
||||
|
||||
## Concepts
|
||||
|
||||
|
||||
## UI Screens
|
||||
|
||||
The web application is the provided user interface. THere are the following screens
|
||||
|
||||
|
||||
### Login
|
||||
|
||||
### Change Password
|
||||
|
||||
### Document Overview
|
||||
|
||||
- search menu on the left, 25%
|
||||
- listing in the middle, 25%
|
||||
- choose to list all documents
|
||||
- or grouped by date, corresp. etc
|
||||
- right: document preview + details, 50%
|
||||
|
||||
### Document Edit
|
||||
|
||||
- search menu + listing is replaced by edit screen, 50%
|
||||
- document preview 50%
|
||||
|
||||
### Manage Additional Data
|
||||
|
||||
CRUD for
|
||||
|
||||
- Organisation
|
||||
- Person
|
||||
- Equipment
|
||||
- Sources, which are the possible upload endpoints.
|
||||
|
||||
### Collective Settings
|
||||
|
||||
- keystore
|
||||
- collective data
|
||||
- manage users
|
||||
|
||||
### User Settings
|
||||
|
||||
- preferences (language, ...)
|
||||
- smtp servers
|
||||
|
||||
### Super Settings
|
||||
|
||||
- admin only
|
||||
- enable/disable registration
|
||||
- settings for the app and collectives
|
||||
- e.g. can block collectives or users
|
||||
- CRUD for all entities
|
||||
|
||||
### Collective Processing Queue
|
||||
|
||||
- user can inspect current processing
|
||||
- see errors and progress
|
||||
- see jobs in queue
|
||||
- cancel jobs
|
||||
- see some stats about executors, so one can make an educated guess as
|
||||
to when the next job is executed
|
||||
|
||||
### Admin Processing Queue
|
||||
|
||||
- see which external processing workers are registered
|
||||
- cancel/pause jobs
|
||||
- some stats
|
14
elm.json
14
elm.json
@ -7,18 +7,26 @@
|
||||
"elm-version": "0.19.0",
|
||||
"dependencies": {
|
||||
"direct": {
|
||||
"CurrySoftware/elm-datepicker": "3.1.0",
|
||||
"NoRedInk/elm-json-decode-pipeline": "1.0.0",
|
||||
"NoRedInk/elm-simple-fuzzy": "1.0.3",
|
||||
"elm/browser": "1.0.1",
|
||||
"elm/core": "1.0.2",
|
||||
"elm/file": "1.0.5",
|
||||
"elm/html": "1.0.0",
|
||||
"elm/http": "2.0.0",
|
||||
"elm/json": "1.1.3",
|
||||
"elm/url": "1.0.0"
|
||||
"elm/time": "1.0.0",
|
||||
"elm/url": "1.0.0",
|
||||
"elm-explorations/markdown": "1.0.0",
|
||||
"justinmimbs/date": "3.1.2",
|
||||
"ryannhg/date-format": "2.3.0",
|
||||
"truqu/elm-base64": "2.0.4"
|
||||
},
|
||||
"indirect": {
|
||||
"elm/bytes": "1.0.8",
|
||||
"elm/file": "1.0.5",
|
||||
"elm/time": "1.0.0",
|
||||
"elm/parser": "1.1.0",
|
||||
"elm/regex": "1.0.0",
|
||||
"elm/virtual-dom": "1.0.2"
|
||||
}
|
||||
},
|
||||
|
@ -0,0 +1,66 @@
|
||||
package docspell.backend
|
||||
|
||||
import cats.effect.{Blocker, ConcurrentEffect, ContextShift, Resource}
|
||||
import docspell.backend.auth.Login
|
||||
import docspell.backend.ops._
|
||||
import docspell.backend.signup.OSignup
|
||||
import docspell.store.Store
|
||||
import docspell.store.ops.ONode
|
||||
import docspell.store.queue.JobQueue
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
trait BackendApp[F[_]] {
|
||||
|
||||
def login: Login[F]
|
||||
def signup: OSignup[F]
|
||||
def collective: OCollective[F]
|
||||
def source: OSource[F]
|
||||
def tag: OTag[F]
|
||||
def equipment: OEquipment[F]
|
||||
def organization: OOrganization[F]
|
||||
def upload: OUpload[F]
|
||||
def node: ONode[F]
|
||||
def job: OJob[F]
|
||||
def item: OItem[F]
|
||||
}
|
||||
|
||||
object BackendApp {
|
||||
|
||||
def create[F[_]: ConcurrentEffect](cfg: Config, store: Store[F], httpClientEc: ExecutionContext): Resource[F, BackendApp[F]] =
|
||||
for {
|
||||
queue <- JobQueue(store)
|
||||
loginImpl <- Login[F](store)
|
||||
signupImpl <- OSignup[F](store)
|
||||
collImpl <- OCollective[F](store)
|
||||
sourceImpl <- OSource[F](store)
|
||||
tagImpl <- OTag[F](store)
|
||||
equipImpl <- OEquipment[F](store)
|
||||
orgImpl <- OOrganization(store)
|
||||
uploadImpl <- OUpload(store, queue, cfg, httpClientEc)
|
||||
nodeImpl <- ONode(store)
|
||||
jobImpl <- OJob(store, httpClientEc)
|
||||
itemImpl <- OItem(store)
|
||||
} yield new BackendApp[F] {
|
||||
val login: Login[F] = loginImpl
|
||||
val signup: OSignup[F] = signupImpl
|
||||
val collective: OCollective[F] = collImpl
|
||||
val source = sourceImpl
|
||||
val tag = tagImpl
|
||||
val equipment = equipImpl
|
||||
val organization = orgImpl
|
||||
val upload = uploadImpl
|
||||
val node = nodeImpl
|
||||
val job = jobImpl
|
||||
val item = itemImpl
|
||||
}
|
||||
|
||||
def apply[F[_]: ConcurrentEffect: ContextShift](cfg: Config
|
||||
, connectEC: ExecutionContext
|
||||
, httpClientEc: ExecutionContext
|
||||
, blocker: Blocker): Resource[F, BackendApp[F]] =
|
||||
for {
|
||||
store <- Store.create(cfg.jdbc, connectEC, blocker)
|
||||
backend <- create(cfg, store, httpClientEc)
|
||||
} yield backend
|
||||
}
|
10
modules/backend/src/main/scala/docspell/backend/Common.scala
Normal file
10
modules/backend/src/main/scala/docspell/backend/Common.scala
Normal file
@ -0,0 +1,10 @@
|
||||
package docspell.backend
|
||||
|
||||
import cats.effect._
|
||||
import org.mindrot.jbcrypt.BCrypt
|
||||
|
||||
object Common {
|
||||
|
||||
def genSaltString[F[_]: Sync]: F[String] =
|
||||
Sync[F].delay(BCrypt.gensalt())
|
||||
}
|
16
modules/backend/src/main/scala/docspell/backend/Config.scala
Normal file
16
modules/backend/src/main/scala/docspell/backend/Config.scala
Normal file
@ -0,0 +1,16 @@
|
||||
package docspell.backend
|
||||
|
||||
import docspell.backend.signup.{Config => SignupConfig}
|
||||
import docspell.common.MimeType
|
||||
import docspell.store.JdbcConfig
|
||||
|
||||
case class Config( jdbc: JdbcConfig
|
||||
, signup: SignupConfig
|
||||
, files: Config.Files) {
|
||||
|
||||
}
|
||||
|
||||
object Config {
|
||||
|
||||
case class Files(chunkSize: Int, validMimeTypes: Seq[MimeType])
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
package docspell.backend
|
||||
|
||||
import docspell.common.Password
|
||||
import org.mindrot.jbcrypt.BCrypt
|
||||
|
||||
object PasswordCrypt {
|
||||
|
||||
def crypt(pass: Password): Password =
|
||||
Password(BCrypt.hashpw(pass.pass, BCrypt.gensalt()))
|
||||
|
||||
def check(plain: Password, hashed: Password): Boolean =
|
||||
BCrypt.checkpw(plain.pass, hashed.pass)
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
package docspell.backend.auth
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import java.time.Instant
|
||||
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import scodec.bits.ByteVector
|
||||
|
||||
import docspell.backend.Common
|
||||
import AuthToken._
|
||||
import docspell.common._
|
||||
|
||||
case class AuthToken(millis: Long, account: AccountId, salt: String, sig: String) {
|
||||
def asString = s"$millis-${b64enc(account.asString)}-$salt-$sig"
|
||||
|
||||
def sigValid(key: ByteVector): Boolean = {
|
||||
val newSig = AuthToken.sign(this, key)
|
||||
AuthToken.constTimeEq(sig, newSig)
|
||||
}
|
||||
def sigInvalid(key: ByteVector): Boolean =
|
||||
!sigValid(key)
|
||||
|
||||
def notExpired(validity: Duration): Boolean =
|
||||
!isExpired(validity)
|
||||
|
||||
def isExpired(validity: Duration): Boolean = {
|
||||
val ends = Instant.ofEpochMilli(millis).plusMillis(validity.millis)
|
||||
Instant.now.isAfter(ends)
|
||||
}
|
||||
|
||||
def validate(key: ByteVector, validity: Duration): Boolean =
|
||||
sigValid(key) && notExpired(validity)
|
||||
}
|
||||
|
||||
object AuthToken {
|
||||
private val utf8 = java.nio.charset.StandardCharsets.UTF_8
|
||||
|
||||
def fromString(s: String): Either[String, AuthToken] =
|
||||
s.split("\\-", 4) match {
|
||||
case Array(ms, as, salt, sig) =>
|
||||
for {
|
||||
millis <- asInt(ms).toRight("Cannot read authenticator data")
|
||||
acc <- b64dec(as).toRight("Cannot read authenticator data")
|
||||
accId <- AccountId.parse(acc)
|
||||
} yield AuthToken(millis, accId, salt, sig)
|
||||
|
||||
case _ =>
|
||||
Left("Invalid authenticator")
|
||||
}
|
||||
|
||||
def user[F[_]: Sync](accountId: AccountId, key: ByteVector): F[AuthToken] = {
|
||||
for {
|
||||
salt <- Common.genSaltString[F]
|
||||
millis = Instant.now.toEpochMilli
|
||||
cd = AuthToken(millis, accountId, salt, "")
|
||||
sig = sign(cd, key)
|
||||
} yield cd.copy(sig = sig)
|
||||
}
|
||||
|
||||
private def sign(cd: AuthToken, key: ByteVector): String = {
|
||||
val raw = cd.millis.toString + cd.account.asString + cd.salt
|
||||
val mac = Mac.getInstance("HmacSHA1")
|
||||
mac.init(new SecretKeySpec(key.toArray, "HmacSHA1"))
|
||||
ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64
|
||||
}
|
||||
|
||||
private def b64enc(s: String): String =
|
||||
ByteVector.view(s.getBytes(utf8)).toBase64
|
||||
|
||||
private def b64dec(s: String): Option[String] =
|
||||
ByteVector.fromValidBase64(s).decodeUtf8.toOption
|
||||
|
||||
private def asInt(s: String): Option[Long] =
|
||||
Either.catchNonFatal(s.toLong).toOption
|
||||
|
||||
private def constTimeEq(s1: String, s2: String): Boolean =
|
||||
s1.zip(s2).foldLeft(true)({ case (r, (c1, c2)) => r & c1 == c2 }) & s1.length == s2.length
|
||||
|
||||
}
|
@ -0,0 +1,89 @@
|
||||
package docspell.backend.auth
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import Login._
|
||||
import docspell.common._
|
||||
import docspell.store.Store
|
||||
import docspell.store.queries.QLogin
|
||||
import docspell.store.records.RUser
|
||||
import org.mindrot.jbcrypt.BCrypt
|
||||
import scodec.bits.ByteVector
|
||||
import org.log4s._
|
||||
|
||||
trait Login[F[_]] {
|
||||
|
||||
def loginSession(config: Config)(sessionKey: String): F[Result]
|
||||
|
||||
def loginUserPass(config: Config)(up: UserPass): F[Result]
|
||||
|
||||
}
|
||||
|
||||
object Login {
|
||||
private[this] val logger = getLogger
|
||||
|
||||
case class Config(serverSecret: ByteVector, sessionValid: Duration)
|
||||
|
||||
case class UserPass(user: String, pass: String) {
|
||||
def hidePass: UserPass =
|
||||
if (pass.isEmpty) copy(pass = "<none>")
|
||||
else copy(pass = "***")
|
||||
}
|
||||
|
||||
sealed trait Result {
|
||||
def toEither: Either[String, AuthToken]
|
||||
}
|
||||
object Result {
|
||||
case class Ok(session: AuthToken) extends Result {
|
||||
val toEither = Right(session)
|
||||
}
|
||||
case object InvalidAuth extends Result {
|
||||
val toEither = Left("Authentication failed.")
|
||||
}
|
||||
case object InvalidTime extends Result {
|
||||
val toEither = Left("Authentication failed.")
|
||||
}
|
||||
|
||||
def ok(session: AuthToken): Result = Ok(session)
|
||||
def invalidAuth: Result = InvalidAuth
|
||||
def invalidTime: Result = InvalidTime
|
||||
}
|
||||
|
||||
def apply[F[_]: Effect](store: Store[F]): Resource[F, Login[F]] = Resource.pure(new Login[F] {
|
||||
|
||||
def loginSession(config: Config)(sessionKey: String): F[Result] =
|
||||
AuthToken.fromString(sessionKey) match {
|
||||
case Right(at) =>
|
||||
if (at.sigInvalid(config.serverSecret)) Result.invalidAuth.pure[F]
|
||||
else if (at.isExpired(config.sessionValid)) Result.invalidTime.pure[F]
|
||||
else Result.ok(at).pure[F]
|
||||
case Left(err) =>
|
||||
Result.invalidAuth.pure[F]
|
||||
}
|
||||
|
||||
def loginUserPass(config: Config)(up: UserPass): F[Result] = {
|
||||
AccountId.parse(up.user) match {
|
||||
case Right(acc) =>
|
||||
val okResult=
|
||||
store.transact(RUser.updateLogin(acc)) *>
|
||||
AuthToken.user(acc, config.serverSecret).map(Result.ok)
|
||||
for {
|
||||
data <- store.transact(QLogin.findUser(acc))
|
||||
_ <- Sync[F].delay(logger.trace(s"Account lookup: $data"))
|
||||
res <- if (data.exists(check(up.pass))) okResult
|
||||
else Result.invalidAuth.pure[F]
|
||||
} yield res
|
||||
case Left(err) =>
|
||||
Result.invalidAuth.pure[F]
|
||||
}
|
||||
}
|
||||
|
||||
private def check(given: String)(data: QLogin.Data): Boolean = {
|
||||
val collOk = data.collectiveState == CollectiveState.Active ||
|
||||
data.collectiveState == CollectiveState.ReadOnly
|
||||
val userOk = data.userState == UserState.Active
|
||||
val passOk = BCrypt.checkpw(given, data.password.pass)
|
||||
collOk && userOk && passOk
|
||||
}
|
||||
})
|
||||
}
|
@ -0,0 +1,111 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.{Effect, Resource}
|
||||
import docspell.common._
|
||||
import docspell.store.{AddResult, Store}
|
||||
import docspell.store.records.{RCollective, RUser}
|
||||
import OCollective._
|
||||
import docspell.backend.PasswordCrypt
|
||||
import docspell.store.queries.QCollective
|
||||
|
||||
trait OCollective[F[_]] {
|
||||
|
||||
def find(name: Ident): F[Option[RCollective]]
|
||||
|
||||
def updateLanguage(collective: Ident, lang: Language): F[AddResult]
|
||||
|
||||
def listUser(collective: Ident): F[Vector[RUser]]
|
||||
|
||||
def add(s: RUser): F[AddResult]
|
||||
|
||||
def update(s: RUser): F[AddResult]
|
||||
|
||||
def deleteUser(login: Ident, collective: Ident): F[AddResult]
|
||||
|
||||
def insights(collective: Ident): F[InsightData]
|
||||
|
||||
def changePassword(accountId: AccountId, current: Password, newPass: Password): F[PassChangeResult]
|
||||
}
|
||||
|
||||
object OCollective {
|
||||
|
||||
type InsightData = QCollective.InsightData
|
||||
val insightData = QCollective.InsightData
|
||||
|
||||
sealed trait PassChangeResult
|
||||
object PassChangeResult {
|
||||
case object UserNotFound extends PassChangeResult
|
||||
case object PasswordMismatch extends PassChangeResult
|
||||
case object UpdateFailed extends PassChangeResult
|
||||
case object Success extends PassChangeResult
|
||||
|
||||
def userNotFound: PassChangeResult = UserNotFound
|
||||
def passwordMismatch: PassChangeResult = PasswordMismatch
|
||||
def success: PassChangeResult = Success
|
||||
def updateFailed: PassChangeResult = UpdateFailed
|
||||
}
|
||||
|
||||
case class RegisterData(collName: Ident, login: Ident, password: Password, invite: Option[Ident])
|
||||
|
||||
sealed trait RegisterResult {
|
||||
def toEither: Either[Throwable, Unit]
|
||||
}
|
||||
object RegisterResult {
|
||||
case object Success extends RegisterResult {
|
||||
val toEither = Right(())
|
||||
}
|
||||
case class CollectiveExists(id: Ident) extends RegisterResult {
|
||||
val toEither = Left(new Exception())
|
||||
}
|
||||
case class Error(ex: Throwable) extends RegisterResult {
|
||||
val toEither = Left(ex)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def apply[F[_]:Effect](store: Store[F]): Resource[F, OCollective[F]] =
|
||||
Resource.pure(new OCollective[F] {
|
||||
def find(name: Ident): F[Option[RCollective]] =
|
||||
store.transact(RCollective.findById(name))
|
||||
|
||||
def updateLanguage(collective: Ident, lang: Language): F[AddResult] =
|
||||
store.transact(RCollective.updateLanguage(collective, lang)).
|
||||
attempt.map(AddResult.fromUpdate)
|
||||
|
||||
def listUser(collective: Ident): F[Vector[RUser]] = {
|
||||
store.transact(RUser.findAll(collective, _.login))
|
||||
}
|
||||
|
||||
def add(s: RUser): F[AddResult] =
|
||||
store.add(RUser.insert(s.copy(password = PasswordCrypt.crypt(s.password))), RUser.exists(s.login))
|
||||
|
||||
def update(s: RUser): F[AddResult] =
|
||||
store.add(RUser.update(s), RUser.exists(s.login))
|
||||
|
||||
def deleteUser(login: Ident, collective: Ident): F[AddResult] =
|
||||
store.transact(RUser.delete(login, collective)).
|
||||
attempt.map(AddResult.fromUpdate)
|
||||
|
||||
def insights(collective: Ident): F[InsightData] =
|
||||
store.transact(QCollective.getInsights(collective))
|
||||
|
||||
def changePassword(accountId: AccountId, current: Password, newPass: Password): F[PassChangeResult] = {
|
||||
val q = for {
|
||||
optUser <- RUser.findByAccount(accountId)
|
||||
check = optUser.map(_.password).map(p => PasswordCrypt.check(current, p))
|
||||
n <- check.filter(identity).traverse(_ => RUser.updatePassword(accountId, PasswordCrypt.crypt(newPass)))
|
||||
res = check match {
|
||||
case Some(true) =>
|
||||
if (n.getOrElse(0) > 0) PassChangeResult.success else PassChangeResult.updateFailed
|
||||
case Some(false) =>
|
||||
PassChangeResult.passwordMismatch
|
||||
case None =>
|
||||
PassChangeResult.userNotFound
|
||||
}
|
||||
} yield res
|
||||
|
||||
store.transact(q)
|
||||
}
|
||||
})
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.{Effect, Resource}
|
||||
import docspell.common.{AccountId, Ident}
|
||||
import docspell.store.{AddResult, Store}
|
||||
import docspell.store.records.{REquipment, RItem}
|
||||
|
||||
trait OEquipment[F[_]] {
|
||||
|
||||
def findAll(account: AccountId): F[Vector[REquipment]]
|
||||
|
||||
def add(s: REquipment): F[AddResult]
|
||||
|
||||
def update(s: REquipment): F[AddResult]
|
||||
|
||||
def delete(id: Ident, collective: Ident): F[AddResult]
|
||||
}
|
||||
|
||||
|
||||
object OEquipment {
|
||||
|
||||
def apply[F[_]: Effect](store: Store[F]): Resource[F, OEquipment[F]] =
|
||||
Resource.pure(new OEquipment[F] {
|
||||
def findAll(account: AccountId): F[Vector[REquipment]] =
|
||||
store.transact(REquipment.findAll(account.collective, _.name))
|
||||
|
||||
def add(e: REquipment): F[AddResult] = {
|
||||
def insert = REquipment.insert(e)
|
||||
def exists = REquipment.existsByName(e.cid, e.name)
|
||||
|
||||
val msg = s"An equipment '${e.name}' already exists"
|
||||
store.add(insert, exists).map(_.fold(identity, _.withMsg(msg), identity))
|
||||
}
|
||||
|
||||
def update(e: REquipment): F[AddResult] = {
|
||||
def insert = REquipment.update(e)
|
||||
def exists = REquipment.existsByName(e.cid, e.name)
|
||||
|
||||
val msg = s"An equipment '${e.name}' already exists"
|
||||
store.add(insert, exists).map(_.fold(identity, _.withMsg(msg), identity))
|
||||
}
|
||||
|
||||
def delete(id: Ident, collective: Ident): F[AddResult] = {
|
||||
val io = for {
|
||||
n0 <- RItem.removeConcEquip(collective, id)
|
||||
n1 <- REquipment.delete(id, collective)
|
||||
} yield n0 + n1
|
||||
store.transact(io).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
}
|
||||
})
|
||||
}
|
159
modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
Normal file
159
modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
Normal file
@ -0,0 +1,159 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import fs2.Stream
|
||||
import cats.implicits._
|
||||
import cats.effect.{Effect, Resource}
|
||||
import doobie._
|
||||
import doobie.implicits._
|
||||
import docspell.store.{AddResult, Store}
|
||||
import docspell.store.queries.{QAttachment, QItem}
|
||||
import OItem.{AttachmentData, ItemData, ListItem, Query}
|
||||
import bitpeace.{FileMeta, RangeDef}
|
||||
import docspell.common.{Direction, Ident, ItemState, MetaProposalList, Timestamp}
|
||||
import docspell.store.records.{RAttachment, RAttachmentMeta, RItem, RTagItem}
|
||||
|
||||
trait OItem[F[_]] {
|
||||
|
||||
def findItem(id: Ident, collective: Ident): F[Option[ItemData]]
|
||||
|
||||
def findItems(q: Query, maxResults: Int): F[Vector[ListItem]]
|
||||
|
||||
def findAttachment(id: Ident, collective: Ident): F[Option[AttachmentData[F]]]
|
||||
|
||||
def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[AddResult]
|
||||
|
||||
def setDirection(item: Ident, direction: Direction, collective: Ident): F[AddResult]
|
||||
|
||||
def setCorrOrg(item: Ident, org: Option[Ident], collective: Ident): F[AddResult]
|
||||
|
||||
def setCorrPerson(item: Ident, person: Option[Ident], collective: Ident): F[AddResult]
|
||||
|
||||
def setConcPerson(item: Ident, person: Option[Ident], collective: Ident): F[AddResult]
|
||||
|
||||
def setConcEquip(item: Ident, equip: Option[Ident], collective: Ident): F[AddResult]
|
||||
|
||||
def setNotes(item: Ident, notes: Option[String], collective: Ident): F[AddResult]
|
||||
|
||||
def setName(item: Ident, notes: String, collective: Ident): F[AddResult]
|
||||
|
||||
def setState(item: Ident, state: ItemState, collective: Ident): F[AddResult]
|
||||
|
||||
def setItemDate(item: Ident, date: Option[Timestamp], collective: Ident): F[AddResult]
|
||||
|
||||
def setItemDueDate(item: Ident, date: Option[Timestamp], collective: Ident): F[AddResult]
|
||||
|
||||
def getProposals(item: Ident, collective: Ident): F[MetaProposalList]
|
||||
|
||||
def delete(itemId: Ident, collective: Ident): F[Int]
|
||||
|
||||
def findAttachmentMeta(id: Ident, collective: Ident): F[Option[RAttachmentMeta]]
|
||||
}
|
||||
|
||||
object OItem {
|
||||
|
||||
type Query = QItem.Query
|
||||
val Query = QItem.Query
|
||||
|
||||
type ListItem = QItem.ListItem
|
||||
val ListItem = QItem.ListItem
|
||||
|
||||
type ItemData = QItem.ItemData
|
||||
val ItemData = QItem.ItemData
|
||||
|
||||
case class AttachmentData[F[_]](ra: RAttachment, meta: FileMeta, data: Stream[F, Byte])
|
||||
|
||||
|
||||
def apply[F[_]: Effect](store: Store[F]): Resource[F, OItem[F]] =
|
||||
Resource.pure(new OItem[F] {
|
||||
|
||||
def findItem(id: Ident, collective: Ident): F[Option[ItemData]] =
|
||||
store.transact(QItem.findItem(id)).
|
||||
map(opt => opt.flatMap(_.filterCollective(collective)))
|
||||
|
||||
def findItems(q: Query, maxResults: Int): F[Vector[ListItem]] = {
|
||||
store.transact(QItem.findItems(q).take(maxResults.toLong)).compile.toVector
|
||||
}
|
||||
|
||||
def findAttachment(id: Ident, collective: Ident): F[Option[AttachmentData[F]]] = {
|
||||
store.transact(RAttachment.findByIdAndCollective(id, collective)).
|
||||
flatMap({
|
||||
case Some(ra) =>
|
||||
store.bitpeace.get(ra.fileId.id).unNoneTerminate.compile.last.
|
||||
map(_.map(m => AttachmentData[F](ra, m, store.bitpeace.fetchData2(RangeDef.all)(Stream.emit(m)))))
|
||||
case None =>
|
||||
(None: Option[AttachmentData[F]]).pure[F]
|
||||
})
|
||||
}
|
||||
|
||||
def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[AddResult] = {
|
||||
val db = for {
|
||||
cid <- RItem.getCollective(item)
|
||||
nd <- if (cid.contains(collective)) RTagItem.deleteItemTags(item) else 0.pure[ConnectionIO]
|
||||
ni <- if (tagIds.nonEmpty && cid.contains(collective)) RTagItem.insertItemTags(item, tagIds) else 0.pure[ConnectionIO]
|
||||
} yield nd + ni
|
||||
|
||||
store.transact(db).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
}
|
||||
|
||||
def setDirection(item: Ident, direction: Direction, collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateDirection(item, collective, direction)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def setCorrOrg(item: Ident, org: Option[Ident], collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateCorrOrg(item, collective, org)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def setCorrPerson(item: Ident, person: Option[Ident], collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateCorrPerson(item, collective, person)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def setConcPerson(item: Ident, person: Option[Ident], collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateConcPerson(item, collective, person)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def setConcEquip(item: Ident, equip: Option[Ident], collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateConcEquip(item, collective, equip)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def setNotes(item: Ident, notes: Option[String], collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateNotes(item, collective, notes)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def setName(item: Ident, name: String, collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateName(item, collective, name)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def setState(item: Ident, state: ItemState, collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateStateForCollective(item, state, collective)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def setItemDate(item: Ident, date: Option[Timestamp], collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateDate(item, collective, date)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def setItemDueDate(item: Ident, date: Option[Timestamp], collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateDueDate(item, collective, date)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def delete(itemId: Ident, collective: Ident): F[Int] =
|
||||
QItem.delete(store)(itemId, collective)
|
||||
|
||||
def getProposals(item: Ident, collective: Ident): F[MetaProposalList] =
|
||||
store.transact(QAttachment.getMetaProposals(item, collective))
|
||||
|
||||
def findAttachmentMeta(id: Ident, collective: Ident): F[Option[RAttachmentMeta]] =
|
||||
store.transact(QAttachment.getAttachmentMeta(id, collective))
|
||||
})
|
||||
}
|
@ -0,0 +1,84 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.{ConcurrentEffect, Resource}
|
||||
import docspell.backend.ops.OJob.{CollectiveQueueState, JobCancelResult}
|
||||
import docspell.common.{Ident, JobState}
|
||||
import docspell.store.Store
|
||||
import docspell.store.queries.QJob
|
||||
import docspell.store.records.{RJob, RJobLog}
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
trait OJob[F[_]] {
|
||||
|
||||
def queueState(collective: Ident, maxResults: Int): F[CollectiveQueueState]
|
||||
|
||||
def cancelJob(id: Ident, collective: Ident): F[JobCancelResult]
|
||||
}
|
||||
|
||||
object OJob {
|
||||
|
||||
sealed trait JobCancelResult
|
||||
object JobCancelResult {
|
||||
case object Removed extends JobCancelResult
|
||||
case object CancelRequested extends JobCancelResult
|
||||
case object JobNotFound extends JobCancelResult
|
||||
}
|
||||
|
||||
case class JobDetail(job: RJob, logs: Vector[RJobLog])
|
||||
case class CollectiveQueueState(jobs: Vector[JobDetail]) {
|
||||
def queued: Vector[JobDetail] =
|
||||
jobs.filter(r => JobState.queued.contains(r.job.state))
|
||||
def done: Vector[JobDetail] =
|
||||
jobs.filter(r => JobState.done.contains(r.job.state))
|
||||
def running: Vector[JobDetail] =
|
||||
jobs.filter(_.job.state == JobState.Running)
|
||||
}
|
||||
|
||||
def apply[F[_]: ConcurrentEffect](store: Store[F], clientEC: ExecutionContext): Resource[F, OJob[F]] =
|
||||
Resource.pure(new OJob[F] {
|
||||
|
||||
def queueState(collective: Ident, maxResults: Int): F[CollectiveQueueState] = {
|
||||
store.transact(QJob.queueStateSnapshot(collective).take(maxResults.toLong)).
|
||||
map(t => JobDetail(t._1, t._2)).
|
||||
compile.toVector.
|
||||
map(CollectiveQueueState)
|
||||
}
|
||||
|
||||
def cancelJob(id: Ident, collective: Ident): F[JobCancelResult] = {
|
||||
def mustCancel(job: Option[RJob]): Option[(RJob, Ident)] =
|
||||
for {
|
||||
worker <- job.flatMap(_.worker)
|
||||
job <- job.filter(j => j.state == JobState.Scheduled || j.state == JobState.Running)
|
||||
} yield (job, worker)
|
||||
|
||||
def canDelete(j: RJob): Boolean =
|
||||
mustCancel(j.some).isEmpty
|
||||
|
||||
val tryDelete = for {
|
||||
job <- RJob.findByIdAndGroup(id, collective)
|
||||
jobm = job.filter(canDelete)
|
||||
del <- jobm.traverse(j => RJob.delete(j.id))
|
||||
} yield del match {
|
||||
case Some(n) => Right(JobCancelResult.Removed: JobCancelResult)
|
||||
case None => Left(mustCancel(job))
|
||||
}
|
||||
|
||||
def tryCancel(job: RJob, worker: Ident): F[JobCancelResult] =
|
||||
OJoex.cancelJob(job.id, worker, store, clientEC).
|
||||
map(flag => if (flag) JobCancelResult.CancelRequested else JobCancelResult.JobNotFound)
|
||||
|
||||
for {
|
||||
tryDel <- store.transact(tryDelete)
|
||||
result <- tryDel match {
|
||||
case Right(r) => r.pure[F]
|
||||
case Left(Some((job, worker))) =>
|
||||
tryCancel(job, worker)
|
||||
case Left(None) =>
|
||||
(JobCancelResult.JobNotFound: OJob.JobCancelResult).pure[F]
|
||||
}
|
||||
} yield result
|
||||
}
|
||||
})
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.ConcurrentEffect
|
||||
import docspell.common.{Ident, NodeType}
|
||||
import docspell.store.Store
|
||||
import docspell.store.records.RNode
|
||||
import org.http4s.client.blaze.BlazeClientBuilder
|
||||
import org.http4s.Method._
|
||||
import org.http4s.{Request, Uri}
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
import org.log4s._
|
||||
|
||||
object OJoex {
|
||||
private [this] val logger = getLogger
|
||||
|
||||
def notifyAll[F[_]: ConcurrentEffect](store: Store[F], clientExecutionContext: ExecutionContext): F[Unit] = {
|
||||
for {
|
||||
nodes <- store.transact(RNode.findAll(NodeType.Joex))
|
||||
_ <- nodes.toList.traverse(notifyJoex[F](clientExecutionContext))
|
||||
} yield ()
|
||||
}
|
||||
|
||||
def cancelJob[F[_]: ConcurrentEffect](jobId: Ident, worker: Ident, store: Store[F], clientEc: ExecutionContext): F[Boolean] =
|
||||
for {
|
||||
node <- store.transact(RNode.findById(worker))
|
||||
cancel <- node.traverse(joexCancel(clientEc)(_, jobId))
|
||||
} yield cancel.getOrElse(false)
|
||||
|
||||
|
||||
private def joexCancel[F[_]: ConcurrentEffect](ec: ExecutionContext)(node: RNode, job: Ident): F[Boolean] = {
|
||||
val notifyUrl = node.url/"api"/"v1"/"job"/job.id/"cancel"
|
||||
BlazeClientBuilder[F](ec).resource.use { client =>
|
||||
val req = Request[F](POST, Uri.unsafeFromString(notifyUrl.asString))
|
||||
client.expect[String](req).map(_ => true)
|
||||
}
|
||||
}
|
||||
|
||||
private def notifyJoex[F[_]: ConcurrentEffect](ec: ExecutionContext)(node: RNode): F[Unit] = {
|
||||
val notifyUrl = node.url/"api"/"v1"/"notify"
|
||||
val execute = BlazeClientBuilder[F](ec).resource.use { client =>
|
||||
val req = Request[F](POST, Uri.unsafeFromString(notifyUrl.asString))
|
||||
client.expect[String](req).map(_ => ())
|
||||
}
|
||||
execute.attempt.map {
|
||||
case Right(_) =>
|
||||
()
|
||||
case Left(_) =>
|
||||
logger.warn(s"Notifying Joex instance '${node.id.id}/${node.url.asString}' failed.")
|
||||
()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.{Effect, Resource}
|
||||
import docspell.common._
|
||||
import docspell.store._
|
||||
import docspell.store.records._
|
||||
import OOrganization._
|
||||
import docspell.store.queries.QOrganization
|
||||
|
||||
trait OOrganization[F[_]] {
|
||||
def findAllOrg(account: AccountId): F[Vector[OrgAndContacts]]
|
||||
|
||||
def findAllOrgRefs(account: AccountId): F[Vector[IdRef]]
|
||||
|
||||
def addOrg(s: OrgAndContacts): F[AddResult]
|
||||
|
||||
def updateOrg(s: OrgAndContacts): F[AddResult]
|
||||
|
||||
def findAllPerson(account: AccountId): F[Vector[PersonAndContacts]]
|
||||
|
||||
def findAllPersonRefs(account: AccountId): F[Vector[IdRef]]
|
||||
|
||||
def addPerson(s: PersonAndContacts): F[AddResult]
|
||||
|
||||
def updatePerson(s: PersonAndContacts): F[AddResult]
|
||||
|
||||
def deleteOrg(orgId: Ident, collective: Ident): F[AddResult]
|
||||
|
||||
def deletePerson(personId: Ident, collective: Ident): F[AddResult]
|
||||
}
|
||||
|
||||
object OOrganization {
|
||||
|
||||
case class OrgAndContacts(org: ROrganization, contacts: Seq[RContact])
|
||||
|
||||
case class PersonAndContacts(person: RPerson, contacts: Seq[RContact])
|
||||
|
||||
def apply[F[_] : Effect](store: Store[F]): Resource[F, OOrganization[F]] =
|
||||
Resource.pure(new OOrganization[F] {
|
||||
|
||||
def findAllOrg(account: AccountId): F[Vector[OrgAndContacts]] =
|
||||
store.transact(QOrganization.findOrgAndContact(account.collective, _.name)).
|
||||
map({ case (org, cont) => OrgAndContacts(org, cont) }).
|
||||
compile.toVector
|
||||
|
||||
def findAllOrgRefs(account: AccountId): F[Vector[IdRef]] =
|
||||
store.transact(ROrganization.findAllRef(account.collective, _.name))
|
||||
|
||||
def addOrg(s: OrgAndContacts): F[AddResult] =
|
||||
QOrganization.addOrg(s.org, s.contacts, s.org.cid)(store)
|
||||
|
||||
def updateOrg(s: OrgAndContacts): F[AddResult] =
|
||||
QOrganization.updateOrg(s.org, s.contacts, s.org.cid)(store)
|
||||
|
||||
def findAllPerson(account: AccountId): F[Vector[PersonAndContacts]] =
|
||||
store.transact(QOrganization.findPersonAndContact(account.collective, _.name)).
|
||||
map({ case (person, cont) => PersonAndContacts(person, cont) }).
|
||||
compile.toVector
|
||||
|
||||
def findAllPersonRefs(account: AccountId): F[Vector[IdRef]] =
|
||||
store.transact(RPerson.findAllRef(account.collective, _.name))
|
||||
|
||||
def addPerson(s: PersonAndContacts): F[AddResult] =
|
||||
QOrganization.addPerson(s.person, s.contacts, s.person.cid)(store)
|
||||
|
||||
def updatePerson(s: PersonAndContacts): F[AddResult] =
|
||||
QOrganization.updatePerson(s.person, s.contacts, s.person.cid)(store)
|
||||
|
||||
def deleteOrg(orgId: Ident, collective: Ident): F[AddResult] =
|
||||
store.transact(QOrganization.deleteOrg(orgId, collective)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def deletePerson(personId: Ident, collective: Ident): F[AddResult] =
|
||||
store.transact(QOrganization.deletePerson(personId, collective)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
})
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.{Effect, Resource}
|
||||
import docspell.common.{AccountId, Ident}
|
||||
import docspell.store.{AddResult, Store}
|
||||
import docspell.store.records.RSource
|
||||
|
||||
trait OSource[F[_]] {
|
||||
|
||||
def findAll(account: AccountId): F[Vector[RSource]]
|
||||
|
||||
def add(s: RSource): F[AddResult]
|
||||
|
||||
def update(s: RSource): F[AddResult]
|
||||
|
||||
def delete(id: Ident, collective: Ident): F[AddResult]
|
||||
}
|
||||
|
||||
object OSource {
|
||||
|
||||
def apply[F[_]: Effect](store: Store[F]): Resource[F, OSource[F]] =
|
||||
Resource.pure(new OSource[F] {
|
||||
def findAll(account: AccountId): F[Vector[RSource]] =
|
||||
store.transact(RSource.findAll(account.collective, _.abbrev))
|
||||
|
||||
def add(s: RSource): F[AddResult] = {
|
||||
def insert = RSource.insert(s)
|
||||
def exists = RSource.existsByAbbrev(s.cid, s.abbrev)
|
||||
|
||||
val msg = s"A source with abbrev '${s.abbrev}' already exists"
|
||||
store.add(insert, exists).map(_.fold(identity, _.withMsg(msg), identity))
|
||||
}
|
||||
|
||||
def update(s: RSource): F[AddResult] = {
|
||||
def insert = RSource.updateNoCounter(s)
|
||||
def exists = RSource.existsByAbbrev(s.cid, s.abbrev)
|
||||
|
||||
val msg = s"A source with abbrev '${s.abbrev}' already exists"
|
||||
store.add(insert, exists).map(_.fold(identity, _.withMsg(msg), identity))
|
||||
}
|
||||
|
||||
def delete(id: Ident, collective: Ident): F[AddResult] =
|
||||
store.transact(RSource.delete(id, collective)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
})
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.{Effect, Resource}
|
||||
import docspell.common.{AccountId, Ident}
|
||||
import docspell.store.{AddResult, Store}
|
||||
import docspell.store.records.{RTag, RTagItem}
|
||||
|
||||
trait OTag[F[_]] {
|
||||
|
||||
def findAll(account: AccountId): F[Vector[RTag]]
|
||||
|
||||
def add(s: RTag): F[AddResult]
|
||||
|
||||
def update(s: RTag): F[AddResult]
|
||||
|
||||
def delete(id: Ident, collective: Ident): F[AddResult]
|
||||
}
|
||||
|
||||
|
||||
object OTag {
|
||||
|
||||
def apply[F[_]: Effect](store: Store[F]): Resource[F, OTag[F]] =
|
||||
Resource.pure(new OTag[F] {
|
||||
def findAll(account: AccountId): F[Vector[RTag]] =
|
||||
store.transact(RTag.findAll(account.collective, _.name))
|
||||
|
||||
def add(t: RTag): F[AddResult] = {
|
||||
def insert = RTag.insert(t)
|
||||
def exists = RTag.existsByName(t)
|
||||
|
||||
val msg = s"A tag '${t.name}' already exists"
|
||||
store.add(insert, exists).map(_.fold(identity, _.withMsg(msg), identity))
|
||||
}
|
||||
|
||||
def update(t: RTag): F[AddResult] = {
|
||||
def insert = RTag.update(t)
|
||||
def exists = RTag.existsByName(t)
|
||||
|
||||
val msg = s"A tag '${t.name}' already exists"
|
||||
store.add(insert, exists).map(_.fold(identity, _.withMsg(msg), identity))
|
||||
}
|
||||
|
||||
def delete(id: Ident, collective: Ident): F[AddResult] = {
|
||||
val io = for {
|
||||
optTag <- RTag.findByIdAndCollective(id, collective)
|
||||
n0 <- optTag.traverse(t => RTagItem.deleteTag(t.tagId))
|
||||
n1 <- optTag.traverse(t => RTag.delete(t.tagId, collective))
|
||||
} yield n0.getOrElse(0) + n1.getOrElse(0)
|
||||
store.transact(io).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -0,0 +1,103 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import bitpeace.MimetypeHint
|
||||
import cats.implicits._
|
||||
import cats.effect.{ConcurrentEffect, Effect, Resource}
|
||||
import docspell.backend.Config
|
||||
import fs2.Stream
|
||||
import docspell.common._
|
||||
import docspell.common.syntax.all._
|
||||
import docspell.store.Store
|
||||
import docspell.store.queue.JobQueue
|
||||
import docspell.store.records.{RCollective, RJob, RSource}
|
||||
import org.log4s._
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
trait OUpload[F[_]] {
|
||||
|
||||
def submit(data: OUpload.UploadData[F], account: AccountId): F[OUpload.UploadResult]
|
||||
|
||||
def submit(data: OUpload.UploadData[F], sourceId: Ident): F[OUpload.UploadResult]
|
||||
}
|
||||
|
||||
object OUpload {
|
||||
private [this] val logger = getLogger
|
||||
|
||||
case class File[F[_]](name: Option[String], advertisedMime: Option[MimeType], data: Stream[F, Byte])
|
||||
|
||||
case class UploadMeta( direction: Option[Direction]
|
||||
, sourceAbbrev: String
|
||||
, validFileTypes: Seq[MimeType])
|
||||
|
||||
case class UploadData[F[_]]( multiple: Boolean
|
||||
, meta: UploadMeta
|
||||
, files: Vector[File[F]], priority: Priority, tracker: Option[Ident])
|
||||
|
||||
sealed trait UploadResult
|
||||
object UploadResult {
|
||||
case object Success extends UploadResult
|
||||
case object NoFiles extends UploadResult
|
||||
case object NoSource extends UploadResult
|
||||
}
|
||||
|
||||
def apply[F[_]: ConcurrentEffect](store: Store[F], queue: JobQueue[F], cfg: Config, httpClientEC: ExecutionContext): Resource[F, OUpload[F]] =
|
||||
Resource.pure(new OUpload[F] {
|
||||
|
||||
def submit(data: OUpload.UploadData[F], account: AccountId): F[OUpload.UploadResult] = {
|
||||
for {
|
||||
files <- data.files.traverse(saveFile).map(_.flatten)
|
||||
pred <- checkFileList(files)
|
||||
lang <- store.transact(RCollective.findLanguage(account.collective))
|
||||
meta = ProcessItemArgs.ProcessMeta(account.collective, lang.getOrElse(Language.German), data.meta.direction, data.meta.sourceAbbrev, data.meta.validFileTypes)
|
||||
args = if (data.multiple) files.map(f => ProcessItemArgs(meta, List(f))) else Vector(ProcessItemArgs(meta, files.toList))
|
||||
job <- pred.traverse(_ => makeJobs(args, account, data.priority, data.tracker))
|
||||
_ <- logger.fdebug(s"Storing jobs: $job")
|
||||
res <- job.traverse(submitJobs)
|
||||
_ <- store.transact(RSource.incrementCounter(data.meta.sourceAbbrev, account.collective))
|
||||
} yield res.fold(identity, identity)
|
||||
}
|
||||
|
||||
def submit(data: OUpload.UploadData[F], sourceId: Ident): F[OUpload.UploadResult] =
|
||||
for {
|
||||
sOpt <- store.transact(RSource.find(sourceId)).map(_.toRight(UploadResult.NoSource))
|
||||
abbrev = sOpt.map(_.abbrev).toOption.getOrElse(data.meta.sourceAbbrev)
|
||||
updata = data.copy(meta = data.meta.copy(sourceAbbrev = abbrev))
|
||||
accId = sOpt.map(source => AccountId(source.cid, source.sid))
|
||||
result <- accId.traverse(acc => submit(updata, acc))
|
||||
} yield result.fold(identity, identity)
|
||||
|
||||
private def submitJobs(jobs: Vector[RJob]): F[OUpload.UploadResult] = {
|
||||
for {
|
||||
_ <- logger.fdebug(s"Storing jobs: $jobs")
|
||||
_ <- queue.insertAll(jobs)
|
||||
_ <- OJoex.notifyAll(store, httpClientEC)
|
||||
} yield UploadResult.Success
|
||||
}
|
||||
|
||||
private def saveFile(file: File[F]): F[Option[ProcessItemArgs.File]] = {
|
||||
logger.finfo(s"Receiving file $file") *>
|
||||
store.bitpeace.saveNew(file.data, cfg.files.chunkSize, MimetypeHint(file.name, None), None).
|
||||
compile.lastOrError.map(fm => Ident.unsafe(fm.id)).attempt.
|
||||
map(_.fold(ex => {
|
||||
logger.warn(ex)(s"Could not store file for processing!")
|
||||
None
|
||||
}, id => Some(ProcessItemArgs.File(file.name, id))))
|
||||
}
|
||||
|
||||
private def checkFileList(files: Seq[ProcessItemArgs.File]): F[Either[UploadResult, Unit]] =
|
||||
Effect[F].pure(if (files.isEmpty) Left(UploadResult.NoFiles) else Right(()))
|
||||
|
||||
private def makeJobs(args: Vector[ProcessItemArgs], account: AccountId, prio: Priority, tracker: Option[Ident]): F[Vector[RJob]] = {
|
||||
def create(id: Ident, now: Timestamp, arg: ProcessItemArgs): RJob =
|
||||
RJob.newJob(id, ProcessItemArgs.taskName, account.collective, arg, arg.makeSubject, now, account.user, prio, tracker)
|
||||
|
||||
for {
|
||||
id <- Ident.randomId[F]
|
||||
now <- Timestamp.current[F]
|
||||
jobs = args.map(a => create(id, now, a))
|
||||
} yield jobs
|
||||
|
||||
}
|
||||
})
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
package docspell.backend.signup
|
||||
|
||||
import docspell.common.{Duration, Password}
|
||||
import io.circe._
|
||||
|
||||
case class Config(mode: Config.Mode, newInvitePassword: Password, inviteTime: Duration)
|
||||
|
||||
object Config {
|
||||
sealed trait Mode { self: Product =>
|
||||
final def name: String =
|
||||
productPrefix.toLowerCase
|
||||
}
|
||||
object Mode {
|
||||
|
||||
case object Open extends Mode
|
||||
|
||||
case object Invite extends Mode
|
||||
|
||||
case object Closed extends Mode
|
||||
|
||||
def fromString(str: String): Either[String, Mode] =
|
||||
str.toLowerCase match {
|
||||
case "open" => Right(Open)
|
||||
case "invite" => Right(Invite)
|
||||
case "closed" => Right(Closed)
|
||||
case _ => Left(s"Invalid signup mode: $str")
|
||||
}
|
||||
def unsafe(str: String): Mode =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
implicit val jsonEncoder: Encoder[Mode] =
|
||||
Encoder.encodeString.contramap(_.name)
|
||||
implicit val jsonDecoder: Decoder[Mode] =
|
||||
Decoder.decodeString.emap(fromString)
|
||||
}
|
||||
|
||||
def open: Mode = Mode.Open
|
||||
def invite: Mode = Mode.Invite
|
||||
def closed: Mode = Mode.Closed
|
||||
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
package docspell.backend.signup
|
||||
|
||||
import docspell.common.Ident
|
||||
|
||||
sealed trait NewInviteResult { self: Product =>
|
||||
|
||||
final def name: String =
|
||||
productPrefix.toLowerCase
|
||||
}
|
||||
|
||||
object NewInviteResult {
|
||||
case class Success(id: Ident) extends NewInviteResult
|
||||
case object InvitationDisabled extends NewInviteResult
|
||||
case object PasswordMismatch extends NewInviteResult
|
||||
|
||||
def passwordMismatch: NewInviteResult = PasswordMismatch
|
||||
def invitationClosed: NewInviteResult = InvitationDisabled
|
||||
def success(id: Ident): NewInviteResult = Success(id)
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
package docspell.backend.signup
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.{Effect, Resource}
|
||||
import docspell.backend.PasswordCrypt
|
||||
import docspell.backend.ops.OCollective.RegisterData
|
||||
import docspell.common._
|
||||
import docspell.store.{AddResult, Store}
|
||||
import docspell.store.records.{RCollective, RInvitation, RUser}
|
||||
import doobie.free.connection.ConnectionIO
|
||||
|
||||
trait OSignup[F[_]] {
|
||||
|
||||
def register(cfg: Config)(data: RegisterData): F[SignupResult]
|
||||
|
||||
def newInvite(cfg: Config)(password: Password): F[NewInviteResult]
|
||||
}
|
||||
|
||||
object OSignup {
|
||||
|
||||
def apply[F[_]:Effect](store: Store[F]): Resource[F, OSignup[F]] =
|
||||
Resource.pure(new OSignup[F] {
|
||||
|
||||
def newInvite(cfg: Config)(password: Password): F[NewInviteResult] = {
|
||||
if (cfg.mode == Config.Mode.Invite) {
|
||||
if (cfg.newInvitePassword.isEmpty || cfg.newInvitePassword != password) NewInviteResult.passwordMismatch.pure[F]
|
||||
else store.transact(RInvitation.insertNew).map(ri => NewInviteResult.success(ri.id))
|
||||
} else {
|
||||
Effect[F].pure(NewInviteResult.invitationClosed)
|
||||
}
|
||||
}
|
||||
|
||||
def register(cfg: Config)(data: RegisterData): F[SignupResult] = {
|
||||
cfg.mode match {
|
||||
case Config.Mode.Open =>
|
||||
addUser(data).map(SignupResult.fromAddResult)
|
||||
|
||||
case Config.Mode.Closed =>
|
||||
SignupResult.signupClosed.pure[F]
|
||||
|
||||
case Config.Mode.Invite =>
|
||||
data.invite match {
|
||||
case Some(inv) =>
|
||||
for {
|
||||
now <- Timestamp.current[F]
|
||||
min = now.minus(cfg.inviteTime)
|
||||
ok <- store.transact(RInvitation.useInvite(inv, min))
|
||||
res <- if (ok) addUser(data).map(SignupResult.fromAddResult)
|
||||
else SignupResult.invalidInvitationKey.pure[F]
|
||||
} yield res
|
||||
case None =>
|
||||
SignupResult.invalidInvitationKey.pure[F]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private def addUser(data: RegisterData): F[AddResult] = {
|
||||
def toRecords: F[(RCollective, RUser)] =
|
||||
for {
|
||||
id2 <- Ident.randomId[F]
|
||||
now <- Timestamp.current[F]
|
||||
c = RCollective(data.collName, CollectiveState.Active, Language.German, now)
|
||||
u = RUser(id2, data.login, data.collName, PasswordCrypt.crypt(data.password), UserState.Active, None, 0, None, now)
|
||||
} yield (c, u)
|
||||
|
||||
def insert(coll: RCollective, user: RUser): ConnectionIO[Int] = {
|
||||
for {
|
||||
n1 <- RCollective.insert(coll)
|
||||
n2 <- RUser.insert(user)
|
||||
} yield n1 + n2
|
||||
}
|
||||
|
||||
def collectiveExists: ConnectionIO[Boolean] =
|
||||
RCollective.existsById(data.collName)
|
||||
|
||||
val msg = s"The collective '${data.collName}' already exists."
|
||||
for {
|
||||
cu <- toRecords
|
||||
save <- store.add(insert(cu._1, cu._2), collectiveExists)
|
||||
} yield save.fold(identity, _.withMsg(msg), identity)
|
||||
}
|
||||
})
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
package docspell.backend.signup
|
||||
|
||||
import docspell.store.AddResult
|
||||
|
||||
sealed trait SignupResult {
|
||||
|
||||
}
|
||||
|
||||
object SignupResult {
|
||||
|
||||
case object CollectiveExists extends SignupResult
|
||||
case object InvalidInvitationKey extends SignupResult
|
||||
case object SignupClosed extends SignupResult
|
||||
case class Failure(ex: Throwable) extends SignupResult
|
||||
case object Success extends SignupResult
|
||||
|
||||
def collectiveExists: SignupResult = CollectiveExists
|
||||
def invalidInvitationKey: SignupResult = InvalidInvitationKey
|
||||
def signupClosed: SignupResult = SignupClosed
|
||||
def failure(ex: Throwable): SignupResult = Failure(ex)
|
||||
def success: SignupResult = Success
|
||||
|
||||
def fromAddResult(ar: AddResult): SignupResult = ar match {
|
||||
case AddResult.Success => Success
|
||||
case AddResult.Failure(ex) => Failure(ex)
|
||||
case AddResult.EntityExists(_) => CollectiveExists
|
||||
}
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
package docspell.common
|
||||
|
||||
case class AccountId(collective: Ident, user: Ident) {
|
||||
|
||||
def asString =
|
||||
s"${collective.id}/${user.id}"
|
||||
}
|
||||
|
||||
object AccountId {
|
||||
private[this] val sepearatorChars: String = "/\\:"
|
||||
|
||||
def parse(str: String): Either[String, AccountId] = {
|
||||
val input = str.replaceAll("\\s+", "").trim
|
||||
val invalid: Either[String, AccountId] =
|
||||
Left(s"Cannot parse account id: $str")
|
||||
|
||||
def parse0(sep: Char): Either[String, AccountId] =
|
||||
input.indexOf(sep.toInt) match {
|
||||
case n if n > 0 && input.length > 2 =>
|
||||
val coll = input.substring(0, n)
|
||||
val user = input.substring(n + 1)
|
||||
Ident.fromString(coll).
|
||||
flatMap(collId => Ident.fromString(user).
|
||||
map(userId => AccountId(collId, userId)))
|
||||
case _ =>
|
||||
invalid
|
||||
}
|
||||
|
||||
val separated = sepearatorChars.foldRight(invalid) { (c, v) =>
|
||||
v.orElse(parse0(c))
|
||||
}
|
||||
|
||||
separated.orElse(Ident.fromString(str).map(id => AccountId(id, id)))
|
||||
}
|
||||
}
|
34
modules/common/src/main/scala/docspell/common/Banner.scala
Normal file
34
modules/common/src/main/scala/docspell/common/Banner.scala
Normal file
@ -0,0 +1,34 @@
|
||||
package docspell.common
|
||||
|
||||
case class Banner( component: String
|
||||
, version: String
|
||||
, gitHash: Option[String]
|
||||
, jdbcUrl: LenientUri
|
||||
, configFile: Option[String]
|
||||
, appId: Ident
|
||||
, baseUrl: LenientUri) {
|
||||
|
||||
private val banner =
|
||||
"""______ _ _
|
||||
|| _ \ | | |
|
||||
|| | | |___ ___ ___ _ __ ___| | |
|
||||
|| | | / _ \ / __/ __| '_ \ / _ \ | |
|
||||
|| |/ / (_) | (__\__ \ |_) | __/ | |
|
||||
||___/ \___/ \___|___/ .__/ \___|_|_|
|
||||
| | |
|
||||
|""".stripMargin +
|
||||
s""" |_| v$version (#${gitHash.map(_.take(8)).getOrElse("")})"""
|
||||
|
||||
def render(prefix: String): String = {
|
||||
val text = banner.split('\n').toList ++ List(
|
||||
s"<< $component >>"
|
||||
, s"Id: ${appId.id}"
|
||||
, s"Base-Url: ${baseUrl.asString}"
|
||||
, s"Database: ${jdbcUrl.asString}"
|
||||
, s"Config: ${configFile.getOrElse("")}"
|
||||
, ""
|
||||
)
|
||||
|
||||
text.map(line => s"$prefix $line").mkString("\n")
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
package docspell.common
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
import io.circe._
|
||||
|
||||
object BaseJsonCodecs {
|
||||
|
||||
implicit val encodeInstantEpoch: Encoder[Instant] =
|
||||
Encoder.encodeJavaLong.contramap(_.toEpochMilli)
|
||||
|
||||
implicit val decodeInstantEpoch: Decoder[Instant] =
|
||||
Decoder.decodeLong.map(Instant.ofEpochMilli)
|
||||
|
||||
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
package docspell.common
|
||||
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
sealed trait CollectiveState
|
||||
object CollectiveState {
|
||||
val all = List(Active, ReadOnly, Closed, Blocked)
|
||||
|
||||
/** A normal active collective */
|
||||
case object Active extends CollectiveState
|
||||
|
||||
/** A collective may be readonly in cases it is implicitly closed
|
||||
* (e.g. no payment). Users can still see there data and
|
||||
* download, but have no write access. */
|
||||
case object ReadOnly extends CollectiveState
|
||||
|
||||
/** A collective that has been explicitely closed. */
|
||||
case object Closed extends CollectiveState
|
||||
|
||||
/** A collective blocked by a super user, usually some emergency
|
||||
* action. */
|
||||
case object Blocked extends CollectiveState
|
||||
|
||||
|
||||
def fromString(s: String): Either[String, CollectiveState] =
|
||||
s.toLowerCase match {
|
||||
case "active" => Right(Active)
|
||||
case "readonly" => Right(ReadOnly)
|
||||
case "closed" => Right(Closed)
|
||||
case "blocked" => Right(Blocked)
|
||||
case _ => Left(s"Unknown state: $s")
|
||||
}
|
||||
|
||||
def unsafe(str: String): CollectiveState =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
def asString(state: CollectiveState): String = state match {
|
||||
case Active => "active"
|
||||
case Blocked => "blocked"
|
||||
case Closed => "closed"
|
||||
case ReadOnly => "readonly"
|
||||
}
|
||||
|
||||
|
||||
|
||||
implicit val collectiveStateEncoder: Encoder[CollectiveState] =
|
||||
Encoder.encodeString.contramap(CollectiveState.asString)
|
||||
|
||||
implicit val collectiveStateDecoder: Decoder[CollectiveState] =
|
||||
Decoder.decodeString.emap(CollectiveState.fromString)
|
||||
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
package docspell.common
|
||||
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
sealed trait ContactKind { self: Product =>
|
||||
|
||||
def asString: String = self.productPrefix
|
||||
}
|
||||
|
||||
object ContactKind {
|
||||
val all = List()
|
||||
|
||||
case object Phone extends ContactKind
|
||||
case object Mobile extends ContactKind
|
||||
case object Fax extends ContactKind
|
||||
case object Email extends ContactKind
|
||||
case object Docspell extends ContactKind
|
||||
case object Website extends ContactKind
|
||||
|
||||
def fromString(s: String): Either[String, ContactKind] =
|
||||
s.toLowerCase match {
|
||||
case "phone" => Right(Phone)
|
||||
case "mobile" => Right(Mobile)
|
||||
case "fax" => Right(Fax)
|
||||
case "email" => Right(Email)
|
||||
case "docspell" => Right(Docspell)
|
||||
case "website" => Right(Website)
|
||||
case _ => Left(s"Not a state value: $s")
|
||||
}
|
||||
|
||||
def unsafe(str: String): ContactKind =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
def asString(s: ContactKind): String =
|
||||
s.asString.toLowerCase
|
||||
|
||||
|
||||
implicit val contactKindEncoder: Encoder[ContactKind] =
|
||||
Encoder.encodeString.contramap(_.asString)
|
||||
|
||||
implicit val contactKindDecoder: Decoder[ContactKind] =
|
||||
Decoder.decodeString.emap(ContactKind.fromString)
|
||||
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
package docspell.common
|
||||
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
sealed trait Direction {
|
||||
self: Product =>
|
||||
|
||||
def name: String =
|
||||
productPrefix.toLowerCase
|
||||
}
|
||||
|
||||
object Direction {
|
||||
|
||||
case object Incoming extends Direction
|
||||
case object Outgoing extends Direction
|
||||
|
||||
def incoming: Direction = Incoming
|
||||
def outgoing: Direction = Outgoing
|
||||
|
||||
def parse(str: String): Either[String, Direction] =
|
||||
str.toLowerCase match {
|
||||
case "incoming" => Right(Incoming)
|
||||
case "outgoing" => Right(Outgoing)
|
||||
case _ => Left(s"No direction: $str")
|
||||
}
|
||||
|
||||
def unsafe(str: String): Direction =
|
||||
parse(str).fold(sys.error, identity)
|
||||
|
||||
def isIncoming(dir: Direction): Boolean =
|
||||
dir == Direction.Incoming
|
||||
|
||||
def isOutgoing(dir: Direction): Boolean =
|
||||
dir == Direction.Outgoing
|
||||
|
||||
implicit val directionEncoder: Encoder[Direction] =
|
||||
Encoder.encodeString.contramap(_.name)
|
||||
|
||||
implicit val directionDecoder: Decoder[Direction] =
|
||||
Decoder.decodeString.emap(Direction.parse)
|
||||
|
||||
}
|
54
modules/common/src/main/scala/docspell/common/Duration.scala
Normal file
54
modules/common/src/main/scala/docspell/common/Duration.scala
Normal file
@ -0,0 +1,54 @@
|
||||
package docspell.common
|
||||
|
||||
import cats.implicits._
|
||||
import scala.concurrent.duration.{FiniteDuration, Duration => SDur}
|
||||
import java.time.{Duration => JDur}
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
import cats.effect.Sync
|
||||
|
||||
case class Duration(nanos: Long) {
|
||||
|
||||
def millis: Long = nanos / 1000000
|
||||
|
||||
def seconds: Long = millis / 1000
|
||||
|
||||
def toScala: FiniteDuration =
|
||||
FiniteDuration(nanos, TimeUnit.NANOSECONDS)
|
||||
|
||||
def toJava: JDur =
|
||||
JDur.ofNanos(nanos)
|
||||
|
||||
def formatExact: String =
|
||||
s"$millis ms"
|
||||
}
|
||||
|
||||
object Duration {
|
||||
|
||||
def apply(d: SDur): Duration =
|
||||
Duration(d.toNanos)
|
||||
|
||||
def apply(d: JDur): Duration =
|
||||
Duration(d.toNanos)
|
||||
|
||||
def seconds(n: Long): Duration =
|
||||
apply(JDur.ofSeconds(n))
|
||||
|
||||
def millis(n: Long): Duration =
|
||||
apply(JDur.ofMillis(n))
|
||||
|
||||
def minutes(n: Long): Duration =
|
||||
apply(JDur.ofMinutes(n))
|
||||
|
||||
def hours(n: Long): Duration =
|
||||
apply(JDur.ofHours(n))
|
||||
|
||||
def nanos(n: Long): Duration =
|
||||
Duration(n)
|
||||
|
||||
def stopTime[F[_]: Sync]: F[F[Duration]] =
|
||||
for {
|
||||
now <- Timestamp.current[F]
|
||||
end = Timestamp.current[F]
|
||||
} yield end.map(e => Duration.millis(e.toMillis - now.toMillis))
|
||||
}
|
16
modules/common/src/main/scala/docspell/common/IdRef.scala
Normal file
16
modules/common/src/main/scala/docspell/common/IdRef.scala
Normal file
@ -0,0 +1,16 @@
|
||||
package docspell.common
|
||||
|
||||
import io.circe._
|
||||
import io.circe.generic.semiauto._
|
||||
|
||||
case class IdRef(id: Ident, name: String) {
|
||||
|
||||
}
|
||||
|
||||
object IdRef {
|
||||
|
||||
implicit val jsonEncoder: Encoder[IdRef] =
|
||||
deriveEncoder[IdRef]
|
||||
implicit val jsonDecoder: Decoder[IdRef] =
|
||||
deriveDecoder[IdRef]
|
||||
}
|
57
modules/common/src/main/scala/docspell/common/Ident.scala
Normal file
57
modules/common/src/main/scala/docspell/common/Ident.scala
Normal file
@ -0,0 +1,57 @@
|
||||
package docspell.common
|
||||
|
||||
import java.security.SecureRandom
|
||||
import java.util.UUID
|
||||
|
||||
import cats.effect.Sync
|
||||
import io.circe.{Decoder, Encoder}
|
||||
import scodec.bits.ByteVector
|
||||
|
||||
case class Ident(id: String) {
|
||||
}
|
||||
|
||||
object Ident {
|
||||
|
||||
val chars: Set[Char] = (('A' to 'Z') ++ ('a' to 'z') ++ ('0' to '9') ++ "-_").toSet
|
||||
|
||||
def randomUUID[F[_]: Sync]: F[Ident] =
|
||||
Sync[F].delay(unsafe(UUID.randomUUID.toString))
|
||||
|
||||
def randomId[F[_]: Sync]: F[Ident] = Sync[F].delay {
|
||||
val random = new SecureRandom()
|
||||
val buffer = new Array[Byte](32)
|
||||
random.nextBytes(buffer)
|
||||
unsafe(ByteVector.view(buffer).toBase58.grouped(11).mkString("-"))
|
||||
}
|
||||
|
||||
def apply(str: String): Either[String, Ident] =
|
||||
fromString(str)
|
||||
|
||||
def fromString(s: String): Either[String, Ident] =
|
||||
if (s.forall(chars.contains)) Right(new Ident(s))
|
||||
else Left(s"Invalid identifier: $s. Allowed chars: ${chars.mkString}")
|
||||
|
||||
def fromBytes(bytes: ByteVector): Ident =
|
||||
unsafe(bytes.toBase58)
|
||||
|
||||
def fromByteArray(bytes: Array[Byte]): Ident =
|
||||
fromBytes(ByteVector.view(bytes))
|
||||
|
||||
def unsafe(s: String): Ident =
|
||||
fromString(s) match {
|
||||
case Right(id) => id
|
||||
case Left(err) => sys.error(err)
|
||||
}
|
||||
|
||||
def unapply(arg: String): Option[Ident] =
|
||||
fromString(arg).toOption
|
||||
|
||||
|
||||
|
||||
implicit val encodeIdent: Encoder[Ident] =
|
||||
Encoder.encodeString.contramap(_.id)
|
||||
|
||||
implicit val decodeIdent: Decoder[Ident] =
|
||||
Decoder.decodeString.emap(Ident.fromString)
|
||||
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
package docspell.common
|
||||
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
sealed trait ItemState { self: Product =>
|
||||
|
||||
final def name: String =
|
||||
productPrefix.toLowerCase
|
||||
}
|
||||
|
||||
object ItemState {
|
||||
|
||||
case object Premature extends ItemState
|
||||
case object Processing extends ItemState
|
||||
case object Created extends ItemState
|
||||
case object Confirmed extends ItemState
|
||||
|
||||
def fromString(str: String): Either[String, ItemState] =
|
||||
str.toLowerCase match {
|
||||
case "premature" => Right(Premature)
|
||||
case "processing" => Right(Processing)
|
||||
case "created" => Right(Created)
|
||||
case "confirmed" => Right(Confirmed)
|
||||
case _ => Left(s"Invalid item state: $str")
|
||||
}
|
||||
|
||||
def unsafe(str: String): ItemState =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
implicit val jsonDecoder: Decoder[ItemState] =
|
||||
Decoder.decodeString.emap(fromString)
|
||||
implicit val jsonEncoder: Encoder[ItemState] =
|
||||
Encoder.encodeString.contramap(_.name)
|
||||
}
|
||||
|
70
modules/common/src/main/scala/docspell/common/JobState.scala
Normal file
70
modules/common/src/main/scala/docspell/common/JobState.scala
Normal file
@ -0,0 +1,70 @@
|
||||
package docspell.common
|
||||
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
sealed trait JobState { self: Product =>
|
||||
def name: String =
|
||||
productPrefix.toLowerCase
|
||||
}
|
||||
|
||||
object JobState {
|
||||
|
||||
/** Waiting for being executed. */
|
||||
case object Waiting extends JobState {
|
||||
}
|
||||
|
||||
/** A scheduler has picked up this job and will pass it to the next
|
||||
* free slot. */
|
||||
case object Scheduled extends JobState {
|
||||
}
|
||||
|
||||
/** Is currently executing */
|
||||
case object Running extends JobState {
|
||||
}
|
||||
|
||||
/** Finished with failure and is being retried. */
|
||||
case object Stuck extends JobState {
|
||||
}
|
||||
|
||||
/** Finished finally with a failure */
|
||||
case object Failed extends JobState {
|
||||
}
|
||||
|
||||
/** Finished by cancellation. */
|
||||
case object Cancelled extends JobState {
|
||||
}
|
||||
|
||||
/** Finished with success */
|
||||
case object Success extends JobState {
|
||||
}
|
||||
|
||||
val all: Set[JobState] = Set(Waiting, Scheduled, Running, Stuck, Failed, Cancelled, Success)
|
||||
val queued: Set[JobState] = Set(Waiting, Scheduled, Stuck)
|
||||
val done: Set[JobState] = Set(Failed, Cancelled, Success)
|
||||
|
||||
def parse(str: String): Either[String, JobState] =
|
||||
str.toLowerCase match {
|
||||
case "waiting" => Right(Waiting)
|
||||
case "scheduled" => Right(Scheduled)
|
||||
case "running" => Right(Running)
|
||||
case "stuck" => Right(Stuck)
|
||||
case "failed" => Right(Failed)
|
||||
case "cancelled" => Right(Cancelled)
|
||||
case "success" => Right(Success)
|
||||
case _ => Left(s"Not a job state: $str")
|
||||
}
|
||||
|
||||
def unsafe(str: String): JobState =
|
||||
parse(str).fold(sys.error, identity)
|
||||
|
||||
def asString(state: JobState): String =
|
||||
state.name
|
||||
|
||||
|
||||
implicit val jobStateEncoder: Encoder[JobState] =
|
||||
Encoder.encodeString.contramap(_.name)
|
||||
|
||||
implicit val jobStateDecoder: Decoder[JobState] =
|
||||
Decoder.decodeString.emap(JobState.parse)
|
||||
|
||||
}
|
46
modules/common/src/main/scala/docspell/common/Language.scala
Normal file
46
modules/common/src/main/scala/docspell/common/Language.scala
Normal file
@ -0,0 +1,46 @@
|
||||
package docspell.common
|
||||
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
sealed trait Language { self: Product =>
|
||||
|
||||
final def name: String =
|
||||
productPrefix.toLowerCase
|
||||
|
||||
def iso2: String
|
||||
|
||||
def iso3: String
|
||||
|
||||
private[common] def allNames =
|
||||
Set(name, iso3, iso2)
|
||||
}
|
||||
|
||||
object Language {
|
||||
|
||||
case object German extends Language {
|
||||
val iso2 = "de"
|
||||
val iso3 = "deu"
|
||||
}
|
||||
|
||||
case object English extends Language {
|
||||
val iso2 = "en"
|
||||
val iso3 = "eng"
|
||||
}
|
||||
|
||||
val all: List[Language] = List(German, English)
|
||||
|
||||
def fromString(str: String): Either[String, Language] = {
|
||||
val lang = str.toLowerCase
|
||||
all.find(_.allNames.contains(lang)).
|
||||
toRight(s"Unsupported or invalid language: $str")
|
||||
}
|
||||
|
||||
def unsafe(str: String): Language =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
|
||||
implicit val jsonDecoder: Decoder[Language] =
|
||||
Decoder.decodeString.emap(fromString)
|
||||
implicit val jsonEncoder: Encoder[Language] =
|
||||
Encoder.encodeString.contramap(_.iso3)
|
||||
}
|
186
modules/common/src/main/scala/docspell/common/LenientUri.scala
Normal file
186
modules/common/src/main/scala/docspell/common/LenientUri.scala
Normal file
@ -0,0 +1,186 @@
|
||||
package docspell.common
|
||||
|
||||
import java.net.URL
|
||||
|
||||
import fs2.Stream
|
||||
import cats.implicits._
|
||||
import cats.data.NonEmptyList
|
||||
import cats.effect.{Blocker, ContextShift, Sync}
|
||||
import docspell.common.LenientUri.Path
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
/** A URI.
|
||||
*
|
||||
* It is not compliant to rfc3986, but covers most use cases in a convenient way.
|
||||
*/
|
||||
case class LenientUri(scheme: NonEmptyList[String]
|
||||
, authority: Option[String]
|
||||
, path: LenientUri.Path
|
||||
, query: Option[String]
|
||||
, fragment: Option[String]) {
|
||||
|
||||
def /(segment: String): LenientUri =
|
||||
copy(path = path / segment)
|
||||
|
||||
def ++ (np: Path): LenientUri =
|
||||
copy(path = np.segments.foldLeft(path)(_ / _))
|
||||
|
||||
def ++ (np: String): LenientUri = {
|
||||
val rel = LenientUri.stripLeading(np, '/')
|
||||
++(LenientUri.unsafe(s"a:$rel").path)
|
||||
}
|
||||
|
||||
def toJavaUrl: Either[String, URL] =
|
||||
Either.catchNonFatal(new URL(asString)).left.map(_.getMessage)
|
||||
|
||||
def readURL[F[_]: Sync : ContextShift](chunkSize: Int, blocker: Blocker): Stream[F, Byte] =
|
||||
Stream.emit(Either.catchNonFatal(new URL(asString))).
|
||||
covary[F].
|
||||
rethrow.
|
||||
flatMap(url => fs2.io.readInputStream(Sync[F].delay(url.openStream()), chunkSize, blocker, true))
|
||||
|
||||
def asString: String = {
|
||||
val schemePart = scheme.toList.mkString(":")
|
||||
val authPart = authority.map(a => s"//$a").getOrElse("")
|
||||
val pathPart = path.asString
|
||||
val queryPart = query.map(q => s"?$q").getOrElse("")
|
||||
val fragPart = fragment.map(f => s"#$f").getOrElse("")
|
||||
s"$schemePart:$authPart$pathPart$queryPart$fragPart"
|
||||
}
|
||||
}
|
||||
|
||||
object LenientUri {
|
||||
|
||||
sealed trait Path {
|
||||
def segments: List[String]
|
||||
def isRoot: Boolean
|
||||
def isEmpty: Boolean
|
||||
def /(segment: String): Path
|
||||
def asString: String
|
||||
}
|
||||
case object RootPath extends Path {
|
||||
val segments = Nil
|
||||
val isRoot = true
|
||||
val isEmpty = false
|
||||
def /(seg: String): Path =
|
||||
NonEmptyPath(NonEmptyList.of(seg))
|
||||
def asString = "/"
|
||||
}
|
||||
case object EmptyPath extends Path {
|
||||
val segments = Nil
|
||||
val isRoot = false
|
||||
val isEmpty = true
|
||||
def /(seg: String): Path =
|
||||
NonEmptyPath(NonEmptyList.of(seg))
|
||||
def asString = ""
|
||||
}
|
||||
case class NonEmptyPath(segs: NonEmptyList[String]) extends Path {
|
||||
def segments = segs.toList
|
||||
val isEmpty = false
|
||||
val isRoot = false
|
||||
def /(seg: String): Path =
|
||||
copy(segs = segs.append(seg))
|
||||
def asString = segs.head match {
|
||||
case "." => segments.map(percentEncode).mkString("/")
|
||||
case ".." => segments.map(percentEncode).mkString("/")
|
||||
case _ => "/" + segments.map(percentEncode).mkString("/")
|
||||
}
|
||||
}
|
||||
|
||||
def unsafe(str: String): LenientUri =
|
||||
parse(str).fold(sys.error, identity)
|
||||
|
||||
def fromJava(u: URL): LenientUri =
|
||||
unsafe(u.toExternalForm)
|
||||
|
||||
def parse(str: String): Either[String, LenientUri] = {
|
||||
def makePath(str: String): Path = str.trim match {
|
||||
case "/" => RootPath
|
||||
case "" => EmptyPath
|
||||
case _ => NonEmptyList.fromList(stripLeading(str, '/').split('/').toList.map(percentDecode)) match {
|
||||
case Some(nl) => NonEmptyPath(nl)
|
||||
case None => sys.error(s"Invalid url: $str")
|
||||
}
|
||||
}
|
||||
|
||||
def makeNonEmpty(str: String): Option[String] =
|
||||
Option(str).filter(_.nonEmpty)
|
||||
def makeScheme(s: String): Option[NonEmptyList[String]] =
|
||||
NonEmptyList.fromList(s.split(':').toList.filter(_.nonEmpty).map(_.toLowerCase))
|
||||
|
||||
def splitPathQF(pqf: String): (Path, Option[String], Option[String]) =
|
||||
pqf.indexOf('?') match {
|
||||
case -1 =>
|
||||
pqf.indexOf('#') match {
|
||||
case -1 =>
|
||||
(makePath(pqf), None, None)
|
||||
case n =>
|
||||
(makePath(pqf.substring(0, n)), None, makeNonEmpty(pqf.substring(n + 1)))
|
||||
}
|
||||
case n =>
|
||||
pqf.indexOf('#', n) match {
|
||||
case -1 =>
|
||||
(makePath(pqf.substring(0, n)), makeNonEmpty(pqf.substring(n+1)), None)
|
||||
case k =>
|
||||
(makePath(pqf.substring(0, n)), makeNonEmpty(pqf.substring(n+1, k)), makeNonEmpty(pqf.substring(k+1)))
|
||||
}
|
||||
}
|
||||
|
||||
str.split("//", 2) match {
|
||||
case Array(p0, p1) =>
|
||||
// scheme:scheme:authority/path
|
||||
val scheme = makeScheme(p0)
|
||||
val (auth, pathQF) = p1.indexOf('/') match {
|
||||
case -1 => (Some(p1), "")
|
||||
case n => (Some(p1.substring(0, n)), p1.substring(n))
|
||||
}
|
||||
val (path, query, frag) = splitPathQF(pathQF)
|
||||
scheme match {
|
||||
case None =>
|
||||
Left(s"No scheme found: $str")
|
||||
case Some(nl) =>
|
||||
Right(LenientUri(nl, auth, path, query, frag))
|
||||
}
|
||||
case Array(p0) =>
|
||||
// scheme:scheme:path
|
||||
p0.lastIndexOf(':') match {
|
||||
case -1 =>
|
||||
Left(s"No scheme found: $str")
|
||||
case n =>
|
||||
val scheme = makeScheme(p0.substring(0, n))
|
||||
val (path, query, frag) = splitPathQF(p0.substring(n + 1))
|
||||
scheme match {
|
||||
case None =>
|
||||
Left(s"No scheme found: $str")
|
||||
case Some(nl) =>
|
||||
Right(LenientUri(nl, None, path, query, frag))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private[this] val delims: Set[Char] = ":/?#[]@".toSet
|
||||
|
||||
private def percentEncode(s: String): String =
|
||||
s.flatMap(c => if (delims.contains(c)) s"%${c.toInt.toHexString}" else c.toString)
|
||||
|
||||
private def percentDecode(s: String): String =
|
||||
if (!s.contains("%")) s
|
||||
else s.foldLeft(("", "")) { case ((acc, res), c) =>
|
||||
if (acc.length == 2) ("", res :+ Integer.parseInt(acc.drop(1) :+ c, 16).toChar)
|
||||
else if (acc.startsWith("%")) (acc :+ c, res)
|
||||
else if (c == '%') ("%", res)
|
||||
else (acc, res :+ c)
|
||||
}._2
|
||||
|
||||
private def stripLeading(s: String, c: Char): String =
|
||||
if (s.length > 0 && s.charAt(0) == c) s.substring(1)
|
||||
else s
|
||||
|
||||
|
||||
implicit val encodeLenientUri: Encoder[LenientUri] =
|
||||
Encoder.encodeString.contramap(_.asString)
|
||||
|
||||
implicit val decodeLenientUri: Decoder[LenientUri] =
|
||||
Decoder.decodeString.emap(LenientUri.parse)
|
||||
}
|
44
modules/common/src/main/scala/docspell/common/LogLevel.scala
Normal file
44
modules/common/src/main/scala/docspell/common/LogLevel.scala
Normal file
@ -0,0 +1,44 @@
|
||||
package docspell.common
|
||||
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
sealed trait LogLevel { self: Product =>
|
||||
def toInt: Int
|
||||
final def name: String =
|
||||
productPrefix.toLowerCase
|
||||
}
|
||||
|
||||
object LogLevel {
|
||||
|
||||
case object Debug extends LogLevel { val toInt = 0 }
|
||||
case object Info extends LogLevel { val toInt = 1 }
|
||||
case object Warn extends LogLevel { val toInt = 2 }
|
||||
case object Error extends LogLevel { val toInt = 3 }
|
||||
|
||||
def fromInt(n: Int): LogLevel =
|
||||
n match {
|
||||
case 0 => Debug
|
||||
case 1 => Info
|
||||
case 2 => Warn
|
||||
case 3 => Error
|
||||
case _ => Debug
|
||||
}
|
||||
|
||||
def fromString(str: String): Either[String, LogLevel] =
|
||||
str.toLowerCase match {
|
||||
case "debug" => Right(Debug)
|
||||
case "info" => Right(Info)
|
||||
case "warn" => Right(Warn)
|
||||
case "warning" => Right(Warn)
|
||||
case "error" => Right(Error)
|
||||
case _ => Left(s"Invalid log-level: $str")
|
||||
}
|
||||
|
||||
def unsafeString(str: String): LogLevel =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
implicit val jsonDecoder: Decoder[LogLevel] =
|
||||
Decoder.decodeString.emap(fromString)
|
||||
implicit val jsonEncoder: Encoder[LogLevel] =
|
||||
Encoder.encodeString.contramap(_.name)
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
package docspell.common
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
import docspell.common.MetaProposal.Candidate
|
||||
import io.circe._
|
||||
import io.circe.generic.semiauto._
|
||||
|
||||
case class MetaProposal(proposalType: MetaProposalType, values: NonEmptyList[Candidate]) {
|
||||
|
||||
def addIdRef(refs: Seq[Candidate]): MetaProposal =
|
||||
copy(values = MetaProposal.flatten(values ++ refs.toList))
|
||||
|
||||
def isSingleValue: Boolean =
|
||||
values.tail.isEmpty
|
||||
|
||||
def isMultiValue: Boolean =
|
||||
!isSingleValue
|
||||
|
||||
def size: Int =
|
||||
values.size
|
||||
}
|
||||
|
||||
object MetaProposal {
|
||||
|
||||
case class Candidate(ref: IdRef, origin: Set[NerLabel])
|
||||
object Candidate {
|
||||
implicit val jsonEncoder: Encoder[Candidate] =
|
||||
deriveEncoder[Candidate]
|
||||
implicit val jsonDecoder: Decoder[Candidate] =
|
||||
deriveDecoder[Candidate]
|
||||
}
|
||||
|
||||
def flatten(s: NonEmptyList[Candidate]): NonEmptyList[Candidate] = {
|
||||
def append(list: List[Candidate]): Candidate =
|
||||
list.reduce((l0, l1) => l0.copy(origin = l0.origin ++ l1.origin))
|
||||
val grouped = s.toList.groupBy(_.ref.id)
|
||||
NonEmptyList.fromListUnsafe(grouped.values.toList.map(append))
|
||||
}
|
||||
|
||||
implicit val jsonDecoder: Decoder[MetaProposal] =
|
||||
deriveDecoder[MetaProposal]
|
||||
implicit val jsonEncoder: Encoder[MetaProposal] =
|
||||
deriveEncoder[MetaProposal]
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
package docspell.common
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
import cats.kernel.Monoid
|
||||
import docspell.common.MetaProposal.Candidate
|
||||
import io.circe._
|
||||
import io.circe.generic.semiauto._
|
||||
|
||||
case class MetaProposalList private (proposals: List[MetaProposal]) {
|
||||
|
||||
def isEmpty: Boolean = proposals.isEmpty
|
||||
def nonEmpty: Boolean = proposals.nonEmpty
|
||||
|
||||
def hasResults(mt: MetaProposalType, mts: MetaProposalType*): Boolean = {
|
||||
(mts :+ mt).map(mtp => proposals.exists(_.proposalType == mtp)).
|
||||
reduce(_ && _)
|
||||
}
|
||||
|
||||
def hasResultsAll: Boolean =
|
||||
proposals.map(_.proposalType).toSet == MetaProposalType.all.toSet
|
||||
|
||||
def getTypes: Set[MetaProposalType] =
|
||||
proposals.foldLeft(Set.empty[MetaProposalType])(_ + _.proposalType)
|
||||
|
||||
def fillEmptyFrom(ml: MetaProposalList): MetaProposalList = {
|
||||
val list = ml.proposals.foldLeft(proposals){ (mine, mp) =>
|
||||
if (hasResults(mp.proposalType)) mine
|
||||
else mp :: mine
|
||||
}
|
||||
new MetaProposalList(list)
|
||||
}
|
||||
|
||||
def find(mpt: MetaProposalType): Option[MetaProposal] =
|
||||
proposals.find(_.proposalType == mpt)
|
||||
|
||||
}
|
||||
|
||||
object MetaProposalList {
|
||||
val empty = MetaProposalList(Nil)
|
||||
|
||||
def apply(lmp: List[MetaProposal]): MetaProposalList =
|
||||
flatten(lmp.map(m => new MetaProposalList(List(m))))
|
||||
|
||||
def of(mps: MetaProposal*): MetaProposalList =
|
||||
flatten(mps.toList.map(mp => MetaProposalList(List(mp))))
|
||||
|
||||
def from(mt: MetaProposalType, label: NerLabel)(refs: Seq[IdRef]): MetaProposalList =
|
||||
fromSeq1(mt, refs.map(ref => Candidate(ref, Set(label))))
|
||||
|
||||
def fromSeq1(mt: MetaProposalType, refs: Seq[Candidate]): MetaProposalList =
|
||||
NonEmptyList.fromList(refs.toList).
|
||||
map(nl => MetaProposalList.of(MetaProposal(mt, nl))).
|
||||
getOrElse(empty)
|
||||
|
||||
def fromMap(m: Map[MetaProposalType, MetaProposal]): MetaProposalList = {
|
||||
new MetaProposalList(m.toList.map({ case (k, v) => v.copy(proposalType = k) }))
|
||||
}
|
||||
|
||||
def flatten(ml: Seq[MetaProposalList]): MetaProposalList = {
|
||||
val init: Map[MetaProposalType, MetaProposal] = Map.empty
|
||||
|
||||
def updateMap(map: Map[MetaProposalType, MetaProposal], mp: MetaProposal): Map[MetaProposalType, MetaProposal] =
|
||||
map.get(mp.proposalType) match {
|
||||
case Some(mp0) => map.updated(mp.proposalType, mp0.addIdRef(mp.values.toList))
|
||||
case None => map.updated(mp.proposalType, mp)
|
||||
}
|
||||
|
||||
val merged = ml.foldLeft(init) { (map, el) =>
|
||||
el.proposals.foldLeft(map)(updateMap)
|
||||
}
|
||||
|
||||
fromMap(merged)
|
||||
}
|
||||
|
||||
implicit val jsonEncoder: Encoder[MetaProposalList] =
|
||||
deriveEncoder[MetaProposalList]
|
||||
implicit val jsonDecoder: Decoder[MetaProposalList] =
|
||||
deriveDecoder[MetaProposalList]
|
||||
|
||||
implicit val metaProposalListMonoid: Monoid[MetaProposalList] =
|
||||
Monoid.instance(empty, (m0, m1) => flatten(Seq(m0, m1)))
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
package docspell.common
|
||||
|
||||
import io.circe._
|
||||
|
||||
sealed trait MetaProposalType { self: Product =>
|
||||
|
||||
final def name: String =
|
||||
productPrefix.toLowerCase
|
||||
}
|
||||
|
||||
object MetaProposalType {
|
||||
|
||||
case object CorrOrg extends MetaProposalType
|
||||
case object CorrPerson extends MetaProposalType
|
||||
case object ConcPerson extends MetaProposalType
|
||||
case object ConcEquip extends MetaProposalType
|
||||
case object DocDate extends MetaProposalType
|
||||
case object DueDate extends MetaProposalType
|
||||
|
||||
val all: List[MetaProposalType] =
|
||||
List(CorrOrg, CorrPerson, ConcPerson, ConcEquip)
|
||||
|
||||
def fromString(str: String): Either[String, MetaProposalType] =
|
||||
str.toLowerCase match {
|
||||
case "corrorg" => Right(CorrOrg)
|
||||
case "corrperson" => Right(CorrPerson)
|
||||
case "concperson" => Right(ConcPerson)
|
||||
case "concequip" => Right(ConcEquip)
|
||||
case "docdate" => Right(DocDate)
|
||||
case "duedate" => Right(DueDate)
|
||||
case _ => Left(s"Invalid item-proposal-type: $str")
|
||||
}
|
||||
|
||||
def unsafe(str: String): MetaProposalType =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
implicit val jsonDecoder: Decoder[MetaProposalType] =
|
||||
Decoder.decodeString.emap(fromString)
|
||||
implicit val jsonEncoder: Encoder[MetaProposalType] =
|
||||
Encoder.encodeString.contramap(_.name)
|
||||
}
|
62
modules/common/src/main/scala/docspell/common/MimeType.scala
Normal file
62
modules/common/src/main/scala/docspell/common/MimeType.scala
Normal file
@ -0,0 +1,62 @@
|
||||
package docspell.common
|
||||
|
||||
import docspell.common.syntax.all._
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
/** A MIME Type impl with just enough features for the use here.
|
||||
*/
|
||||
case class MimeType(primary: String, sub: String) {
|
||||
|
||||
def asString: String =
|
||||
s"$primary/$sub"
|
||||
|
||||
def matches(other: MimeType): Boolean =
|
||||
primary == other.primary &&
|
||||
(sub == other.sub || sub == "*" )
|
||||
}
|
||||
|
||||
object MimeType {
|
||||
|
||||
def application(sub: String): MimeType =
|
||||
MimeType("application", partFromString(sub).throwLeft)
|
||||
|
||||
def text(sub: String): MimeType =
|
||||
MimeType("text", partFromString(sub).throwLeft)
|
||||
|
||||
def image(sub: String): MimeType =
|
||||
MimeType("image", partFromString(sub).throwLeft)
|
||||
|
||||
private[this] val validChars: Set[Char] = (('A' to 'Z') ++ ('a' to 'z') ++ ('0' to '9') ++ "*-").toSet
|
||||
|
||||
def parse(str: String): Either[String, MimeType] = {
|
||||
str.indexOf('/') match {
|
||||
case -1 => Left(s"Invalid MIME type: $str")
|
||||
case n =>
|
||||
for {
|
||||
prim <- partFromString(str.substring(0, n))
|
||||
sub <- partFromString(str.substring(n + 1))
|
||||
} yield MimeType(prim.toLowerCase, sub.toLowerCase)
|
||||
}
|
||||
}
|
||||
|
||||
def unsafe(str: String): MimeType =
|
||||
parse(str).throwLeft
|
||||
|
||||
private def partFromString(s: String): Either[String, String] =
|
||||
if (s.forall(validChars.contains)) Right(s)
|
||||
else Left(s"Invalid identifier: $s. Allowed chars: ${validChars.mkString}")
|
||||
|
||||
val octetStream = application("octet-stream")
|
||||
val pdf = application("pdf")
|
||||
val png = image("png")
|
||||
val jpeg = image("jpeg")
|
||||
val tiff = image("tiff")
|
||||
val html = text("html")
|
||||
val plain = text("plain")
|
||||
|
||||
implicit val jsonEncoder: Encoder[MimeType] =
|
||||
Encoder.encodeString.contramap(_.asString)
|
||||
|
||||
implicit val jsonDecoder: Decoder[MimeType] =
|
||||
Decoder.decodeString.emap(parse)
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package docspell.common
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
case class NerDateLabel(date: LocalDate, label: NerLabel) {
|
||||
|
||||
}
|
13
modules/common/src/main/scala/docspell/common/NerLabel.scala
Normal file
13
modules/common/src/main/scala/docspell/common/NerLabel.scala
Normal file
@ -0,0 +1,13 @@
|
||||
package docspell.common
|
||||
|
||||
import io.circe.generic.semiauto._
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
case class NerLabel(label: String, tag: NerTag, startPosition: Int, endPosition: Int) {
|
||||
|
||||
}
|
||||
|
||||
object NerLabel {
|
||||
implicit val jsonEncoder: Encoder[NerLabel] = deriveEncoder[NerLabel]
|
||||
implicit val jsonDecoder: Decoder[NerLabel] = deriveDecoder[NerLabel]
|
||||
}
|
43
modules/common/src/main/scala/docspell/common/NerTag.scala
Normal file
43
modules/common/src/main/scala/docspell/common/NerTag.scala
Normal file
@ -0,0 +1,43 @@
|
||||
package docspell.common
|
||||
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
sealed trait NerTag { self: Product =>
|
||||
|
||||
final def name: String =
|
||||
productPrefix.toLowerCase
|
||||
}
|
||||
|
||||
object NerTag {
|
||||
|
||||
case object Organization extends NerTag
|
||||
case object Person extends NerTag
|
||||
case object Location extends NerTag
|
||||
case object Misc extends NerTag
|
||||
case object Email extends NerTag
|
||||
case object Website extends NerTag
|
||||
case object Date extends NerTag
|
||||
|
||||
val all: List[NerTag] = List(Organization, Person, Location)
|
||||
|
||||
def fromString(str: String): Either[String, NerTag] =
|
||||
str.toLowerCase match {
|
||||
case "organization" => Right(Organization)
|
||||
case "person" => Right(Person)
|
||||
case "location" => Right(Location)
|
||||
case "misc" => Right(Misc)
|
||||
case "email" => Right(Email)
|
||||
case "website" => Right(Website)
|
||||
case "date" => Right(Date)
|
||||
case _ => Left(s"Invalid ner tag: $str")
|
||||
}
|
||||
|
||||
def unsafe(str: String): NerTag =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
|
||||
implicit val jsonDecoder: Decoder[NerTag] =
|
||||
Decoder.decodeString.emap(fromString)
|
||||
implicit val jsonEncoder: Encoder[NerTag] =
|
||||
Encoder.encodeString.contramap(_.name)
|
||||
}
|
25
modules/common/src/main/scala/docspell/common/NodeType.scala
Normal file
25
modules/common/src/main/scala/docspell/common/NodeType.scala
Normal file
@ -0,0 +1,25 @@
|
||||
package docspell.common
|
||||
|
||||
sealed trait NodeType { self: Product =>
|
||||
|
||||
final def name: String =
|
||||
self.productPrefix.toLowerCase
|
||||
|
||||
}
|
||||
|
||||
object NodeType {
|
||||
|
||||
case object Restserver extends NodeType
|
||||
case object Joex extends NodeType
|
||||
|
||||
def fromString(str: String): Either[String, NodeType] =
|
||||
str.toLowerCase match {
|
||||
case "restserver" => Right(Restserver)
|
||||
case "joex" => Right(Joex)
|
||||
case _ => Left(s"Invalid node type: $str")
|
||||
}
|
||||
|
||||
def unsafe(str: String): NodeType =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
}
|
27
modules/common/src/main/scala/docspell/common/Password.scala
Normal file
27
modules/common/src/main/scala/docspell/common/Password.scala
Normal file
@ -0,0 +1,27 @@
|
||||
package docspell.common
|
||||
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
final class Password(val pass: String) extends AnyVal {
|
||||
|
||||
def isEmpty: Boolean= pass.isEmpty
|
||||
|
||||
override def toString: String =
|
||||
if (pass.isEmpty) "<empty>" else "***"
|
||||
|
||||
}
|
||||
|
||||
object Password {
|
||||
|
||||
val empty = Password("")
|
||||
|
||||
def apply(pass: String): Password =
|
||||
new Password(pass)
|
||||
|
||||
implicit val passwordEncoder: Encoder[Password] =
|
||||
Encoder.encodeString.contramap(_.pass)
|
||||
|
||||
implicit val passwordDecoder: Decoder[Password] =
|
||||
Decoder.decodeString.map(Password(_))
|
||||
|
||||
}
|
48
modules/common/src/main/scala/docspell/common/Priority.scala
Normal file
48
modules/common/src/main/scala/docspell/common/Priority.scala
Normal file
@ -0,0 +1,48 @@
|
||||
package docspell.common
|
||||
|
||||
import cats.implicits._
|
||||
import cats.Order
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
sealed trait Priority { self: Product =>
|
||||
|
||||
final def name: String =
|
||||
productPrefix.toLowerCase
|
||||
}
|
||||
|
||||
object Priority {
|
||||
|
||||
case object High extends Priority
|
||||
|
||||
case object Low extends Priority
|
||||
|
||||
|
||||
def fromString(str: String): Either[String, Priority] =
|
||||
str.toLowerCase match {
|
||||
case "high" => Right(High)
|
||||
case "low" => Right(Low)
|
||||
case _ => Left(s"Invalid priority: $str")
|
||||
}
|
||||
|
||||
def unsafe(str: String): Priority =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
|
||||
def fromInt(n: Int): Priority =
|
||||
if (n <= toInt(Low)) Low
|
||||
else High
|
||||
|
||||
def toInt(p: Priority): Int =
|
||||
p match {
|
||||
case Low => 0
|
||||
case High => 10
|
||||
}
|
||||
|
||||
implicit val priorityOrder: Order[Priority] =
|
||||
Order.by[Priority, Int](toInt)
|
||||
|
||||
implicit val jsonEncoder: Encoder[Priority] =
|
||||
Encoder.encodeString.contramap(_.name)
|
||||
implicit val jsonDecoder: Decoder[Priority] =
|
||||
Decoder.decodeString.emap(fromString)
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
package docspell.common
|
||||
|
||||
import io.circe._, io.circe.generic.semiauto._
|
||||
import docspell.common.syntax.all._
|
||||
import ProcessItemArgs._
|
||||
|
||||
case class ProcessItemArgs(meta: ProcessMeta, files: List[File]) {
|
||||
|
||||
def makeSubject: String = {
|
||||
files.flatMap(_.name) match {
|
||||
case Nil => s"${meta.sourceAbbrev}: No files"
|
||||
case n :: Nil => n
|
||||
case n1 :: n2 :: Nil => s"$n1, $n2"
|
||||
case more => s"${files.size} files from ${meta.sourceAbbrev}"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
object ProcessItemArgs {
|
||||
|
||||
val taskName = Ident.unsafe("process-item")
|
||||
|
||||
case class ProcessMeta( collective: Ident
|
||||
, language: Language
|
||||
, direction: Option[Direction]
|
||||
, sourceAbbrev: String
|
||||
, validFileTypes: Seq[MimeType])
|
||||
|
||||
object ProcessMeta {
|
||||
implicit val jsonEncoder: Encoder[ProcessMeta] = deriveEncoder[ProcessMeta]
|
||||
implicit val jsonDecoder: Decoder[ProcessMeta] = deriveDecoder[ProcessMeta]
|
||||
}
|
||||
|
||||
case class File(name: Option[String], fileMetaId: Ident)
|
||||
object File {
|
||||
implicit val jsonEncoder: Encoder[File] = deriveEncoder[File]
|
||||
implicit val jsonDecoder: Decoder[File] = deriveDecoder[File]
|
||||
}
|
||||
|
||||
implicit val jsonEncoder: Encoder[ProcessItemArgs] = deriveEncoder[ProcessItemArgs]
|
||||
implicit val jsonDecoder: Decoder[ProcessItemArgs] = deriveDecoder[ProcessItemArgs]
|
||||
|
||||
def parse(str: String): Either[Throwable, ProcessItemArgs] =
|
||||
str.parseJsonAs[ProcessItemArgs]
|
||||
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
package docspell.common
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.concurrent.{Executors, ThreadFactory}
|
||||
|
||||
object ThreadFactories {
|
||||
|
||||
def ofName(prefix: String): ThreadFactory =
|
||||
new ThreadFactory {
|
||||
|
||||
val counter = new AtomicLong(0)
|
||||
|
||||
override def newThread(r: Runnable): Thread = {
|
||||
val t = Executors.defaultThreadFactory().newThread(r)
|
||||
t.setName(s"$prefix-${counter.getAndIncrement()}")
|
||||
t
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
package docspell.common
|
||||
|
||||
import java.time.{Instant, LocalDate, ZoneId}
|
||||
|
||||
import cats.effect.Sync
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
case class Timestamp(value: Instant) {
|
||||
|
||||
def toMillis: Long = value.toEpochMilli
|
||||
|
||||
def toSeconds: Long = value.toEpochMilli / 1000L
|
||||
|
||||
def minus(d: Duration): Timestamp =
|
||||
Timestamp(value.minusNanos(d.nanos))
|
||||
|
||||
def minusHours(n: Long): Timestamp =
|
||||
Timestamp(value.minusSeconds(n * 60 * 60))
|
||||
|
||||
def toDate: LocalDate =
|
||||
value.atZone(ZoneId.of("UTC")).toLocalDate
|
||||
|
||||
def asString: String = value.toString
|
||||
}
|
||||
|
||||
object Timestamp {
|
||||
|
||||
val Epoch = Timestamp(Instant.EPOCH)
|
||||
|
||||
def current[F[_]: Sync]: F[Timestamp] =
|
||||
Sync[F].delay(Timestamp(Instant.now))
|
||||
|
||||
|
||||
|
||||
implicit val encodeTimestamp: Encoder[Timestamp] =
|
||||
BaseJsonCodecs.encodeInstantEpoch.contramap(_.value)
|
||||
|
||||
implicit val decodeTimestamp: Decoder[Timestamp] =
|
||||
BaseJsonCodecs.decodeInstantEpoch.map(Timestamp(_))
|
||||
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
package docspell.common
|
||||
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
sealed trait UserState
|
||||
object UserState {
|
||||
val all = List(Active, Disabled)
|
||||
|
||||
/** An active or enabled user. */
|
||||
case object Active extends UserState
|
||||
|
||||
/** The user is blocked by an admin. */
|
||||
case object Disabled extends UserState
|
||||
|
||||
|
||||
def fromString(s: String): Either[String, UserState] =
|
||||
s.toLowerCase match {
|
||||
case "active" => Right(Active)
|
||||
case "disabled" => Right(Disabled)
|
||||
case _ => Left(s"Not a state value: $s")
|
||||
}
|
||||
|
||||
def unsafe(str: String): UserState =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
def asString(s: UserState): String = s match {
|
||||
case Active => "active"
|
||||
case Disabled => "disabled"
|
||||
}
|
||||
|
||||
implicit val userStateEncoder: Encoder[UserState] =
|
||||
Encoder.encodeString.contramap(UserState.asString)
|
||||
|
||||
implicit val userStateDecoder: Decoder[UserState] =
|
||||
Decoder.decodeString.emap(UserState.fromString)
|
||||
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
package docspell.common.pureconfig
|
||||
|
||||
import docspell.common._
|
||||
import _root_.pureconfig._
|
||||
import _root_.pureconfig.error.{CannotConvert, FailureReason}
|
||||
import scodec.bits.ByteVector
|
||||
|
||||
import scala.reflect.ClassTag
|
||||
|
||||
object Implicits {
|
||||
implicit val lenientUriReader: ConfigReader[LenientUri] =
|
||||
ConfigReader[String].emap(reason(LenientUri.parse))
|
||||
|
||||
implicit val durationReader: ConfigReader[Duration] =
|
||||
ConfigReader[scala.concurrent.duration.Duration].map(sd => Duration(sd))
|
||||
|
||||
implicit val passwordReader: ConfigReader[Password] =
|
||||
ConfigReader[String].map(Password(_))
|
||||
|
||||
implicit val mimeTypeReader: ConfigReader[MimeType] =
|
||||
ConfigReader[String].emap(reason(MimeType.parse))
|
||||
|
||||
implicit val identReader: ConfigReader[Ident] =
|
||||
ConfigReader[String].emap(reason(Ident.fromString))
|
||||
|
||||
implicit val byteVectorReader: ConfigReader[ByteVector] =
|
||||
ConfigReader[String].emap(reason(str => {
|
||||
if (str.startsWith("hex:")) ByteVector.fromHex(str.drop(4)).toRight("Invalid hex value.")
|
||||
else if (str.startsWith("b64:")) ByteVector.fromBase64(str.drop(4)).toRight("Invalid Base64 string.")
|
||||
else ByteVector.fromHex(str).toRight("Invalid hex value.")
|
||||
}))
|
||||
|
||||
def reason[A: ClassTag](f: String => Either[String, A]): String => Either[FailureReason, A] =
|
||||
in => f(in).left.map(str => CannotConvert(in, implicitly[ClassTag[A]].runtimeClass.toString, str))
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
package docspell.common.syntax
|
||||
|
||||
trait EitherSyntax {
|
||||
|
||||
implicit final class LeftStringEitherOps[A](e: Either[String, A]) {
|
||||
def throwLeft: A = e match {
|
||||
case Right(a) => a
|
||||
case Left(err) => sys.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
implicit final class ThrowableLeftEitherOps[A](e: Either[Throwable, A]) {
|
||||
def throwLeft: A = e match {
|
||||
case Right(a) => a
|
||||
case Left(err) => throw err
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
object EitherSyntax extends EitherSyntax
|
@ -0,0 +1,35 @@
|
||||
package docspell.common.syntax
|
||||
|
||||
import cats.effect.Sync
|
||||
import fs2.Stream
|
||||
import org.log4s.Logger
|
||||
|
||||
trait LoggerSyntax {
|
||||
|
||||
implicit final class LoggerOps(logger: Logger) {
|
||||
|
||||
def ftrace[F[_]: Sync](msg: => String): F[Unit] =
|
||||
Sync[F].delay(logger.trace(msg))
|
||||
|
||||
def fdebug[F[_]: Sync](msg: => String): F[Unit] =
|
||||
Sync[F].delay(logger.debug(msg))
|
||||
|
||||
def sdebug[F[_]: Sync](msg: => String): Stream[F, Nothing] =
|
||||
Stream.eval(fdebug(msg)).drain
|
||||
|
||||
def finfo[F[_]: Sync](msg: => String): F[Unit] =
|
||||
Sync[F].delay(logger.info(msg))
|
||||
|
||||
def sinfo[F[_]: Sync](msg: => String): Stream[F, Nothing] =
|
||||
Stream.eval(finfo(msg)).drain
|
||||
|
||||
def fwarn[F[_]: Sync](msg: => String): F[Unit] =
|
||||
Sync[F].delay(logger.warn(msg))
|
||||
|
||||
def ferror[F[_]: Sync](msg: => String): F[Unit] =
|
||||
Sync[F].delay(logger.error(msg))
|
||||
|
||||
def ferror[F[_]: Sync](ex: Throwable)(msg: => String): F[Unit] =
|
||||
Sync[F].delay(logger.error(ex)(msg))
|
||||
}
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
package docspell.common.syntax
|
||||
|
||||
import cats.effect.Sync
|
||||
import fs2.Stream
|
||||
import cats.implicits._
|
||||
import io.circe._
|
||||
import io.circe.parser._
|
||||
|
||||
trait StreamSyntax {
|
||||
|
||||
implicit class StringStreamOps[F[_]](s: Stream[F, String]) {
|
||||
|
||||
def parseJsonAs[A](implicit d: Decoder[A], F: Sync[F]): F[Either[Throwable, A]] =
|
||||
s.fold("")(_ + _).
|
||||
compile.last.
|
||||
map(optStr => for {
|
||||
str <- optStr.map(_.trim).toRight(new Exception("Empty string cannot be parsed into a value"))
|
||||
json <- parse(str).leftMap(_.underlying)
|
||||
value <- json.as[A]
|
||||
} yield value)
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
package docspell.common.syntax
|
||||
|
||||
import cats.implicits._
|
||||
import io.circe.Decoder
|
||||
import io.circe.parser._
|
||||
|
||||
trait StringSyntax {
|
||||
|
||||
implicit class EvenMoreStringOps(s: String) {
|
||||
|
||||
def asNonBlank: Option[String] =
|
||||
Option(s).filter(_.trim.nonEmpty)
|
||||
|
||||
def parseJsonAs[A](implicit d: Decoder[A]): Either[Throwable, A] =
|
||||
for {
|
||||
json <- parse(s).leftMap(_.underlying)
|
||||
value <- json.as[A]
|
||||
} yield value
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
package docspell.common
|
||||
|
||||
package object syntax {
|
||||
|
||||
object all extends EitherSyntax
|
||||
with StreamSyntax
|
||||
with StringSyntax
|
||||
with LoggerSyntax
|
||||
|
||||
}
|
14
modules/joex/src/main/resources/logback.xml
Normal file
14
modules/joex/src/main/resources/logback.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<configuration>
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<withJansi>true</withJansi>
|
||||
|
||||
<encoder>
|
||||
<pattern>[%thread] %highlight(%-5level) %cyan(%logger{15}) - %msg %n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<logger name="docspell" level="debug" />
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDOUT" />
|
||||
</root>
|
||||
</configuration>
|
@ -1,3 +1,131 @@
|
||||
docspell.joex {
|
||||
|
||||
# This is the id of this node. If you run more than one server, you
|
||||
# have to make sure to provide unique ids per node.
|
||||
app-id = "joex1"
|
||||
|
||||
|
||||
# This is the base URL this application is deployed to. This is used
|
||||
# to register this joex instance such that docspell rest servers can
|
||||
# reach them
|
||||
base-url = "http://localhost:7878"
|
||||
|
||||
# Where the REST server binds to.
|
||||
#
|
||||
# JOEX provides a very simple REST interface to inspect its state.
|
||||
bind {
|
||||
address = "localhost"
|
||||
port = 7878
|
||||
}
|
||||
|
||||
# The database connection.
|
||||
#
|
||||
# By default a H2 file-based database is configured. You can provide
|
||||
# a postgresql or mariadb connection here. When using H2 use the
|
||||
# PostgreSQL compatibility mode and AUTO_SERVER feature.
|
||||
#
|
||||
# It must be the same connection as the rest server is using.
|
||||
jdbc {
|
||||
url = "jdbc:h2://"${java.io.tmpdir}"/docspell-demo.db;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;AUTO_SERVER=TRUE"
|
||||
user = "sa"
|
||||
password = ""
|
||||
}
|
||||
|
||||
# Configuration for the job scheduler.
|
||||
scheduler {
|
||||
|
||||
# Each scheduler needs a unique name. This defaults to the node
|
||||
# name, which must be unique, too.
|
||||
name = ${docspell.joex.app-id}
|
||||
|
||||
# Number of processing allowed in parallel.
|
||||
pool-size = 2
|
||||
|
||||
# A counting scheme determines the ratio of how high- and low-prio
|
||||
# jobs are run. For example: 4,1 means run 4 high prio jobs, then
|
||||
# 1 low prio and then start over.
|
||||
counting-scheme = "4,1"
|
||||
|
||||
# How often a failed job should be retried until it enters failed
|
||||
# state. If a job fails, it becomes "stuck" and will be retried
|
||||
# after a delay.
|
||||
retries = 5
|
||||
|
||||
# The delay until the next try is performed for a failed job. This
|
||||
# delay is increased exponentially with the number of retries.
|
||||
retry-delay = "1 minute"
|
||||
|
||||
# The queue size of log statements from a job.
|
||||
log-buffer-size = 500
|
||||
|
||||
# If no job is left in the queue, the scheduler will wait until a
|
||||
# notify is requested (using the REST interface). To also retry
|
||||
# stuck jobs, it will notify itself periodically.
|
||||
wakeup-period = "30 minutes"
|
||||
}
|
||||
|
||||
# Configuration of text extraction
|
||||
#
|
||||
# Extracting text currently only work for image and pdf files. It
|
||||
# will first runs ghostscript to create a gray image from a
|
||||
# pdf. Then unpaper is run to optimize the image for the upcoming
|
||||
# ocr, which will be done by tesseract. All these programs must be
|
||||
# available in your PATH or the absolute path can be specified
|
||||
# below.
|
||||
extraction {
|
||||
allowed-content-types = [ "application/pdf", "image/jpeg", "image/png" ]
|
||||
|
||||
# Defines what pages to process. If a PDF with 600 pages is
|
||||
# submitted, it is probably not necessary to scan through all of
|
||||
# them. This would take a long time and occupy resources for no
|
||||
# value. The first few pages should suffice. The default is first
|
||||
# 10 pages.
|
||||
#
|
||||
# If you want all pages being processed, set this number to -1.
|
||||
#
|
||||
# Note: if you change the ghostscript command below, be aware that
|
||||
# this setting (if not -1) will add another parameter to the
|
||||
# beginning of the command.
|
||||
page-range {
|
||||
begin = 10
|
||||
}
|
||||
|
||||
# The ghostscript command.
|
||||
ghostscript {
|
||||
command {
|
||||
program = "gs"
|
||||
args = [ "-dNOPAUSE"
|
||||
, "-dBATCH"
|
||||
, "-dSAFER"
|
||||
, "-sDEVICE=tiffscaled8"
|
||||
, "-sOutputFile={{outfile}}"
|
||||
, "{{infile}}"
|
||||
]
|
||||
timeout = "5 minutes"
|
||||
}
|
||||
working-dir = ${java.io.tmpdir}"/docspell-extraction"
|
||||
}
|
||||
|
||||
# The unpaper command.
|
||||
unpaper {
|
||||
command {
|
||||
program = "unpaper"
|
||||
args = [ "{{infile}}", "{{outfile}}" ]
|
||||
timeout = "5 minutes"
|
||||
}
|
||||
}
|
||||
|
||||
# The tesseract command.
|
||||
tesseract {
|
||||
command {
|
||||
program = "tesseract"
|
||||
args = ["{{file}}"
|
||||
, "stdout"
|
||||
, "-l"
|
||||
, "{{lang}}"
|
||||
]
|
||||
timeout = "5 minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,18 +1,21 @@
|
||||
package docspell.joex
|
||||
|
||||
import docspell.common.{Ident, LenientUri}
|
||||
import docspell.joex.scheduler.SchedulerConfig
|
||||
import docspell.store.JdbcConfig
|
||||
import docspell.text.ocr.{Config => OcrConfig}
|
||||
|
||||
case class Config(id: String
|
||||
case class Config(appId: Ident
|
||||
, baseUrl: LenientUri
|
||||
, bind: Config.Bind
|
||||
, jdbc: JdbcConfig
|
||||
, scheduler: SchedulerConfig
|
||||
, extraction: OcrConfig
|
||||
)
|
||||
|
||||
object Config {
|
||||
|
||||
|
||||
val default: Config =
|
||||
Config("testid", Config.Bind("localhost", 7878), JdbcConfig("", "", ""))
|
||||
|
||||
val postgres = JdbcConfig(LenientUri.unsafe("jdbc:postgresql://localhost:5432/docspelldev"), "dev", "dev")
|
||||
val h2 = JdbcConfig(LenientUri.unsafe("jdbc:h2:./target/docspelldev.db;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE"), "sa", "")
|
||||
|
||||
case class Bind(address: String, port: Int)
|
||||
}
|
||||
|
20
modules/joex/src/main/scala/docspell/joex/ConfigFile.scala
Normal file
20
modules/joex/src/main/scala/docspell/joex/ConfigFile.scala
Normal file
@ -0,0 +1,20 @@
|
||||
package docspell.joex
|
||||
|
||||
import docspell.common.pureconfig.Implicits._
|
||||
import _root_.pureconfig._
|
||||
import _root_.pureconfig.generic.auto._
|
||||
import docspell.joex.scheduler.CountingScheme
|
||||
|
||||
object ConfigFile {
|
||||
import Implicits._
|
||||
|
||||
def loadConfig: Config =
|
||||
ConfigSource.default.at("docspell.joex").loadOrThrow[Config]
|
||||
|
||||
|
||||
object Implicits {
|
||||
implicit val countingSchemeReader: ConfigReader[CountingScheme] =
|
||||
ConfigReader[String].emap(reason(CountingScheme.readString))
|
||||
|
||||
}
|
||||
}
|
@ -1,6 +1,23 @@
|
||||
package docspell.joex
|
||||
|
||||
import docspell.common.Ident
|
||||
import docspell.joex.scheduler.Scheduler
|
||||
import docspell.store.records.RJobLog
|
||||
|
||||
trait JoexApp[F[_]] {
|
||||
|
||||
def init: F[Unit]
|
||||
|
||||
def scheduler: Scheduler[F]
|
||||
|
||||
def findLogs(jobId: Ident): F[Vector[RJobLog]]
|
||||
|
||||
/** Shuts down the job executor.
|
||||
*
|
||||
* It will immediately stop taking new jobs, waiting for currently
|
||||
* running jobs to complete normally (i.e. running jobs are not
|
||||
* canceled). After this completed, the webserver stops and the
|
||||
* main loop will exit.
|
||||
*/
|
||||
def initShutdown: F[Unit]
|
||||
}
|
||||
|
@ -1,16 +1,56 @@
|
||||
package docspell.joex
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect._
|
||||
import docspell.common.{Ident, NodeType, ProcessItemArgs}
|
||||
import docspell.joex.process.ItemHandler
|
||||
import docspell.joex.scheduler.{JobTask, Scheduler, SchedulerBuilder}
|
||||
import docspell.store.Store
|
||||
import docspell.store.ops.ONode
|
||||
import docspell.store.records.RJobLog
|
||||
import fs2.concurrent.SignallingRef
|
||||
|
||||
final class JoexAppImpl[F[_]: Sync](cfg: Config) extends JoexApp[F] {
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
def init: F[Unit] =
|
||||
Sync[F].pure(())
|
||||
final class JoexAppImpl[F[_]: ConcurrentEffect : ContextShift: Timer]( cfg: Config
|
||||
, nodeOps: ONode[F]
|
||||
, store: Store[F]
|
||||
, termSignal: SignallingRef[F, Boolean]
|
||||
, val scheduler: Scheduler[F]) extends JoexApp[F] {
|
||||
|
||||
def init: F[Unit] = {
|
||||
val run = scheduler.start.compile.drain
|
||||
for {
|
||||
_ <- ConcurrentEffect[F].start(run)
|
||||
_ <- scheduler.periodicAwake
|
||||
_ <- nodeOps.register(cfg.appId, NodeType.Joex, cfg.baseUrl)
|
||||
} yield ()
|
||||
}
|
||||
|
||||
def findLogs(jobId: Ident): F[Vector[RJobLog]] =
|
||||
store.transact(RJobLog.findLogs(jobId))
|
||||
|
||||
def shutdown: F[Unit] =
|
||||
nodeOps.unregister(cfg.appId)
|
||||
|
||||
def initShutdown: F[Unit] =
|
||||
scheduler.shutdown(false) *> termSignal.set(true)
|
||||
|
||||
}
|
||||
|
||||
object JoexAppImpl {
|
||||
|
||||
def create[F[_]: Sync](cfg: Config): Resource[F, JoexApp[F]] =
|
||||
Resource.liftF(Sync[F].pure(new JoexAppImpl(cfg)))
|
||||
def create[F[_]: ConcurrentEffect : ContextShift: Timer](cfg: Config
|
||||
, termSignal: SignallingRef[F, Boolean]
|
||||
, connectEC: ExecutionContext
|
||||
, blocker: Blocker): Resource[F, JoexApp[F]] =
|
||||
for {
|
||||
store <- Store.create(cfg.jdbc, connectEC, blocker)
|
||||
nodeOps <- ONode(store)
|
||||
sch <- SchedulerBuilder(cfg.scheduler, blocker, store).
|
||||
withTask(JobTask.json(ProcessItemArgs.taskName, ItemHandler[F](cfg.extraction), ItemHandler.onCancel[F])).
|
||||
resource
|
||||
app = new JoexAppImpl(cfg, nodeOps, store, termSignal, sch)
|
||||
appR <- Resource.make(app.init.map(_ => app))(_.shutdown)
|
||||
} yield appR
|
||||
}
|
||||
|
@ -1,37 +1,48 @@
|
||||
package docspell.joex
|
||||
|
||||
import cats.effect._
|
||||
import cats.effect.concurrent.Ref
|
||||
import docspell.joex.routes._
|
||||
import org.http4s.server.blaze.BlazeServerBuilder
|
||||
import org.http4s.implicits._
|
||||
import fs2.Stream
|
||||
|
||||
import fs2.concurrent.SignallingRef
|
||||
import org.http4s.HttpApp
|
||||
import org.http4s.server.middleware.Logger
|
||||
import org.http4s.server.Router
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
object JoexServer {
|
||||
|
||||
def stream[F[_]: ConcurrentEffect](cfg: Config)
|
||||
|
||||
private case class App[F[_]](httpApp: HttpApp[F], termSig: SignallingRef[F, Boolean], exitRef: Ref[F, ExitCode])
|
||||
|
||||
def stream[F[_]: ConcurrentEffect : ContextShift](cfg: Config, connectEC: ExecutionContext, blocker: Blocker)
|
||||
(implicit T: Timer[F]): Stream[F, Nothing] = {
|
||||
|
||||
val app = for {
|
||||
joexApp <- JoexAppImpl.create[F](cfg)
|
||||
_ <- Resource.liftF(joexApp.init)
|
||||
signal <- Resource.liftF(SignallingRef[F, Boolean](false))
|
||||
exitCode <- Resource.liftF(Ref[F].of(ExitCode.Success))
|
||||
joexApp <- JoexAppImpl.create[F](cfg, signal, connectEC, blocker)
|
||||
|
||||
httpApp = Router(
|
||||
"/api/info" -> InfoRoutes(cfg)
|
||||
"/api/info" -> InfoRoutes(cfg),
|
||||
"/api/v1" -> JoexRoutes(cfg, joexApp)
|
||||
).orNotFound
|
||||
|
||||
// With Middlewares in place
|
||||
finalHttpApp = Logger.httpApp(false, false)(httpApp)
|
||||
|
||||
} yield finalHttpApp
|
||||
} yield App(finalHttpApp, signal, exitCode)
|
||||
|
||||
|
||||
Stream.resource(app).flatMap(httpApp =>
|
||||
BlazeServerBuilder[F]
|
||||
.bindHttp(cfg.bind.port, cfg.bind.address)
|
||||
.withHttpApp(httpApp)
|
||||
.serve
|
||||
Stream.resource(app).flatMap(app =>
|
||||
BlazeServerBuilder[F].
|
||||
bindHttp(cfg.bind.port, cfg.bind.address).
|
||||
withHttpApp(app.httpApp).
|
||||
withoutBanner.
|
||||
serveWhile(app.termSig, app.exitRef)
|
||||
)
|
||||
|
||||
}.drain
|
||||
|
@ -1,16 +1,23 @@
|
||||
package docspell.joex
|
||||
|
||||
import cats.effect.{ExitCode, IO, IOApp}
|
||||
import cats.effect.{Blocker, ExitCode, IO, IOApp}
|
||||
import cats.implicits._
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
import java.util.concurrent.Executors
|
||||
import java.nio.file.{Files, Paths}
|
||||
|
||||
import docspell.common.{Banner, ThreadFactories}
|
||||
import org.log4s._
|
||||
|
||||
object Main extends IOApp {
|
||||
private[this] val logger = getLogger
|
||||
|
||||
val blockingEc: ExecutionContext = ExecutionContext.fromExecutor(Executors.newCachedThreadPool)
|
||||
val blockingEc: ExecutionContext = ExecutionContext.fromExecutor(
|
||||
Executors.newCachedThreadPool(ThreadFactories.ofName("docspell-joex-blocking")))
|
||||
val blocker = Blocker.liftExecutionContext(blockingEc)
|
||||
val connectEC: ExecutionContext = ExecutionContext.fromExecutorService(
|
||||
Executors.newFixedThreadPool(5, ThreadFactories.ofName("docspell-joex-dbconnect")))
|
||||
|
||||
def run(args: List[String]) = {
|
||||
args match {
|
||||
@ -32,7 +39,14 @@ object Main extends IOApp {
|
||||
}
|
||||
}
|
||||
|
||||
val cfg = Config.default
|
||||
JoexServer.stream[IO](cfg).compile.drain.as(ExitCode.Success)
|
||||
val cfg = ConfigFile.loadConfig
|
||||
val banner = Banner("JOEX"
|
||||
, BuildInfo.version
|
||||
, BuildInfo.gitHeadCommit
|
||||
, cfg.jdbc.url
|
||||
, Option(System.getProperty("config.file"))
|
||||
, cfg.appId, cfg.baseUrl)
|
||||
logger.info(s"\n${banner.render("***>")}")
|
||||
JoexServer.stream[IO](cfg, connectEC, blocker).compile.drain.as(ExitCode.Success)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,73 @@
|
||||
package docspell.joex.process
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.Sync
|
||||
import fs2.Stream
|
||||
import docspell.common._
|
||||
import docspell.joex.scheduler.{Context, Task}
|
||||
import docspell.store.queries.QItem
|
||||
import docspell.store.records.{RAttachment, RItem}
|
||||
|
||||
/**
|
||||
* Task that creates the item.
|
||||
*/
|
||||
object CreateItem {
|
||||
|
||||
def apply[F[_]: Sync]: Task[F, ProcessItemArgs, ItemData] =
|
||||
findExisting[F].flatMap {
|
||||
case Some(ri) => Task.pure(ri)
|
||||
case None => createNew[F]
|
||||
}
|
||||
|
||||
def createNew[F[_]: Sync]: Task[F, ProcessItemArgs, ItemData] =
|
||||
Task { ctx =>
|
||||
val validFiles = ctx.args.meta.validFileTypes.map(_.asString).toSet
|
||||
|
||||
def fileMetas(itemId: Ident, now: Timestamp) = Stream.emits(ctx.args.files).
|
||||
flatMap(f => ctx.store.bitpeace.get(f.fileMetaId.id).map(fm => (f, fm))).
|
||||
collect({ case (f, Some(fm)) if validFiles.contains(fm.mimetype.baseType) => (f, fm) }).
|
||||
zipWithIndex.
|
||||
evalMap({ case ((f, fm), index) =>
|
||||
Ident.randomId[F].map(id => RAttachment(id, itemId, f.fileMetaId, index.toInt, now, f.name))
|
||||
}).
|
||||
compile.toVector
|
||||
|
||||
val item = RItem.newItem[F](ctx.args.meta.collective
|
||||
, ctx.args.makeSubject
|
||||
, ctx.args.meta.sourceAbbrev
|
||||
, ctx.args.meta.direction.getOrElse(Direction.Incoming)
|
||||
, ItemState.Premature)
|
||||
|
||||
for {
|
||||
_ <- ctx.logger.info(s"Creating new item with ${ctx.args.files.size} attachment(s)")
|
||||
time <- Duration.stopTime[F]
|
||||
it <- item
|
||||
n <- ctx.store.transact(RItem.insert(it))
|
||||
_ <- if (n != 1) storeItemError[F](ctx) else ().pure[F]
|
||||
fm <- fileMetas(it.id, it.created)
|
||||
k <- fm.traverse(a => ctx.store.transact(RAttachment.insert(a)))
|
||||
_ <- logDifferences(ctx, fm, k.sum)
|
||||
dur <- time
|
||||
_ <- ctx.logger.info(s"Creating item finished in ${dur.formatExact}")
|
||||
} yield ItemData(it, fm, Vector.empty, Vector.empty)
|
||||
}
|
||||
|
||||
def findExisting[F[_]: Sync]: Task[F, ProcessItemArgs, Option[ItemData]] =
|
||||
Task { ctx =>
|
||||
for {
|
||||
cand <- ctx.store.transact(QItem.findByFileIds(ctx.args.files.map(_.fileMetaId)))
|
||||
_ <- if (cand.nonEmpty) ctx.logger.warn("Found existing item with these files.") else ().pure[F]
|
||||
ht <- cand.drop(1).traverse(ri => QItem.delete(ctx.store)(ri.id, ri.cid))
|
||||
_ <- if (ht.sum > 0) ctx.logger.warn(s"Removed ${ht.sum} items with same attachments") else ().pure[F]
|
||||
rms <- cand.headOption.traverse(ri => ctx.store.transact(RAttachment.findByItemAndCollective(ri.id, ri.cid)))
|
||||
} yield cand.headOption.map(ri => ItemData(ri, rms.getOrElse(Vector.empty), Vector.empty, Vector.empty))
|
||||
}
|
||||
|
||||
private def logDifferences[F[_]: Sync](ctx: Context[F, ProcessItemArgs], saved: Vector[RAttachment], saveCount: Int): F[Unit] =
|
||||
ctx.logger.info("TODO log diffs")
|
||||
|
||||
private def storeItemError[F[_]: Sync](ctx: Context[F, ProcessItemArgs]): F[Unit] = {
|
||||
val msg = "Inserting item failed. DB returned 0 update count!"
|
||||
ctx.logger.error(msg) *> Sync[F].raiseError(new Exception(msg))
|
||||
}
|
||||
}
|
@ -0,0 +1,181 @@
|
||||
package docspell.joex.process
|
||||
|
||||
import java.time.ZoneId
|
||||
|
||||
import cats.{Applicative, FlatMap}
|
||||
import cats.implicits._
|
||||
import cats.effect.Sync
|
||||
import docspell.common.MetaProposal.Candidate
|
||||
import docspell.common._
|
||||
import docspell.joex.scheduler.{Context, Task}
|
||||
import docspell.store.records.{RAttachmentMeta, REquipment, ROrganization, RPerson}
|
||||
import docspell.text.contact.Domain
|
||||
|
||||
/** Super simple approach to find corresponding meta data to an item
|
||||
* by looking up values from NER in the users address book.
|
||||
*
|
||||
*/
|
||||
object FindProposal {
|
||||
|
||||
def apply[F[_]: Sync](data: ItemData): Task[F, ProcessItemArgs, ItemData] =
|
||||
Task { ctx =>
|
||||
val rmas = data.metas.map(rm =>
|
||||
rm.copy(nerlabels = removeDuplicates(rm.nerlabels)))
|
||||
|
||||
ctx.logger.info("Starting find-proposal") *>
|
||||
rmas.traverse(rm => processAttachment(rm, data.findDates(rm), ctx).map(ml => rm.copy(proposals = ml))).
|
||||
flatMap(rmv => rmv.traverse(rm =>
|
||||
ctx.logger.debug(s"Storing attachment proposals: ${rm.proposals}") *>
|
||||
ctx.store.transact(RAttachmentMeta.updateProposals(rm.id, rm.proposals))).
|
||||
map(_ => data.copy(metas = rmv)))
|
||||
}
|
||||
|
||||
def processAttachment[F[_]: Sync]( rm: RAttachmentMeta
|
||||
, rd: Vector[NerDateLabel]
|
||||
, ctx: Context[F, ProcessItemArgs]): F[MetaProposalList] = {
|
||||
val finder = Finder.searchExact(ctx).next(Finder.searchFuzzy(ctx))
|
||||
List(finder.find(rm.nerlabels), makeDateProposal(rd)).
|
||||
traverse(identity).map(MetaProposalList.flatten)
|
||||
}
|
||||
|
||||
def makeDateProposal[F[_]: Sync](dates: Vector[NerDateLabel]): F[MetaProposalList] = {
|
||||
Timestamp.current[F].map { now =>
|
||||
val latestFirst = dates.sortWith(_.date isAfter _.date)
|
||||
val nowDate = now.value.atZone(ZoneId.of("GMT")).toLocalDate
|
||||
val (after, before) = latestFirst.span(ndl => ndl.date.isAfter(nowDate))
|
||||
|
||||
val dueDates = MetaProposalList.fromSeq1(MetaProposalType.DueDate,
|
||||
after.map(ndl => Candidate(IdRef(Ident.unsafe(ndl.date.toString), ndl.date.toString), Set(ndl.label))))
|
||||
val itemDates = MetaProposalList.fromSeq1(MetaProposalType.DocDate,
|
||||
before.map(ndl => Candidate(IdRef(Ident.unsafe(ndl.date.toString), ndl.date.toString), Set(ndl.label))))
|
||||
|
||||
MetaProposalList.flatten(Seq(dueDates, itemDates))
|
||||
}
|
||||
}
|
||||
|
||||
def removeDuplicates(labels: List[NerLabel]): List[NerLabel] =
|
||||
labels.foldLeft((Set.empty[String], List.empty[NerLabel])) { case ((seen, result), el) =>
|
||||
if (seen.contains(el.tag.name+el.label.toLowerCase)) (seen, result)
|
||||
else (seen + (el.tag.name + el.label.toLowerCase), el :: result)
|
||||
}._2.sortBy(_.startPosition)
|
||||
|
||||
trait Finder[F[_]] { self =>
|
||||
def find(labels: Seq[NerLabel]): F[MetaProposalList]
|
||||
|
||||
def contraMap(f: Seq[NerLabel] => Seq[NerLabel]): Finder[F] =
|
||||
labels => self.find(f(labels))
|
||||
|
||||
def filterLabels(f: NerLabel => Boolean): Finder[F] =
|
||||
contraMap(_.filter(f))
|
||||
|
||||
def flatMap(f: MetaProposalList => Finder[F])(implicit F: FlatMap[F]): Finder[F] =
|
||||
labels => self.find(labels).flatMap(ml => f(ml).find(labels))
|
||||
|
||||
def map(f: MetaProposalList => MetaProposalList)(implicit F: Applicative[F]): Finder[F] =
|
||||
labels => self.find(labels).map(f)
|
||||
|
||||
def next(f: Finder[F])(implicit F: FlatMap[F], F3: Applicative[F]): Finder[F] =
|
||||
flatMap({ ml0 =>
|
||||
if (ml0.hasResultsAll) Finder.unit[F](ml0)
|
||||
else f.map(ml1 => ml0.fillEmptyFrom(ml1))
|
||||
})
|
||||
|
||||
def nextWhenEmpty(f: Finder[F], mt0: MetaProposalType, mts: MetaProposalType*)
|
||||
(implicit F: FlatMap[F], F2: Applicative[F]): Finder[F] =
|
||||
flatMap(res0 => {
|
||||
if (res0.hasResults(mt0, mts: _*)) Finder.unit[F](res0)
|
||||
else f.map(res1 => res0.fillEmptyFrom(res1))
|
||||
})
|
||||
}
|
||||
|
||||
object Finder {
|
||||
def none[F[_]: Applicative]: Finder[F] =
|
||||
_ => MetaProposalList.empty.pure[F]
|
||||
|
||||
def unit[F[_]: Applicative](value: MetaProposalList): Finder[F] =
|
||||
_ => value.pure[F]
|
||||
|
||||
def searchExact[F[_]: Sync](ctx: Context[F, ProcessItemArgs]): Finder[F] =
|
||||
labels => labels.toList.traverse(nl => search(nl, true, ctx)).map(MetaProposalList.flatten)
|
||||
|
||||
def searchFuzzy[F[_]: Sync](ctx: Context[F, ProcessItemArgs]): Finder[F] =
|
||||
labels => labels.toList.traverse(nl => search(nl, false, ctx)).map(MetaProposalList.flatten)
|
||||
}
|
||||
|
||||
private def search[F[_]: Sync](nt: NerLabel, exact: Boolean, ctx: Context[F, ProcessItemArgs]): F[MetaProposalList] = {
|
||||
val value =
|
||||
if (exact) normalizeSearchValue(nt.label)
|
||||
else s"%${normalizeSearchValue(nt.label)}%"
|
||||
val minLength =
|
||||
if (exact) 2 else 5
|
||||
|
||||
if (value.length < minLength) {
|
||||
ctx.logger.debug(s"Skipping too small value '$value' (original '${nt.label}').").map(_ => MetaProposalList.empty)
|
||||
} else nt.tag match {
|
||||
case NerTag.Organization =>
|
||||
ctx.logger.debug(s"Looking for organizations: $value") *>
|
||||
ctx.store.transact(ROrganization.findLike(ctx.args.meta.collective, value)).
|
||||
map(MetaProposalList.from(MetaProposalType.CorrOrg, nt))
|
||||
|
||||
case NerTag.Person =>
|
||||
val s1 = ctx.store.transact(RPerson.findLike(ctx.args.meta.collective, value, true)).
|
||||
map(MetaProposalList.from(MetaProposalType.ConcPerson, nt))
|
||||
val s2 = ctx.store.transact(RPerson.findLike(ctx.args.meta.collective, value, false)).
|
||||
map(MetaProposalList.from(MetaProposalType.CorrPerson, nt))
|
||||
ctx.logger.debug(s"Looking for persons: $value") *> (for {
|
||||
ml0 <- s1
|
||||
ml1 <- s2
|
||||
} yield ml0 |+| ml1)
|
||||
|
||||
case NerTag.Location =>
|
||||
ctx.logger.debug(s"NerTag 'Location' is currently not used. Ignoring value '$value'.").
|
||||
map(_ => MetaProposalList.empty)
|
||||
|
||||
case NerTag.Misc =>
|
||||
ctx.logger.debug(s"Looking for equipments: $value") *>
|
||||
ctx.store.transact(REquipment.findLike(ctx.args.meta.collective, value)).
|
||||
map(MetaProposalList.from(MetaProposalType.ConcEquip, nt))
|
||||
|
||||
case NerTag.Email =>
|
||||
searchContact(nt, ContactKind.Email, value, ctx)
|
||||
|
||||
case NerTag.Website =>
|
||||
if (!exact) {
|
||||
val searchString = Domain.domainFromUri(nt.label.toLowerCase).
|
||||
toOption.
|
||||
map(_.toPrimaryDomain.asString).
|
||||
map(s => s"%$s%").
|
||||
getOrElse(value)
|
||||
searchContact(nt, ContactKind.Website, searchString, ctx)
|
||||
} else {
|
||||
searchContact(nt, ContactKind.Website, value, ctx)
|
||||
}
|
||||
|
||||
case NerTag.Date =>
|
||||
// There is no database search required for this tag
|
||||
MetaProposalList.empty.pure[F]
|
||||
}
|
||||
}
|
||||
|
||||
private def searchContact[F[_]: Sync]( nt: NerLabel
|
||||
, kind: ContactKind
|
||||
, value: String
|
||||
, ctx: Context[F, ProcessItemArgs]): F[MetaProposalList] = {
|
||||
val orgs = ctx.store.transact(ROrganization.findLike(ctx.args.meta.collective, kind, value)).
|
||||
map(MetaProposalList.from(MetaProposalType.CorrOrg, nt))
|
||||
val corrP = ctx.store.transact(RPerson.findLike(ctx.args.meta.collective, kind, value, false)).
|
||||
map(MetaProposalList.from(MetaProposalType.CorrPerson, nt))
|
||||
val concP = ctx.store.transact(RPerson.findLike(ctx.args.meta.collective, kind, value, true)).
|
||||
map(MetaProposalList.from(MetaProposalType.CorrPerson, nt))
|
||||
|
||||
ctx.logger.debug(s"Looking with $kind: $value") *>
|
||||
List(orgs, corrP, concP).traverse(identity).map(MetaProposalList.flatten)
|
||||
}
|
||||
|
||||
// The backslash *must* be stripped from search strings.
|
||||
private [this] val invalidSearch =
|
||||
"…_[]^<>=&ſ/{}*?@#$|~`+%\"';\\".toSet
|
||||
|
||||
private def normalizeSearchValue(str: String): String =
|
||||
str.toLowerCase.filter(c => !invalidSearch.contains(c))
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
package docspell.joex.process
|
||||
|
||||
import docspell.common.{Ident, NerDateLabel, NerLabel}
|
||||
import docspell.joex.process.ItemData.AttachmentDates
|
||||
import docspell.store.records.{RAttachment, RAttachmentMeta, RItem}
|
||||
|
||||
case class ItemData( item: RItem
|
||||
, attachments: Vector[RAttachment]
|
||||
, metas: Vector[RAttachmentMeta]
|
||||
, dateLabels: Vector[AttachmentDates]) {
|
||||
|
||||
def findMeta(attachId: Ident): Option[RAttachmentMeta] =
|
||||
metas.find(_.id == attachId)
|
||||
|
||||
def findDates(rm: RAttachmentMeta): Vector[NerDateLabel] =
|
||||
dateLabels.find(m => m.rm.id == rm.id).map(_.dates).getOrElse(Vector.empty)
|
||||
}
|
||||
|
||||
|
||||
object ItemData {
|
||||
|
||||
case class AttachmentDates(rm: RAttachmentMeta, dates: Vector[NerDateLabel]) {
|
||||
def toNerLabel: Vector[NerLabel] =
|
||||
dates.map(dl => dl.label.copy(label = dl.date.toString))
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
package docspell.joex.process
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.{ContextShift, Sync}
|
||||
import docspell.common.{ItemState, ProcessItemArgs}
|
||||
import docspell.joex.scheduler.{Context, Task}
|
||||
import docspell.store.queries.QItem
|
||||
import docspell.store.records.{RItem, RJob}
|
||||
import docspell.text.ocr.{Config => OcrConfig}
|
||||
|
||||
object ItemHandler {
|
||||
def onCancel[F[_]: Sync: ContextShift]: Task[F, ProcessItemArgs, Unit] =
|
||||
logWarn("Now cancelling. Deleting potentially created data.").
|
||||
flatMap(_ => deleteByFileIds)
|
||||
|
||||
def apply[F[_]: Sync: ContextShift](cfg: OcrConfig): Task[F, ProcessItemArgs, Unit] =
|
||||
CreateItem[F].
|
||||
flatMap(itemStateTask(ItemState.Processing)).
|
||||
flatMap(safeProcess[F](cfg)).
|
||||
map(_ => ())
|
||||
|
||||
def itemStateTask[F[_]: Sync, A](state: ItemState)(data: ItemData): Task[F, A, ItemData] =
|
||||
Task { ctx =>
|
||||
ctx.store.transact(RItem.updateState(data.item.id, state)).map(_ => data)
|
||||
}
|
||||
|
||||
def isLastRetry[F[_]: Sync, A](ctx: Context[F, A]): F[Boolean] =
|
||||
for {
|
||||
current <- ctx.store.transact(RJob.getRetries(ctx.jobId))
|
||||
last = ctx.config.retries == current.getOrElse(0)
|
||||
} yield last
|
||||
|
||||
|
||||
def safeProcess[F[_]: Sync: ContextShift](cfg: OcrConfig)(data: ItemData): Task[F, ProcessItemArgs, ItemData] =
|
||||
Task(isLastRetry[F, ProcessItemArgs] _).flatMap {
|
||||
case true =>
|
||||
ProcessItem[F](cfg)(data).
|
||||
attempt.flatMap({
|
||||
case Right(d) =>
|
||||
Task.pure(d)
|
||||
case Left(ex) =>
|
||||
logWarn[F]("Processing failed on last retry. Creating item but without proposals.").
|
||||
flatMap(_ => itemStateTask(ItemState.Created)(data)).
|
||||
andThen(_ => Sync[F].raiseError(ex))
|
||||
})
|
||||
case false =>
|
||||
ProcessItem[F](cfg)(data).
|
||||
flatMap(itemStateTask(ItemState.Created))
|
||||
}
|
||||
|
||||
def deleteByFileIds[F[_]: Sync: ContextShift]: Task[F, ProcessItemArgs, Unit] =
|
||||
Task { ctx =>
|
||||
for {
|
||||
items <- ctx.store.transact(QItem.findByFileIds(ctx.args.files.map(_.fileMetaId)))
|
||||
_ <- ctx.logger.info(s"Deleting items ${items.map(_.id.id)}")
|
||||
_ <- items.traverse(i => QItem.delete(ctx.store)(i.id, ctx.args.meta.collective))
|
||||
} yield ()
|
||||
}
|
||||
|
||||
private def logWarn[F[_]](msg: => String): Task[F, ProcessItemArgs, Unit] =
|
||||
Task(_.logger.warn(msg))
|
||||
}
|
@ -0,0 +1,71 @@
|
||||
package docspell.joex.process
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.Sync
|
||||
import docspell.common._
|
||||
import docspell.joex.scheduler.{Context, Task}
|
||||
import docspell.store.records.RItem
|
||||
|
||||
object LinkProposal {
|
||||
|
||||
def apply[F[_]: Sync](data: ItemData): Task[F, ProcessItemArgs, ItemData] =
|
||||
Task { ctx =>
|
||||
val proposals = MetaProposalList.flatten(data.metas.map(_.proposals))
|
||||
|
||||
ctx.logger.info(s"Starting linking proposals") *>
|
||||
MetaProposalType.all.
|
||||
traverse(applyValue(data, proposals, ctx)).
|
||||
map(result => ctx.logger.info(s"Results from proposal processing: $result")).
|
||||
map(_ => data)
|
||||
}
|
||||
|
||||
def applyValue[F[_]: Sync](data: ItemData, proposalList: MetaProposalList, ctx: Context[F, ProcessItemArgs])(mpt: MetaProposalType): F[Result] = {
|
||||
proposalList.find(mpt) match {
|
||||
case None =>
|
||||
Result.noneFound(mpt).pure[F]
|
||||
case Some(a) if a.isSingleValue =>
|
||||
ctx.logger.info(s"Found one candidate for ${a.proposalType}") *>
|
||||
setItemMeta(data.item.id, ctx, a.proposalType, a.values.head.ref.id).
|
||||
map(_ => Result.single(mpt))
|
||||
case Some(a) =>
|
||||
ctx.logger.info(s"Found many (${a.size}, ${a.values.map(_.ref.id.id)}) candidates for ${a.proposalType}. Setting first.") *>
|
||||
setItemMeta(data.item.id, ctx, a.proposalType, a.values.head.ref.id).
|
||||
map(_ => Result.multiple(mpt))
|
||||
}
|
||||
}
|
||||
|
||||
def setItemMeta[F[_]: Sync](itemId: Ident, ctx: Context[F, ProcessItemArgs], mpt: MetaProposalType, value: Ident): F[Int] =
|
||||
mpt match {
|
||||
case MetaProposalType.CorrOrg =>
|
||||
ctx.logger.debug(s"Updating item organization with: ${value.id}") *>
|
||||
ctx.store.transact(RItem.updateCorrOrg(itemId, ctx.args.meta.collective, Some(value)))
|
||||
case MetaProposalType.ConcPerson =>
|
||||
ctx.logger.debug(s"Updating item concerning person with: $value") *>
|
||||
ctx.store.transact(RItem.updateConcPerson(itemId, ctx.args.meta.collective, Some(value)))
|
||||
case MetaProposalType.CorrPerson =>
|
||||
ctx.logger.debug(s"Updating item correspondent person with: $value") *>
|
||||
ctx.store.transact(RItem.updateCorrPerson(itemId, ctx.args.meta.collective, Some(value)))
|
||||
case MetaProposalType.ConcEquip =>
|
||||
ctx.logger.debug(s"Updating item concerning equipment with: $value") *>
|
||||
ctx.store.transact(RItem.updateConcEquip(itemId, ctx.args.meta.collective, Some(value)))
|
||||
case MetaProposalType.DocDate =>
|
||||
ctx.logger.debug(s"Not linking document date suggestion ${value.id}").map(_ => 0)
|
||||
case MetaProposalType.DueDate =>
|
||||
ctx.logger.debug(s"Not linking document date suggestion ${value.id}").map(_ => 0)
|
||||
}
|
||||
|
||||
|
||||
sealed trait Result {
|
||||
def proposalType: MetaProposalType
|
||||
}
|
||||
object Result {
|
||||
|
||||
case class NoneFound(proposalType: MetaProposalType) extends Result
|
||||
case class SingleResult(proposalType: MetaProposalType) extends Result
|
||||
case class MultipleResult(proposalType: MetaProposalType) extends Result
|
||||
|
||||
def noneFound(proposalType: MetaProposalType): Result = NoneFound(proposalType)
|
||||
def single(proposalType: MetaProposalType): Result = SingleResult(proposalType)
|
||||
def multiple(proposalType: MetaProposalType): Result = MultipleResult(proposalType)
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
package docspell.joex.process
|
||||
|
||||
import cats.effect.{ContextShift, Sync}
|
||||
import docspell.common.ProcessItemArgs
|
||||
import docspell.joex.scheduler.Task
|
||||
import docspell.text.ocr.{Config => OcrConfig}
|
||||
|
||||
object ProcessItem {
|
||||
|
||||
def apply[F[_]: Sync: ContextShift](cfg: OcrConfig)(item: ItemData): Task[F, ProcessItemArgs, ItemData] =
|
||||
TextExtraction(cfg, item).
|
||||
flatMap(Task.setProgress(25)).
|
||||
flatMap(TextAnalysis[F]).
|
||||
flatMap(Task.setProgress(50)).
|
||||
flatMap(FindProposal[F]).
|
||||
flatMap(Task.setProgress(75)).
|
||||
flatMap(LinkProposal[F]).
|
||||
flatMap(Task.setProgress(99))
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
package docspell.joex.process
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.Sync
|
||||
import docspell.common.ProcessItemArgs
|
||||
import docspell.common.syntax.all._
|
||||
import docspell.joex.scheduler.Task
|
||||
import org.log4s._
|
||||
|
||||
object TestTasks {
|
||||
private [this] val logger = getLogger
|
||||
|
||||
def success[F[_]]: Task[F, ProcessItemArgs, Unit] =
|
||||
Task { ctx =>
|
||||
ctx.logger.info(s"Running task now: ${ctx.args}")
|
||||
}
|
||||
|
||||
def failing[F[_]: Sync]: Task[F, ProcessItemArgs, Unit] =
|
||||
Task { ctx =>
|
||||
ctx.logger.info(s"Failing the task run :(").map(_ =>
|
||||
sys.error("Oh, cannot extract gold from this document")
|
||||
)
|
||||
}
|
||||
|
||||
def longRunning[F[_]: Sync]: Task[F, ProcessItemArgs, Unit] =
|
||||
Task { ctx =>
|
||||
logger.fwarn(s"${Thread.currentThread()} From executing long running task") >>
|
||||
ctx.logger.info(s"${Thread.currentThread()} Running task now: ${ctx.args}") >>
|
||||
sleep(2400) >>
|
||||
ctx.logger.debug("doing things") >>
|
||||
sleep(2400) >>
|
||||
ctx.logger.debug("doing more things") >>
|
||||
sleep(2400) >>
|
||||
ctx.logger.info("doing more things")
|
||||
}
|
||||
|
||||
private def sleep[F[_]:Sync](ms: Long): F[Unit] =
|
||||
Sync[F].delay(Thread.sleep(ms))
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
package docspell.joex.process
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.Sync
|
||||
import docspell.common.{Duration, Language, NerLabel, ProcessItemArgs}
|
||||
import docspell.joex.process.ItemData.AttachmentDates
|
||||
import docspell.joex.scheduler.Task
|
||||
import docspell.store.records.RAttachmentMeta
|
||||
import docspell.text.contact.Contact
|
||||
import docspell.text.date.DateFind
|
||||
import docspell.text.nlp.StanfordNerClassifier
|
||||
|
||||
object TextAnalysis {
|
||||
|
||||
def apply[F[_]: Sync](item: ItemData): Task[F, ProcessItemArgs, ItemData] =
|
||||
Task { ctx =>
|
||||
for {
|
||||
_ <- ctx.logger.info("Starting text analysis")
|
||||
s <- Duration.stopTime[F]
|
||||
t <- item.metas.toList.traverse(annotateAttachment[F](ctx.args.meta.language))
|
||||
_ <- ctx.logger.debug(s"Storing tags: ${t.map(_._1.copy(content = None))}")
|
||||
_ <- t.traverse(m => ctx.store.transact(RAttachmentMeta.updateLabels(m._1.id, m._1.nerlabels)))
|
||||
e <- s
|
||||
_ <- ctx.logger.info(s"Text-Analysis finished in ${e.formatExact}")
|
||||
v = t.toVector
|
||||
} yield item.copy(metas = v.map(_._1), dateLabels = v.map(_._2))
|
||||
}
|
||||
|
||||
def annotateAttachment[F[_]: Sync](lang: Language)(rm: RAttachmentMeta): F[(RAttachmentMeta, AttachmentDates)] =
|
||||
for {
|
||||
list0 <- stanfordNer[F](lang, rm)
|
||||
list1 <- contactNer[F](rm)
|
||||
dates <- dateNer[F](rm, lang)
|
||||
} yield (rm.copy(nerlabels = (list0 ++ list1 ++ dates.toNerLabel).toList), dates)
|
||||
|
||||
def stanfordNer[F[_]: Sync](lang: Language, rm: RAttachmentMeta): F[Vector[NerLabel]] = Sync[F].delay {
|
||||
rm.content.map(StanfordNerClassifier.nerAnnotate(lang)).getOrElse(Vector.empty)
|
||||
}
|
||||
|
||||
def contactNer[F[_]: Sync](rm: RAttachmentMeta): F[Vector[NerLabel]] = Sync[F].delay {
|
||||
rm.content.map(Contact.annotate).getOrElse(Vector.empty)
|
||||
}
|
||||
|
||||
def dateNer[F[_]: Sync](rm: RAttachmentMeta, lang: Language): F[AttachmentDates] = Sync[F].delay {
|
||||
AttachmentDates(rm, rm.content.map(txt => DateFind.findDates(txt, lang).toVector).getOrElse(Vector.empty))
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
package docspell.joex.process
|
||||
|
||||
import bitpeace.RangeDef
|
||||
import cats.implicits._
|
||||
import cats.effect.{Blocker, ContextShift, Sync}
|
||||
import docspell.common.{Duration, Language, ProcessItemArgs}
|
||||
import docspell.joex.scheduler.{Context, Task}
|
||||
import docspell.store.Store
|
||||
import docspell.store.records.{RAttachment, RAttachmentMeta}
|
||||
import docspell.text.ocr.{TextExtract, Config => OcrConfig}
|
||||
|
||||
object TextExtraction {
|
||||
|
||||
def apply[F[_]: Sync : ContextShift](cfg: OcrConfig, item: ItemData): Task[F, ProcessItemArgs, ItemData] =
|
||||
Task { ctx =>
|
||||
for {
|
||||
_ <- ctx.logger.info("Starting text extraction")
|
||||
start <- Duration.stopTime[F]
|
||||
txt <- item.attachments.traverse(extractTextToMeta(ctx, cfg, ctx.args.meta.language))
|
||||
_ <- ctx.logger.debug("Storing extracted texts")
|
||||
_ <- txt.toList.traverse(rm => ctx.store.transact(RAttachmentMeta.upsert(rm)))
|
||||
dur <- start
|
||||
_ <- ctx.logger.info(s"Text extraction finished in ${dur.formatExact}")
|
||||
} yield item.copy(metas = txt)
|
||||
}
|
||||
|
||||
def extractTextToMeta[F[_]: Sync : ContextShift](ctx: Context[F, _], cfg: OcrConfig, lang: Language)(ra: RAttachment): F[RAttachmentMeta] =
|
||||
for {
|
||||
_ <- ctx.logger.debug(s"Extracting text for attachment ${ra.name}")
|
||||
dst <- Duration.stopTime[F]
|
||||
txt <- extractText(cfg, lang, ctx.store, ctx.blocker)(ra)
|
||||
meta = RAttachmentMeta.empty(ra.id).copy(content = txt.map(_.trim).filter(_.nonEmpty))
|
||||
est <- dst
|
||||
_ <- ctx.logger.debug(s"Extracting text for attachment ${ra.name} finished in ${est.formatExact}")
|
||||
} yield meta
|
||||
|
||||
def extractText[F[_]: Sync : ContextShift](ocrConfig: OcrConfig, lang: Language, store: Store[F], blocker: Blocker)(ra: RAttachment): F[Option[String]] = {
|
||||
val data = store.bitpeace.get(ra.fileId.id).
|
||||
unNoneTerminate.
|
||||
through(store.bitpeace.fetchData2(RangeDef.all))
|
||||
|
||||
TextExtract.extract(data, blocker, lang.iso3, ocrConfig).
|
||||
compile.last
|
||||
}
|
||||
}
|
@ -1,14 +1,12 @@
|
||||
package docspell.joex
|
||||
package docspell.joex.routes
|
||||
|
||||
import cats.effect._
|
||||
import org.http4s._
|
||||
import cats.effect.Sync
|
||||
import docspell.joex.{BuildInfo, Config}
|
||||
import docspell.joexapi.model.VersionInfo
|
||||
import org.http4s.HttpRoutes
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.http4s.circe.CirceEntityEncoder._
|
||||
|
||||
import docspell.joexapi.model._
|
||||
import docspell.joex.BuildInfo
|
||||
|
||||
object InfoRoutes {
|
||||
|
||||
def apply[F[_]: Sync](cfg: Config): HttpRoutes[F] = {
|
@ -0,0 +1,59 @@
|
||||
package docspell.joex.routes
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect._
|
||||
import docspell.common.{Duration, Ident, Timestamp}
|
||||
import docspell.joex.{Config, JoexApp}
|
||||
import docspell.joexapi.model._
|
||||
import docspell.store.records.{RJob, RJobLog}
|
||||
import org.http4s.HttpRoutes
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.http4s.circe.CirceEntityEncoder._
|
||||
|
||||
object JoexRoutes {
|
||||
|
||||
def apply[F[_]: ConcurrentEffect: Timer](cfg: Config, app: JoexApp[F]): HttpRoutes[F] = {
|
||||
val dsl = new Http4sDsl[F]{}
|
||||
import dsl._
|
||||
HttpRoutes.of[F] {
|
||||
case POST -> Root / "notify" =>
|
||||
for {
|
||||
_ <- app.scheduler.notifyChange
|
||||
resp <- Ok(BasicResult(true, "Scheduler notified."))
|
||||
} yield resp
|
||||
|
||||
case GET -> Root / "running" =>
|
||||
for {
|
||||
jobs <- app.scheduler.getRunning
|
||||
jj = jobs.map(mkJob)
|
||||
resp <- Ok(JobList(jj.toList))
|
||||
} yield resp
|
||||
|
||||
case POST -> Root / "shutdownAndExit" =>
|
||||
for {
|
||||
_ <- ConcurrentEffect[F].start(Timer[F].sleep(Duration.seconds(1).toScala) *> app.initShutdown)
|
||||
resp <- Ok(BasicResult(true, "Shutdown initiated."))
|
||||
} yield resp
|
||||
|
||||
case GET -> Root / "job" / Ident(id) =>
|
||||
for {
|
||||
optJob <- app.scheduler.getRunning.map(_.find(_.id == id))
|
||||
optLog <- optJob.traverse(j => app.findLogs(j.id))
|
||||
jAndL = for { job <- optJob; log <- optLog } yield mkJobLog(job, log)
|
||||
resp <- jAndL.map(Ok(_)).getOrElse(NotFound(BasicResult(false, "Not found")))
|
||||
} yield resp
|
||||
|
||||
case POST -> Root / "job" / Ident(id) / "cancel" =>
|
||||
for {
|
||||
flag <- app.scheduler.requestCancel(id)
|
||||
resp <- Ok(BasicResult(flag, if (flag) "Cancel request submitted" else "Job not found"))
|
||||
} yield resp
|
||||
}
|
||||
}
|
||||
|
||||
def mkJob(j: RJob): Job =
|
||||
Job(j.id, j.subject, j.submitted, j.priority, j.retries, j.progress, j.started.getOrElse(Timestamp.Epoch))
|
||||
|
||||
def mkJobLog(j: RJob, jl: Vector[RJobLog]): JobAndLog =
|
||||
JobAndLog(mkJob(j), jl.map(r => JobLogEvent(r.created, r.level, r.message)).toList)
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
package docspell.joex.scheduler
|
||||
|
||||
import cats.Functor
|
||||
import cats.effect.{Blocker, Concurrent}
|
||||
import cats.implicits._
|
||||
import docspell.common.Ident
|
||||
import docspell.store.Store
|
||||
import docspell.store.records.RJob
|
||||
import docspell.common.syntax.all._
|
||||
import org.log4s.{Logger => _, _}
|
||||
|
||||
trait Context[F[_], A] { self =>
|
||||
|
||||
def jobId: Ident
|
||||
|
||||
def args: A
|
||||
|
||||
def config: SchedulerConfig
|
||||
|
||||
def logger: Logger[F]
|
||||
|
||||
def setProgress(percent: Int): F[Unit]
|
||||
|
||||
def store: Store[F]
|
||||
|
||||
def blocker: Blocker
|
||||
|
||||
def map[C](f: A => C)(implicit F: Functor[F]): Context[F, C] =
|
||||
new Context.ContextImpl[F, C](f(args), logger, store, blocker, config, jobId)
|
||||
}
|
||||
|
||||
object Context {
|
||||
private [this] val log = getLogger
|
||||
|
||||
def create[F[_]: Functor, A]( job: RJob
|
||||
, arg: A
|
||||
, config: SchedulerConfig
|
||||
, log: Logger[F]
|
||||
, store: Store[F]
|
||||
, blocker: Blocker): Context[F, A] =
|
||||
new ContextImpl(arg, log, store, blocker, config, job.id)
|
||||
|
||||
def apply[F[_]: Concurrent, A]( job: RJob
|
||||
, arg: A
|
||||
, config: SchedulerConfig
|
||||
, logSink: LogSink[F]
|
||||
, blocker: Blocker
|
||||
, store: Store[F]): F[Context[F, A]] =
|
||||
for {
|
||||
_ <- log.ftrace("Creating logger for task run")
|
||||
logger <- Logger(job.id, job.info, config.logBufferSize, logSink)
|
||||
_ <- log.ftrace("Logger created, instantiating context")
|
||||
ctx = create[F, A](job, arg, config, logger, store, blocker)
|
||||
} yield ctx
|
||||
|
||||
private final class ContextImpl[F[_]: Functor, A]( val args: A
|
||||
, val logger: Logger[F]
|
||||
, val store: Store[F]
|
||||
, val blocker: Blocker
|
||||
, val config: SchedulerConfig
|
||||
, val jobId: Ident)
|
||||
extends Context[F,A] {
|
||||
|
||||
def setProgress(percent: Int): F[Unit] = {
|
||||
val pval = math.min(100, math.max(0, percent))
|
||||
store.transact(RJob.setProgress(jobId, pval)).map(_ => ())
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
package docspell.joex.scheduler
|
||||
|
||||
import cats.implicits._
|
||||
import docspell.common.Priority
|
||||
|
||||
/** A counting scheme to indicate a ratio between scheduling high and
|
||||
* low priority jobs.
|
||||
*
|
||||
* For example high=4, low=1 means: ”schedule 4 high priority jobs
|
||||
* and then 1 low priority job“.
|
||||
*/
|
||||
case class CountingScheme(high: Int, low: Int, counter: Int = 0) {
|
||||
|
||||
def nextPriority: (CountingScheme, Priority) = {
|
||||
if (counter <= 0) (increment, Priority.High)
|
||||
else {
|
||||
val rest = counter % (high + low)
|
||||
if (rest < high) (increment, Priority.High)
|
||||
else (increment, Priority.Low)
|
||||
}
|
||||
}
|
||||
|
||||
def increment: CountingScheme =
|
||||
copy(counter = counter + 1)
|
||||
}
|
||||
|
||||
object CountingScheme {
|
||||
|
||||
def writeString(cs: CountingScheme): String =
|
||||
s"${cs.high},${cs.low}"
|
||||
|
||||
def readString(str: String): Either[String, CountingScheme] =
|
||||
str.split(',') match {
|
||||
case Array(h, l) =>
|
||||
Either.catchNonFatal(CountingScheme(h.toInt, l.toInt)).
|
||||
left.map(_.getMessage)
|
||||
case _ =>
|
||||
Left(s"Invalid counting scheme: $str")
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
package docspell.joex.scheduler
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.Sync
|
||||
import docspell.common.Ident
|
||||
import docspell.common.syntax.all._
|
||||
import io.circe.Decoder
|
||||
|
||||
/**
|
||||
* Binds a Task to a name. This is required to lookup the code based
|
||||
* on the taskName in the RJob data and to execute it given the
|
||||
* arguments that have to be read from a string.
|
||||
*
|
||||
* Since the scheduler only has a string for the task argument, this
|
||||
* only works for Task impls that accept a string. There is a
|
||||
* convenience constructor that uses circe to decode json into some
|
||||
* type A.
|
||||
*/
|
||||
case class JobTask[F[_]](name: Ident, task: Task[F, String, Unit], onCancel: Task[F, String, Unit])
|
||||
|
||||
object JobTask {
|
||||
|
||||
def json[F[_]: Sync, A](name: Ident, task: Task[F, A, Unit], onCancel: Task[F, A, Unit])
|
||||
(implicit D: Decoder[A]): JobTask[F] = {
|
||||
val convert: String => F[A] =
|
||||
str => str.parseJsonAs[A] match {
|
||||
case Right(a) => a.pure[F]
|
||||
case Left(ex) => Sync[F].raiseError(new Exception(s"Cannot parse task arguments: $str", ex))
|
||||
}
|
||||
|
||||
JobTask(name, task.contramap(convert), onCancel.contramap(convert))
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
package docspell.joex.scheduler
|
||||
|
||||
import docspell.common.Ident
|
||||
|
||||
/**
|
||||
* This is a mapping from some identifier to a task. This is used by
|
||||
* the scheduler to lookup an implementation using the taskName field
|
||||
* of the RJob database record.
|
||||
*/
|
||||
final class JobTaskRegistry[F[_]](tasks: Map[Ident, JobTask[F]]) {
|
||||
|
||||
def withTask(task: JobTask[F]): JobTaskRegistry[F] =
|
||||
JobTaskRegistry(tasks.updated(task.name, task))
|
||||
|
||||
def find(taskName: Ident): Option[JobTask[F]] =
|
||||
tasks.get(taskName)
|
||||
}
|
||||
|
||||
object JobTaskRegistry {
|
||||
|
||||
def apply[F[_]](map: Map[Ident, JobTask[F]]): JobTaskRegistry[F] =
|
||||
new JobTaskRegistry[F](map)
|
||||
|
||||
def empty[F[_]]: JobTaskRegistry[F] = apply(Map.empty)
|
||||
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
package docspell.joex.scheduler
|
||||
|
||||
import cats.implicits._
|
||||
import docspell.common._
|
||||
import cats.effect.Sync
|
||||
|
||||
case class LogEvent( jobId: Ident
|
||||
, jobInfo: String
|
||||
, time: Timestamp
|
||||
, level: LogLevel
|
||||
, msg: String
|
||||
, ex: Option[Throwable] = None) {
|
||||
|
||||
def logLine: String =
|
||||
s">>> ${time.asString} $level $jobInfo: $msg"
|
||||
|
||||
}
|
||||
|
||||
object LogEvent {
|
||||
|
||||
def create[F[_]: Sync](jobId: Ident, jobInfo: String, level: LogLevel, msg: String): F[LogEvent] =
|
||||
Timestamp.current[F].map(now => LogEvent(jobId, jobInfo, now, level, msg))
|
||||
|
||||
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
package docspell.joex.scheduler
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.{Concurrent, Sync}
|
||||
import fs2.{Pipe, Stream}
|
||||
import org.log4s.{LogLevel => _, _}
|
||||
import docspell.common._
|
||||
import docspell.common.syntax.all._
|
||||
import docspell.store.Store
|
||||
import docspell.store.records.RJobLog
|
||||
|
||||
trait LogSink[F[_]] {
|
||||
|
||||
def receive: Pipe[F, LogEvent, Unit]
|
||||
|
||||
}
|
||||
|
||||
object LogSink {
|
||||
private[this] val logger = getLogger
|
||||
|
||||
def apply[F[_]](sink: Pipe[F, LogEvent, Unit]): LogSink[F] =
|
||||
new LogSink[F] {
|
||||
val receive = sink
|
||||
}
|
||||
|
||||
def logInternal[F[_]: Sync](e: LogEvent): F[Unit] =
|
||||
e.level match {
|
||||
case LogLevel.Info =>
|
||||
logger.finfo(e.logLine)
|
||||
case LogLevel.Debug =>
|
||||
logger.fdebug(e.logLine)
|
||||
case LogLevel.Warn =>
|
||||
logger.fwarn(e.logLine)
|
||||
case LogLevel.Error =>
|
||||
e.ex match {
|
||||
case Some(exc) =>
|
||||
logger.ferror(exc)(e.logLine)
|
||||
case None =>
|
||||
logger.ferror(e.logLine)
|
||||
}
|
||||
}
|
||||
|
||||
def printer[F[_]: Sync]: LogSink[F] =
|
||||
LogSink(_.evalMap(e => logInternal(e)))
|
||||
|
||||
def db[F[_]: Sync](store: Store[F]): LogSink[F] =
|
||||
LogSink(_.evalMap(ev => for {
|
||||
id <- Ident.randomId[F]
|
||||
joblog = RJobLog(id, ev.jobId, ev.level, ev.time, ev.msg + ev.ex.map(th => ": "+ th.getMessage).getOrElse(""))
|
||||
_ <- logInternal(ev)
|
||||
_ <- store.transact(RJobLog.insert(joblog))
|
||||
} yield ()))
|
||||
|
||||
def dbAndLog[F[_]: Concurrent](store: Store[F]): LogSink[F] = {
|
||||
val s: Stream[F, Pipe[F, LogEvent, Unit]] =
|
||||
Stream.emits(Seq(printer[F].receive, db[F](store).receive))
|
||||
LogSink(Pipe.join(s))
|
||||
}
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
package docspell.joex.scheduler
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.{Concurrent, Sync}
|
||||
import docspell.common._
|
||||
import fs2.concurrent.Queue
|
||||
|
||||
trait Logger[F[_]] {
|
||||
|
||||
def trace(msg: => String): F[Unit]
|
||||
def debug(msg: => String): F[Unit]
|
||||
def info(msg: => String): F[Unit]
|
||||
def warn(msg: => String): F[Unit]
|
||||
def error(ex: Throwable)(msg: => String): F[Unit]
|
||||
def error(msg: => String): F[Unit]
|
||||
|
||||
}
|
||||
|
||||
object Logger {
|
||||
|
||||
def create[F[_]: Sync](jobId: Ident, jobInfo: String, q: Queue[F, LogEvent]): Logger[F] =
|
||||
new Logger[F] {
|
||||
def trace(msg: => String): F[Unit] =
|
||||
LogEvent.create[F](jobId, jobInfo, LogLevel.Debug, msg).flatMap(q.enqueue1)
|
||||
|
||||
def debug(msg: => String): F[Unit] =
|
||||
LogEvent.create[F](jobId, jobInfo, LogLevel.Debug, msg).flatMap(q.enqueue1)
|
||||
|
||||
def info(msg: => String): F[Unit] =
|
||||
LogEvent.create[F](jobId, jobInfo, LogLevel.Info, msg).flatMap(q.enqueue1)
|
||||
|
||||
def warn(msg: => String): F[Unit] =
|
||||
LogEvent.create[F](jobId, jobInfo, LogLevel.Warn, msg).flatMap(q.enqueue1)
|
||||
|
||||
def error(ex: Throwable)(msg: => String): F[Unit] =
|
||||
LogEvent.create[F](jobId, jobInfo, LogLevel.Error, msg).map(le => le.copy(ex = Some(ex))).flatMap(q.enqueue1)
|
||||
|
||||
def error(msg: => String): F[Unit] =
|
||||
LogEvent.create[F](jobId, jobInfo, LogLevel.Error, msg).flatMap(q.enqueue1)
|
||||
}
|
||||
|
||||
def apply[F[_]: Concurrent](jobId: Ident, jobInfo: String, bufferSize: Int, sink: LogSink[F]): F[Logger[F]] =
|
||||
for {
|
||||
q <- Queue.circularBuffer[F, LogEvent](bufferSize)
|
||||
log = create(jobId, jobInfo, q)
|
||||
fib <- Concurrent[F].start(q.dequeue.through(sink.receive).compile.drain)
|
||||
} yield log
|
||||
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
package docspell.joex.scheduler
|
||||
|
||||
import cats.effect.{Fiber, Timer}
|
||||
import fs2.Stream
|
||||
import docspell.common.Ident
|
||||
import docspell.store.records.RJob
|
||||
|
||||
trait Scheduler[F[_]] {
|
||||
|
||||
def config: SchedulerConfig
|
||||
|
||||
def getRunning: F[Vector[RJob]]
|
||||
|
||||
def requestCancel(jobId: Ident): F[Boolean]
|
||||
|
||||
def notifyChange: F[Unit]
|
||||
|
||||
def start: Stream[F, Nothing]
|
||||
|
||||
/** Requests to shutdown the scheduler.
|
||||
*
|
||||
* The scheduler will not take any new jobs from the queue. If
|
||||
* there are still running jobs, it waits for them to complete.
|
||||
* when the cancelAll flag is set to true, it cancels all running
|
||||
* jobs.
|
||||
*
|
||||
* The returned F[Unit] can be evaluated to wait for all that to
|
||||
* complete.
|
||||
*/
|
||||
def shutdown(cancelAll: Boolean): F[Unit]
|
||||
|
||||
def periodicAwake(implicit T: Timer[F]): F[Fiber[F, Unit]]
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
package docspell.joex.scheduler
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.concurrent.Semaphore
|
||||
import cats.effect.{Blocker, ConcurrentEffect, ContextShift, Resource}
|
||||
import docspell.store.Store
|
||||
import docspell.store.queue.JobQueue
|
||||
import fs2.concurrent.SignallingRef
|
||||
|
||||
case class SchedulerBuilder[F[_]: ConcurrentEffect : ContextShift](
|
||||
config: SchedulerConfig
|
||||
, tasks: JobTaskRegistry[F]
|
||||
, store: Store[F]
|
||||
, blocker: Blocker
|
||||
, queue: Resource[F, JobQueue[F]]
|
||||
, logSink: LogSink[F]) {
|
||||
|
||||
def withConfig(cfg: SchedulerConfig): SchedulerBuilder[F] =
|
||||
copy(config = cfg)
|
||||
|
||||
def withTaskRegistry(reg: JobTaskRegistry[F]): SchedulerBuilder[F] =
|
||||
copy(tasks = reg)
|
||||
|
||||
def withTask[A](task: JobTask[F]): SchedulerBuilder[F] =
|
||||
withTaskRegistry(tasks.withTask(task))
|
||||
|
||||
def withQueue(queue: Resource[F, JobQueue[F]]): SchedulerBuilder[F] =
|
||||
SchedulerBuilder[F](config, tasks, store, blocker, queue, logSink)
|
||||
|
||||
def withBlocker(blocker: Blocker): SchedulerBuilder[F] =
|
||||
copy(blocker = blocker)
|
||||
|
||||
def withLogSink(sink: LogSink[F]): SchedulerBuilder[F] =
|
||||
copy(logSink = sink)
|
||||
|
||||
|
||||
def serve: Resource[F, Scheduler[F]] =
|
||||
resource.evalMap(sch => ConcurrentEffect[F].start(sch.start.compile.drain).map(_ => sch))
|
||||
|
||||
def resource: Resource[F, Scheduler[F]] = {
|
||||
val scheduler = for {
|
||||
jq <- queue
|
||||
waiter <- Resource.liftF(SignallingRef(true))
|
||||
state <- Resource.liftF(SignallingRef(SchedulerImpl.emptyState[F]))
|
||||
perms <- Resource.liftF(Semaphore(config.poolSize.toLong))
|
||||
} yield new SchedulerImpl[F](config, blocker, jq, tasks, store, logSink, state, waiter, perms)
|
||||
|
||||
scheduler.evalTap(_.init).
|
||||
map(s => s: Scheduler[F])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
object SchedulerBuilder {
|
||||
|
||||
def apply[F[_]: ConcurrentEffect : ContextShift]( config: SchedulerConfig
|
||||
, blocker: Blocker
|
||||
, store: Store[F]): SchedulerBuilder[F] =
|
||||
new SchedulerBuilder[F](config
|
||||
, JobTaskRegistry.empty[F]
|
||||
, store
|
||||
, blocker
|
||||
, JobQueue(store)
|
||||
, LogSink.db[F](store))
|
||||
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
package docspell.joex.scheduler
|
||||
|
||||
import docspell.common._
|
||||
|
||||
case class SchedulerConfig( name: Ident
|
||||
, poolSize: Int
|
||||
, countingScheme: CountingScheme
|
||||
, retries: Int
|
||||
, retryDelay: Duration
|
||||
, logBufferSize: Int
|
||||
, wakeupPeriod: Duration
|
||||
)
|
||||
|
||||
object SchedulerConfig {
|
||||
|
||||
val default = SchedulerConfig(
|
||||
name = Ident.unsafe("default-scheduler")
|
||||
, poolSize = 2 // math.max(2, Runtime.getRuntime.availableProcessors / 2)
|
||||
, countingScheme = CountingScheme(2, 1)
|
||||
, retries = 5
|
||||
, retryDelay = Duration.seconds(30)
|
||||
, logBufferSize = 500
|
||||
, wakeupPeriod = Duration.minutes(10)
|
||||
)
|
||||
}
|
@ -0,0 +1,227 @@
|
||||
package docspell.joex.scheduler
|
||||
|
||||
import fs2.Stream
|
||||
import cats.implicits._
|
||||
import cats.effect.concurrent.Semaphore
|
||||
import docspell.common._
|
||||
import docspell.common.syntax.all._
|
||||
import docspell.store.queue.JobQueue
|
||||
import docspell.store.records.RJob
|
||||
import fs2.concurrent.SignallingRef
|
||||
import cats.effect._
|
||||
import org.log4s._
|
||||
import SchedulerImpl._
|
||||
import docspell.store.Store
|
||||
import docspell.store.queries.QJob
|
||||
|
||||
final class SchedulerImpl[F[_]: ConcurrentEffect : ContextShift](val config: SchedulerConfig
|
||||
, blocker: Blocker
|
||||
, queue: JobQueue[F]
|
||||
, tasks: JobTaskRegistry[F]
|
||||
, store: Store[F]
|
||||
, logSink: LogSink[F]
|
||||
, state: SignallingRef[F, State[F]]
|
||||
, waiter: SignallingRef[F, Boolean]
|
||||
, permits: Semaphore[F]) extends Scheduler[F] {
|
||||
|
||||
private [this] val logger = getLogger
|
||||
|
||||
/**
|
||||
* On startup, get all jobs in state running from this scheduler
|
||||
* and put them into waiting state, so they get picked up again.
|
||||
*/
|
||||
def init: F[Unit] =
|
||||
QJob.runningToWaiting(config.name, store)
|
||||
|
||||
def periodicAwake(implicit T: Timer[F]): F[Fiber[F, Unit]] =
|
||||
ConcurrentEffect[F].start(Stream.awakeEvery[F](config.wakeupPeriod.toScala).
|
||||
evalMap(_ => logger.fdebug("Periodic awake reached") *> notifyChange).compile.drain)
|
||||
|
||||
def getRunning: F[Vector[RJob]] =
|
||||
state.get.flatMap(s => QJob.findAll(s.getRunning, store))
|
||||
|
||||
def requestCancel(jobId: Ident): F[Boolean] =
|
||||
state.get.flatMap(_.cancelRequest(jobId) match {
|
||||
case Some(ct) => ct.map(_ => true)
|
||||
case None => logger.fwarn(s"Job ${jobId.id} not found, cannot cancel.").map(_ => false)
|
||||
})
|
||||
|
||||
def notifyChange: F[Unit] =
|
||||
waiter.update(b => !b)
|
||||
|
||||
def shutdown(cancelAll: Boolean): F[Unit] = {
|
||||
val doCancel =
|
||||
state.get.
|
||||
flatMap(_.cancelTokens.values.toList.traverse(identity)).
|
||||
map(_ => ())
|
||||
|
||||
val runShutdown =
|
||||
state.modify(_.requestShutdown) *> (if (cancelAll) doCancel else ().pure[F])
|
||||
|
||||
val wait = Stream.eval(runShutdown).
|
||||
evalMap(_ => logger.finfo("Scheduler is shutting down now.")).
|
||||
flatMap(_ => Stream.eval(state.get) ++ Stream.suspend(state.discrete.takeWhile(_.getRunning.nonEmpty))).
|
||||
flatMap(state => {
|
||||
if (state.getRunning.isEmpty) Stream.eval(logger.finfo("No jobs running."))
|
||||
else Stream.eval(logger.finfo(s"Waiting for ${state.getRunning.size} jobs to finish.")) ++
|
||||
Stream.emit(state)
|
||||
})
|
||||
|
||||
(wait.drain ++ Stream.emit(())).compile.lastOrError
|
||||
}
|
||||
|
||||
def start: Stream[F, Nothing] =
|
||||
logger.sinfo("Starting scheduler") ++
|
||||
mainLoop
|
||||
|
||||
def mainLoop: Stream[F, Nothing] = {
|
||||
val body: F[Boolean] =
|
||||
for {
|
||||
_ <- permits.available.flatMap(a => logger.fdebug(s"Try to acquire permit ($a free)"))
|
||||
_ <- permits.acquire
|
||||
_ <- logger.fdebug("New permit acquired")
|
||||
down <- state.get.map(_.shutdownRequest)
|
||||
rjob <- if (down) logger.finfo("") *> permits.release *> (None: Option[RJob]).pure[F]
|
||||
else queue.nextJob(group => state.modify(_.nextPrio(group, config.countingScheme)), config.name, config.retryDelay)
|
||||
_ <- logger.fdebug(s"Next job found: ${rjob.map(_.info)}")
|
||||
_ <- rjob.map(execute).getOrElse(permits.release)
|
||||
} yield rjob.isDefined
|
||||
|
||||
Stream.eval(state.get.map(_.shutdownRequest)).
|
||||
evalTap(if (_) logger.finfo[F]("Stopping main loop due to shutdown request.") else ().pure[F]).
|
||||
flatMap(if (_) Stream.empty else Stream.eval(body)).
|
||||
flatMap({
|
||||
case true =>
|
||||
mainLoop
|
||||
case false =>
|
||||
logger.sdebug(s"Waiting for notify") ++
|
||||
waiter.discrete.take(2).drain ++
|
||||
logger.sdebug(s"Notify signal, going into main loop") ++
|
||||
mainLoop
|
||||
})
|
||||
}
|
||||
|
||||
def execute(job: RJob): F[Unit] = {
|
||||
val task = for {
|
||||
jobtask <- tasks.find(job.task).toRight(s"This executor cannot run tasks with name: ${job.task}")
|
||||
} yield jobtask
|
||||
|
||||
task match {
|
||||
case Left(err) =>
|
||||
logger.ferror(s"Unable to start a task for job ${job.info}: $err")
|
||||
case Right(t) =>
|
||||
for {
|
||||
_ <- logger.fdebug(s"Creating context for job ${job.info} to run $t")
|
||||
ctx <- Context[F, String](job, job.args, config, logSink, blocker, store)
|
||||
jot = wrapTask(job, t.task, ctx)
|
||||
tok <- forkRun(job, jot.run(ctx), t.onCancel.run(ctx), ctx)
|
||||
_ <- state.modify(_.addRunning(job, tok))
|
||||
} yield ()
|
||||
}
|
||||
}
|
||||
|
||||
def onFinish(job: RJob, finalState: JobState): F[Unit] =
|
||||
for {
|
||||
_ <- logger.fdebug(s"Job ${job.info} done $finalState. Releasing resources.")
|
||||
_ <- permits.release *> permits.available.flatMap(a => logger.fdebug(s"Permit released ($a free)"))
|
||||
_ <- state.modify(_.removeRunning(job))
|
||||
_ <- QJob.setFinalState(job.id, finalState, store)
|
||||
} yield ()
|
||||
|
||||
def onStart(job: RJob): F[Unit] =
|
||||
QJob.setRunning(job.id, config.name, store) //also increments retries if current state=stuck
|
||||
|
||||
def wrapTask(job: RJob, task: Task[F, String, Unit], ctx: Context[F, String]): Task[F, String, Unit] = {
|
||||
task.mapF(fa => onStart(job) *> logger.fdebug("Starting task now") *> blocker.blockOn(fa)).
|
||||
mapF(_.attempt.flatMap({
|
||||
case Right(()) =>
|
||||
logger.info(s"Job execution successful: ${job.info}")
|
||||
ctx.logger.info("Job execution successful") *>
|
||||
(JobState.Success: JobState).pure[F]
|
||||
case Left(ex) =>
|
||||
state.get.map(_.wasCancelled(job)).flatMap {
|
||||
case true =>
|
||||
logger.error(ex)(s"Job ${job.info} execution failed (cancel = true)")
|
||||
ctx.logger.error(ex)("Job execution failed (cancel = true)") *>
|
||||
(JobState.Cancelled: JobState).pure[F]
|
||||
case false =>
|
||||
QJob.exceedsRetries(job.id, config.retries, store).flatMap {
|
||||
case true =>
|
||||
logger.error(ex)(s"Job ${job.info} execution failed. Retries exceeded.")
|
||||
ctx.logger.error(ex)(s"Job ${job.info} execution failed. Retries exceeded.").
|
||||
map(_ => JobState.Failed: JobState)
|
||||
case false =>
|
||||
logger.error(ex)(s"Job ${job.info} execution failed. Retrying later.")
|
||||
ctx.logger.error(ex)(s"Job ${job.info} execution failed. Retrying later.").
|
||||
map(_ => JobState.Stuck: JobState)
|
||||
}
|
||||
}
|
||||
})).
|
||||
mapF(_.attempt.flatMap {
|
||||
case Right(jstate) =>
|
||||
onFinish(job, jstate)
|
||||
case Left(ex) =>
|
||||
logger.error(ex)(s"Error happened during post-processing of ${job.info}!")
|
||||
// we don't know the real outcome here…
|
||||
// since tasks should be idempotent, set it to stuck. if above has failed, this might fail anyways
|
||||
onFinish(job, JobState.Stuck)
|
||||
})
|
||||
}
|
||||
|
||||
def forkRun(job: RJob, code: F[Unit], onCancel: F[Unit], ctx: Context[F, String]): F[F[Unit]] = {
|
||||
val bfa = blocker.blockOn(code)
|
||||
logger.fdebug(s"Forking job ${job.info}") *>
|
||||
ConcurrentEffect[F].start(bfa).
|
||||
map(fiber =>
|
||||
logger.fdebug(s"Cancelling job ${job.info}") *>
|
||||
fiber.cancel *>
|
||||
onCancel.attempt.map({
|
||||
case Right(_) => ()
|
||||
case Left(ex) =>
|
||||
logger.error(ex)(s"Task's cancelling code failed. Job ${job.info}.")
|
||||
()
|
||||
}) *>
|
||||
state.modify(_.markCancelled(job)) *>
|
||||
onFinish(job, JobState.Cancelled) *>
|
||||
ctx.logger.warn("Job has been cancelled.") *>
|
||||
logger.fdebug(s"Job ${job.info} has been cancelled."))
|
||||
}
|
||||
}
|
||||
|
||||
object SchedulerImpl {
|
||||
|
||||
def emptyState[F[_]]: State[F] =
|
||||
State(Map.empty, Set.empty, Map.empty, false)
|
||||
|
||||
case class State[F[_]]( counters: Map[Ident, CountingScheme]
|
||||
, cancelled: Set[Ident]
|
||||
, cancelTokens: Map[Ident, CancelToken[F]]
|
||||
, shutdownRequest: Boolean) {
|
||||
|
||||
def nextPrio(group: Ident, initial: CountingScheme): (State[F], Priority) = {
|
||||
val (cs, prio) = counters.getOrElse(group, initial).nextPriority
|
||||
(copy(counters = counters.updated(group, cs)), prio)
|
||||
}
|
||||
|
||||
def addRunning(job: RJob, token: CancelToken[F]): (State[F], Unit) =
|
||||
(State(counters, cancelled, cancelTokens.updated(job.id, token), shutdownRequest), ())
|
||||
|
||||
def removeRunning(job: RJob): (State[F], Unit) =
|
||||
(copy(cancelled = cancelled - job.id, cancelTokens = cancelTokens.removed(job.id)), ())
|
||||
|
||||
def markCancelled(job: RJob): (State[F], Unit) =
|
||||
(copy(cancelled = cancelled + job.id), ())
|
||||
|
||||
def wasCancelled(job: RJob): Boolean =
|
||||
cancelled.contains(job.id)
|
||||
|
||||
def cancelRequest(id: Ident): Option[F[Unit]] =
|
||||
cancelTokens.get(id)
|
||||
|
||||
def getRunning: Seq[Ident] =
|
||||
cancelTokens.keys.toSeq
|
||||
|
||||
def requestShutdown: (State[F], Unit) =
|
||||
(copy(shutdownRequest = true), ())
|
||||
}
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
package docspell.joex.scheduler
|
||||
|
||||
import cats.implicits._
|
||||
import cats.{Applicative, ApplicativeError, FlatMap, Functor}
|
||||
import cats.data.Kleisli
|
||||
import cats.effect.Sync
|
||||
|
||||
/**
|
||||
* The code that is executed by the scheduler
|
||||
*/
|
||||
trait Task[F[_], A, B] {
|
||||
|
||||
def run(ctx: Context[F, A]): F[B]
|
||||
|
||||
def map[C](f: B => C)(implicit F: Functor[F]): Task[F, A, C] =
|
||||
Task(Task.toKleisli(this).map(f))
|
||||
|
||||
def flatMap[C](f: B => Task[F, A, C])(implicit F: FlatMap[F]): Task[F, A, C] =
|
||||
Task(Task.toKleisli(this).flatMap(a => Task.toKleisli(f(a))))
|
||||
|
||||
def andThen[C](f: B => F[C])(implicit F: FlatMap[F]): Task[F, A, C] =
|
||||
Task(Task.toKleisli(this).andThen(f))
|
||||
|
||||
def mapF[C](f: F[B] => F[C]): Task[F, A, C] =
|
||||
Task(Task.toKleisli(this).mapF(f))
|
||||
|
||||
def attempt(implicit F: ApplicativeError[F,Throwable]): Task[F, A, Either[Throwable, B]] =
|
||||
mapF(_.attempt)
|
||||
|
||||
def contramap[C](f: C => F[A])(implicit F: FlatMap[F]): Task[F, C, B] = {
|
||||
ctxc: Context[F, C] => f(ctxc.args).flatMap(a => run(ctxc.map(_ => a)))
|
||||
}
|
||||
}
|
||||
|
||||
object Task {
|
||||
|
||||
def pure[F[_]: Applicative, A, B](b: B): Task[F, A, B] =
|
||||
Task(_ => b.pure[F])
|
||||
|
||||
def of[F[_], A, B](b: F[B]): Task[F, A, B] =
|
||||
Task(_ => b)
|
||||
|
||||
def apply[F[_], A, B](f: Context[F, A] => F[B]): Task[F, A, B] =
|
||||
(ctx: Context[F, A]) => f(ctx)
|
||||
|
||||
def apply[F[_], A, B](k: Kleisli[F, Context[F, A], B]): Task[F, A, B] =
|
||||
c => k.run(c)
|
||||
|
||||
|
||||
def toKleisli[F[_], A, B](t: Task[F, A, B]): Kleisli[F, Context[F, A], B] =
|
||||
Kleisli(t.run)
|
||||
|
||||
def setProgress[F[_]: Sync, A, B](n: Int)(data: B): Task[F, A, B] =
|
||||
Task(_.setProgress(n).map(_ => data))
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
package docspell.joex.scheduler
|
||||
|
||||
import docspell.common.Priority
|
||||
import minitest.SimpleTestSuite
|
||||
|
||||
object CountingSchemeSpec extends SimpleTestSuite {
|
||||
|
||||
test("counting") {
|
||||
val cs = CountingScheme(2,1)
|
||||
val list = List.iterate(cs.nextPriority, 6)(_._1.nextPriority).map(_._2)
|
||||
val expect = List(Priority.High, Priority.High, Priority.Low)
|
||||
assertEquals(list, expect ++ expect)
|
||||
}
|
||||
|
||||
}
|
@ -9,9 +9,162 @@ servers:
|
||||
description: Current host
|
||||
|
||||
paths:
|
||||
/api/info:
|
||||
get:
|
||||
tag: [ Api Info ]
|
||||
summary: Get basic information about this software.
|
||||
description: |
|
||||
Returns the version and project name and other properties of the build.
|
||||
responses:
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/VersionInfo"
|
||||
/api/v1/notify:
|
||||
post:
|
||||
tag: [ Job Executor ]
|
||||
summary: Notify the job executor.
|
||||
description: |
|
||||
Notifies the job executor to wake up and look for jobs in th queue.
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
/api/v1/running:
|
||||
get:
|
||||
tag: [ Job Executor ]
|
||||
summary: Get a list of currently executing jobs.
|
||||
description: |
|
||||
Returns all jobs this executor is currently executing.
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/JobList"
|
||||
/api/v1/shutdownAndExit:
|
||||
post:
|
||||
tag: [ Job Executor ]
|
||||
summary: Stops this component and exits.
|
||||
description: |
|
||||
Gracefully stops the scheduler and also stops the process.
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
/api/v1/job/{id}:
|
||||
get:
|
||||
tag: [ Current Jobs ]
|
||||
summary: Get a job by its id.
|
||||
description: |
|
||||
Returns details about a job given the id.
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/JobAndLogs"
|
||||
/api/v1/job/{id}/cancel:
|
||||
post:
|
||||
tag: [ Current Jobs ]
|
||||
summary: Request to cancel a running job.
|
||||
description: |
|
||||
Requests to cancel the running job. This will try to cancel
|
||||
the execution but it is not guaranteed that it can immediately
|
||||
abort. The job is then removed from the queue.
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
|
||||
components:
|
||||
schemas:
|
||||
JobAndLog:
|
||||
description: |
|
||||
Some more details about the job.
|
||||
required:
|
||||
- job
|
||||
- logs
|
||||
properties:
|
||||
job:
|
||||
$ref: "#/components/schemas/Job"
|
||||
logs:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/JobLogEvent"
|
||||
JobLogEvent:
|
||||
description: |
|
||||
A log output line.
|
||||
required:
|
||||
- time
|
||||
- level
|
||||
- message
|
||||
properties:
|
||||
time:
|
||||
description: DateTime
|
||||
type: integer
|
||||
format: date-time
|
||||
level:
|
||||
type: string
|
||||
format: loglevel
|
||||
message:
|
||||
type: string
|
||||
JobList:
|
||||
description: |
|
||||
A list of jobs.
|
||||
required:
|
||||
- items
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Job"
|
||||
Job:
|
||||
description: |
|
||||
Data about a running job.
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- submitted
|
||||
- priority
|
||||
- retries
|
||||
- progress
|
||||
- started
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: ident
|
||||
name:
|
||||
type: string
|
||||
submitted:
|
||||
description: DateTime
|
||||
type: integer
|
||||
format: date-time
|
||||
priority:
|
||||
type: integer
|
||||
format: priority
|
||||
retries:
|
||||
type: integer
|
||||
format: int32
|
||||
progress:
|
||||
type: integer
|
||||
format: int32
|
||||
started:
|
||||
description: DateTime
|
||||
type: integer
|
||||
format: date-time
|
||||
VersionInfo:
|
||||
description: |
|
||||
Information about the software.
|
||||
@ -33,3 +186,14 @@ components:
|
||||
type: string
|
||||
gitVersion:
|
||||
type: string
|
||||
BasicResult:
|
||||
description: |
|
||||
Some basic result of an operation.
|
||||
required:
|
||||
- success
|
||||
- message
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
|
@ -0,0 +1,18 @@
|
||||
.jumbotron {
|
||||
background: url(../img/back-master-small.jpg);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 800px;
|
||||
}
|
||||
|
||||
.content-wrapper h1, .h1 {
|
||||
border-bottom: 1px solid #d8dfe5;
|
||||
padding-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 1.75em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
text-decoration: underline;
|
||||
}
|
48
modules/microsite/src/main/resources/microsite/data/menu.yml
Normal file
48
modules/microsite/src/main/resources/microsite/data/menu.yml
Normal file
@ -0,0 +1,48 @@
|
||||
options:
|
||||
- title: Home
|
||||
url: index.html
|
||||
|
||||
- title: Getit
|
||||
url: getit.html
|
||||
|
||||
- title: Documentation
|
||||
url: doc.html
|
||||
|
||||
nested_options:
|
||||
- title: Installation
|
||||
url: doc/install.html
|
||||
|
||||
- title: Configuring
|
||||
url: doc/configure.html
|
||||
|
||||
- title: Adding Meta Data
|
||||
url: doc/metadata.html
|
||||
|
||||
- title: Uploads
|
||||
url: doc/uploading.html
|
||||
|
||||
- title: Processing Queue
|
||||
url: doc/processing.html
|
||||
|
||||
- title: Find and Review
|
||||
url: doc/curate.html
|
||||
|
||||
- title: Joex
|
||||
url: doc/joex.html
|
||||
|
||||
- title: Development
|
||||
url: dev.html
|
||||
|
||||
nested_options:
|
||||
- tite: ADRs
|
||||
url: dev/adr.html
|
||||
|
||||
- title: Api
|
||||
url: api.html
|
||||
|
||||
nested_options:
|
||||
- title: REST Api Doc
|
||||
url: openapi/docspell-openapi.html
|
||||
|
||||
- title: REST OpenApi Spec
|
||||
url: openapi/docspell-openapi.yml
|
Binary file not shown.
After Width: | Height: | Size: 339 KiB |
Binary file not shown.
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
After Width: | Height: | Size: 88 KiB |
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user