v1.0.0
This commit is contained in:
commit
6708bbb67f
43 changed files with 8342 additions and 0 deletions
674
LICENSE
Normal file
674
LICENSE
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://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 <https://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
|
||||
<https://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
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
254
assets/css/media-audit-admin.css
Normal file
254
assets/css/media-audit-admin.css
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
/* -----------------------------------------------------------------------
|
||||
Media Audit — Admin styles
|
||||
----------------------------------------------------------------------- */
|
||||
|
||||
/* Stats strip */
|
||||
.mra-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: 12px 0 20px;
|
||||
}
|
||||
|
||||
.mra-stat-box {
|
||||
background: #fff;
|
||||
border: 1px solid #dcdcde;
|
||||
border-radius: 4px;
|
||||
padding: 12px 20px;
|
||||
text-align: center;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.mra-stat-box strong {
|
||||
display: block;
|
||||
font-size: 26px;
|
||||
line-height: 1.2;
|
||||
color: #1d2327;
|
||||
}
|
||||
|
||||
.mra-stat-box span {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #50575e;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.mra-stat-box small {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #787c82;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Thumbnail */
|
||||
.mra-thumb {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Usage list inside table cell */
|
||||
.mra-usage-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.mra-usage-list li {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
/* Filters row */
|
||||
.mra-filters select,
|
||||
.mra-filters input[type="checkbox"] {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* External status badges */
|
||||
.mra-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mra-badge-pending {
|
||||
background: #e8e8e8;
|
||||
color: #50575e;
|
||||
}
|
||||
|
||||
.mra-badge-queued {
|
||||
background: #cce5ff;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
.mra-badge-scanned {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.mra-badge-matches {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.mra-badge-error {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.mra-badge-match-count {
|
||||
background: #721c24;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Modal overlay */
|
||||
.mra-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 100000;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mra-modal-overlay.is-open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Modal box */
|
||||
.mra-modal {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
max-width: 720px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 6px 32px rgba(0, 0, 0, 0.28);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mra-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid #dcdcde;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #fff;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.mra-modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mra-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: #50575e;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.mra-modal-close:hover {
|
||||
color: #1d2327;
|
||||
}
|
||||
|
||||
.mra-modal-body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.mra-modal-body .widefat {
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Usages table inside modal */
|
||||
.mra-usages-table th {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Section headings inside modal (usage + external results) */
|
||||
.mra-section-heading {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1d2327;
|
||||
border-bottom: 1px solid #dcdcde;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.mra-section-heading:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Provider result row */
|
||||
.mra-provider-result {
|
||||
margin: 8px 0 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mra-provider-result small {
|
||||
color: #787c82;
|
||||
}
|
||||
|
||||
/* Domains table inside modal */
|
||||
.mra-domains-table {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mra-domains-table th {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Pricing reference table (settings page) */
|
||||
.mra-pricing-table {
|
||||
margin-top: 8px;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
color: #50575e;
|
||||
}
|
||||
|
||||
.mra-pricing-table th,
|
||||
.mra-pricing-table td {
|
||||
padding: 3px 16px 3px 0;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mra-pricing-table thead th {
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #dcdcde;
|
||||
}
|
||||
|
||||
/* Consensus block */
|
||||
.mra-consensus {
|
||||
background: #f0f8e8;
|
||||
border: 1px solid #c3e6a0;
|
||||
border-radius: 3px;
|
||||
padding: 10px 14px;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mra-consensus strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
67
assets/js/media-audit-admin.js
Normal file
67
assets/js/media-audit-admin.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/* global mraAdmin */
|
||||
( function ( $ ) {
|
||||
'use strict';
|
||||
|
||||
var $overlay, $modalTitle, $modalBody;
|
||||
|
||||
function openModal( attachmentId ) {
|
||||
$modalTitle.text( mraAdmin.i18n.detailsTitle );
|
||||
$modalBody.html( '<p>' + mraAdmin.i18n.loading + '</p>' );
|
||||
$overlay.addClass( 'is-open' );
|
||||
|
||||
$.ajax( {
|
||||
url: mraAdmin.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'mra_usage_details',
|
||||
attachment_id: attachmentId,
|
||||
nonce: mraAdmin.nonce
|
||||
},
|
||||
success: function ( response ) {
|
||||
if ( response.success ) {
|
||||
$modalTitle.text( response.data.title );
|
||||
$modalBody.html( response.data.html );
|
||||
} else {
|
||||
$modalBody.html( '<p>' + mraAdmin.i18n.errorLoading + '</p>' );
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
$modalBody.html( '<p>' + mraAdmin.i18n.errorLoading + '</p>' );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
$overlay.removeClass( 'is-open' );
|
||||
$modalBody.html( '' );
|
||||
}
|
||||
|
||||
$( function () {
|
||||
$overlay = $( '#mra-modal-overlay' );
|
||||
$modalTitle = $overlay.find( '.mra-modal-title' );
|
||||
$modalBody = $overlay.find( '.mra-modal-body' );
|
||||
|
||||
// Close on × button.
|
||||
$overlay.on( 'click', '.mra-modal-close', closeModal );
|
||||
|
||||
// Close when clicking the dark backdrop (not the box itself).
|
||||
$overlay.on( 'click', function ( e ) {
|
||||
if ( $( e.target ).is( $overlay ) ) {
|
||||
closeModal();
|
||||
}
|
||||
} );
|
||||
|
||||
// Close on Escape key.
|
||||
$( document ).on( 'keydown', function ( e ) {
|
||||
if ( 27 === e.which && $overlay.hasClass( 'is-open' ) ) {
|
||||
closeModal();
|
||||
}
|
||||
} );
|
||||
|
||||
// "View Details" links (row action or "N more" link in usages column).
|
||||
$( document ).on( 'click', '.mra-view-details', function ( e ) {
|
||||
e.preventDefault();
|
||||
openModal( $( this ).data( 'id' ) );
|
||||
} );
|
||||
} );
|
||||
} ( jQuery ) );
|
||||
34
changelog.txt
Normal file
34
changelog.txt
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
== Changelog ==
|
||||
|
||||
= 1.0.0 =
|
||||
|
||||
_Release date: 2026-05-01_
|
||||
|
||||
**Highlights**
|
||||
|
||||
* First stable release.
|
||||
|
||||
**Added**
|
||||
|
||||
* Internal media usage scanner: tracks featured images, inline content, and post meta references across all post types.
|
||||
* External reverse image search via Google Cloud Vision (Web Detection) and TinEye Commercial API.
|
||||
* Cross-provider consensus detection: surfaces domains confirmed by multiple providers.
|
||||
* WP-CLI commands: `wp mra install`, `wp mra status`, `wp mra scan-internal`, `wp mra scan-external`.
|
||||
* Admin audit list page with filtering by external scan status and pagination.
|
||||
* Settings page for API credentials, batch size, and rate limiting.
|
||||
* GDPR compliance: personal data export and erasure via WordPress privacy tools.
|
||||
* Spanish (Spain) and Catalan translations.
|
||||
* Action Scheduler integration for async batch processing with configurable batch size and per-minute rate limit.
|
||||
* `mra/internal/scanner/post_types`, `mra/internal/scanner/meta_keys`, and `mra/external/providers` extension filters.
|
||||
|
||||
**Compatibility**
|
||||
|
||||
* WordPress: 6.8 - 7.0
|
||||
* PHP: 8.2 - 8.4
|
||||
* WP-CLI: 2.x
|
||||
|
||||
**Tests**
|
||||
|
||||
* PHP Coding Standards: WPCS 3.x / PHPCS 3.x
|
||||
* PHPStan: level 9
|
||||
* PHPCompatibility: PHP 8.2 - 8.4
|
||||
705
includes/Admin/AuditPage.php
Normal file
705
includes/Admin/AuditPage.php
Normal file
|
|
@ -0,0 +1,705 @@
|
|||
<?php
|
||||
/**
|
||||
* Admin page controller for the Media Audit list view.
|
||||
*
|
||||
* @package MediaRightsAudit\Admin
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\Admin;
|
||||
|
||||
use MediaRightsAudit\External\ExternalScanner;
|
||||
use MediaRightsAudit\External\ResultsConsolidator;
|
||||
use MediaRightsAudit\Internal\AttachmentIndexer;
|
||||
use MediaRightsAudit\Internal\UsageScanner;
|
||||
|
||||
/**
|
||||
* Registers and renders the main Media Audit admin page.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Enqueue CSS/JS on the plugin's admin pages.
|
||||
* - Render the page: dashboard stats + WP_List_Table.
|
||||
* - Handle CSV bulk-action export (before page output).
|
||||
* - Serve AJAX usage-detail modal content.
|
||||
*/
|
||||
class AuditPage {
|
||||
|
||||
/**
|
||||
* Hook suffix assigned to the main menu page (populated on admin_menu).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $hook_suffix = '';
|
||||
|
||||
/**
|
||||
* Registers all hooks owned by this controller.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
|
||||
add_action( 'wp_ajax_mra_usage_details', array( $this, 'ajax_usage_details' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the hook suffix so asset enqueuing can limit to this page.
|
||||
*
|
||||
* @param string $suffix Hook suffix from add_menu_page / add_submenu_page.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function set_hook_suffix( string $suffix ): void {
|
||||
$this->hook_suffix = $suffix;
|
||||
add_action( 'load-' . $suffix, array( $this, 'handle_load' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires before the admin header is sent, allowing headers to be set.
|
||||
*
|
||||
* Handles bulk actions that need to modify headers (CSV export) or redirect
|
||||
* (run_external_scan, purge_external_data) before any HTML output begins.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_load(): void {
|
||||
$action = $this->get_current_bulk_action();
|
||||
|
||||
if ( 'export_csv' === $action ) {
|
||||
$this->handle_export_csv();
|
||||
} elseif ( 'run_external_scan' === $action ) {
|
||||
$this->handle_run_external_scan();
|
||||
} elseif ( 'purge_external_data' === $action ) {
|
||||
$this->handle_purge_external_data();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues CSS and JS only on the Media Audit admin page.
|
||||
*
|
||||
* @param string $hook_suffix Current admin page hook suffix.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function enqueue_assets( string $hook_suffix ): void {
|
||||
if ( '' !== $this->hook_suffix && $hook_suffix !== $this->hook_suffix ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$base = ROBOTSTXT_MEDIAAUDIT_PLUGIN_URL . 'assets/';
|
||||
$ver = ROBOTSTXT_MEDIAAUDIT_VERSION;
|
||||
|
||||
wp_enqueue_style(
|
||||
'mra-admin',
|
||||
$base . 'css/media-audit-admin.css',
|
||||
array(),
|
||||
$ver
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'mra-admin',
|
||||
$base . 'js/media-audit-admin.js',
|
||||
array( 'jquery' ),
|
||||
$ver,
|
||||
true
|
||||
);
|
||||
|
||||
wp_localize_script(
|
||||
'mra-admin',
|
||||
'mraAdmin',
|
||||
array(
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'nonce' => wp_create_nonce( 'mra_usage_details' ),
|
||||
'i18n' => array(
|
||||
'loading' => __( 'Loading…', 'robotstxt-mediaaudit' ),
|
||||
'errorLoading' => __( 'Error loading details.', 'robotstxt-mediaaudit' ),
|
||||
'noUsages' => __( 'This image is not used in any post.', 'robotstxt-mediaaudit' ),
|
||||
'detailsTitle' => __( 'Usage Details', 'robotstxt-mediaaudit' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the full Media Audit admin page.
|
||||
*
|
||||
* Bulk actions (CSV export, external scan, purge) are intercepted earlier in
|
||||
* handle_load() via the load-{hook_suffix} action, before any HTML is sent.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render(): void {
|
||||
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-mediaaudit' ) );
|
||||
}
|
||||
|
||||
$table = new MediaListTable();
|
||||
$table->prepare_items();
|
||||
|
||||
echo '<div class="wrap">';
|
||||
echo '<h1>' . esc_html__( 'Media Audit', 'robotstxt-mediaaudit' ) . '</h1>';
|
||||
|
||||
$this->render_dashboard_stats();
|
||||
|
||||
echo '<form method="get">';
|
||||
echo '<input type="hidden" name="page" value="robotstxt-mediaaudit" />';
|
||||
$table->search_box( __( 'Search', 'robotstxt-mediaaudit' ), 'mra-search' );
|
||||
$table->display();
|
||||
echo '</form>';
|
||||
|
||||
// Modal overlay (hidden by default, opened via JS).
|
||||
echo '<div id="mra-modal-overlay" class="mra-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mra-modal-title">';
|
||||
echo ' <div class="mra-modal">';
|
||||
echo ' <div class="mra-modal-header">';
|
||||
echo ' <h3 id="mra-modal-title" class="mra-modal-title">' . esc_html__( 'Usage Details', 'robotstxt-mediaaudit' ) . '</h3>';
|
||||
echo ' <button type="button" class="mra-modal-close" aria-label="' . esc_attr__( 'Close', 'robotstxt-mediaaudit' ) . '">×</button>';
|
||||
echo ' </div>';
|
||||
echo ' <div class="mra-modal-body"></div>';
|
||||
echo ' </div>';
|
||||
echo '</div>';
|
||||
|
||||
echo '</div>'; // .wrap
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler: returns formatted HTML for the usage-detail modal.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function ajax_usage_details(): void {
|
||||
check_ajax_referer( 'mra_usage_details', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) ) );
|
||||
}
|
||||
|
||||
$attachment_id = isset( $_POST['attachment_id'] ) && is_string( $_POST['attachment_id'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['attachment_id'] ) ) : 0;
|
||||
if ( $attachment_id <= 0 ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Invalid attachment ID.', 'robotstxt-mediaaudit' ) ) );
|
||||
}
|
||||
|
||||
$usages = MediaListTable::fetch_usages( array( $attachment_id ) );
|
||||
$title = get_the_title( $attachment_id );
|
||||
|
||||
// Build usage section.
|
||||
$context_labels = array(
|
||||
'featured' => __( 'Featured Image', 'robotstxt-mediaaudit' ),
|
||||
'content' => __( 'Post Content', 'robotstxt-mediaaudit' ),
|
||||
'meta' => __( 'Custom Field', 'robotstxt-mediaaudit' ),
|
||||
);
|
||||
|
||||
if ( empty( $usages ) ) {
|
||||
$usage_html = '<p>' . esc_html__( 'This image is not used in any post.', 'robotstxt-mediaaudit' ) . '</p>';
|
||||
} else {
|
||||
$usage_html = '<table class="widefat mra-usages-table">';
|
||||
$usage_html .= '<thead><tr>';
|
||||
$usage_html .= '<th>' . esc_html__( 'Post', 'robotstxt-mediaaudit' ) . '</th>';
|
||||
$usage_html .= '<th>' . esc_html__( 'Type', 'robotstxt-mediaaudit' ) . '</th>';
|
||||
$usage_html .= '<th>' . esc_html__( 'Context', 'robotstxt-mediaaudit' ) . '</th>';
|
||||
$usage_html .= '<th>' . esc_html__( 'Status', 'robotstxt-mediaaudit' ) . '</th>';
|
||||
$usage_html .= '</tr></thead><tbody>';
|
||||
|
||||
foreach ( $usages as $u ) {
|
||||
$pid_val = $u['post_id'] ?? null;
|
||||
$post_id = is_numeric( $pid_val ) ? (int) $pid_val : 0;
|
||||
$t_raw = $u['post_title'] ?? '';
|
||||
$post_title = is_string( $t_raw ) && '' !== $t_raw ? $t_raw : sprintf( '#%d', $post_id );
|
||||
$pt_val = $u['post_type'] ?? null;
|
||||
$post_type = is_string( $pt_val ) ? $pt_val : '';
|
||||
$ctx_val = $u['context'] ?? null;
|
||||
$context = is_string( $ctx_val ) ? $ctx_val : '';
|
||||
$status_val = $u['post_status'] ?? null;
|
||||
$status = is_string( $status_val ) ? $status_val : '';
|
||||
$mk_val = $u['meta_key'] ?? null;
|
||||
$meta_key = is_string( $mk_val ) ? $mk_val : '';
|
||||
|
||||
$ctx_label = isset( $context_labels[ $context ] ) ? $context_labels[ $context ] : esc_html( $context );
|
||||
if ( '' !== $meta_key ) {
|
||||
$ctx_label .= ' <code>' . esc_html( $meta_key ) . '</code>';
|
||||
}
|
||||
|
||||
$edit_link = get_edit_post_link( $post_id );
|
||||
$post_cell = $edit_link
|
||||
? sprintf( '<a href="%s">%s</a>', esc_url( $edit_link ), esc_html( $post_title ) )
|
||||
: esc_html( $post_title );
|
||||
|
||||
$usage_html .= '<tr>';
|
||||
$usage_html .= '<td>' . $post_cell . '</td>';
|
||||
$usage_html .= '<td>' . esc_html( $post_type ) . '</td>';
|
||||
$usage_html .= '<td>' . $ctx_label . '</td>';
|
||||
$usage_html .= '<td>' . esc_html( $status ) . '</td>';
|
||||
$usage_html .= '</tr>';
|
||||
}
|
||||
|
||||
$usage_html .= '</tbody></table>';
|
||||
}
|
||||
|
||||
// Build external results section.
|
||||
$external_html = $this->build_external_results_html( $attachment_id );
|
||||
|
||||
// Combine sections; add headings only when both sections are present.
|
||||
if ( '' !== $external_html ) {
|
||||
$html = '<h4 class="mra-section-heading">' . esc_html__( 'Internal Usage', 'robotstxt-mediaaudit' ) . '</h4>';
|
||||
$html .= $usage_html;
|
||||
$html .= $external_html;
|
||||
} else {
|
||||
$html = $usage_html;
|
||||
}
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'title' => esc_html( $title ? $title : sprintf( '#%d', $attachment_id ) ),
|
||||
'html' => $html,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the HTML for the external scan results section of the detail modal.
|
||||
*
|
||||
* Returns an empty string when no external scan data exists for the attachment.
|
||||
*
|
||||
* @param int $attachment_id WordPress attachment ID.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function build_external_results_html( int $attachment_id ): string {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT provider, match_count, top_domains, created_at
|
||||
FROM {$wpdb->prefix}mra_external_results
|
||||
WHERE attachment_id = %d
|
||||
ORDER BY provider ASC",
|
||||
$attachment_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( empty( $rows ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$provider_names = array(
|
||||
'google_vision' => 'Google Cloud Vision',
|
||||
'tineye' => 'TinEye',
|
||||
);
|
||||
|
||||
$html = '<h4 class="mra-section-heading">' . esc_html__( 'External Scan Results', 'robotstxt-mediaaudit' ) . '</h4>';
|
||||
|
||||
foreach ( $rows as $row ) {
|
||||
if ( ! is_array( $row ) ) {
|
||||
continue;
|
||||
}
|
||||
$slug_val = $row['provider'] ?? null;
|
||||
$slug = is_string( $slug_val ) ? $slug_val : '';
|
||||
$name = isset( $provider_names[ $slug ] ) ? $provider_names[ $slug ] : ucwords( str_replace( '_', ' ', $slug ) );
|
||||
$mc_val = $row['match_count'] ?? null;
|
||||
$match_count = is_numeric( $mc_val ) ? (int) $mc_val : 0;
|
||||
$at_val = $row['created_at'] ?? null;
|
||||
$scanned_at = is_string( $at_val ) ? $at_val : '';
|
||||
$td_val = $row['top_domains'] ?? null;
|
||||
$td_raw = is_string( $td_val ) ? json_decode( $td_val, true ) : null;
|
||||
$domains = is_array( $td_raw ) ? $td_raw : array();
|
||||
|
||||
$html .= '<div class="mra-provider-result">';
|
||||
$html .= '<strong>' . esc_html( $name ) . '</strong> — ';
|
||||
|
||||
if ( $match_count > 0 ) {
|
||||
$html .= esc_html(
|
||||
sprintf(
|
||||
/* translators: %d: number of external matches */
|
||||
_n( '%d match', '%d matches', $match_count, 'robotstxt-mediaaudit' ),
|
||||
$match_count
|
||||
)
|
||||
);
|
||||
} else {
|
||||
$html .= esc_html__( 'No matches', 'robotstxt-mediaaudit' );
|
||||
}
|
||||
|
||||
if ( '' !== $scanned_at ) {
|
||||
$html .= ' <small>' . esc_html( $scanned_at ) . '</small>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
if ( ! empty( $domains ) ) {
|
||||
$html .= '<table class="widefat mra-domains-table">';
|
||||
$html .= '<thead><tr>';
|
||||
$html .= '<th>' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . '</th>';
|
||||
$html .= '<th>' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . '</th>';
|
||||
$html .= '</tr></thead><tbody>';
|
||||
foreach ( $domains as $domain => $count ) {
|
||||
if ( ! is_string( $domain ) ) {
|
||||
continue;
|
||||
}
|
||||
$html .= sprintf(
|
||||
'<tr><td>%s</td><td>%s</td></tr>',
|
||||
esc_html( $domain ),
|
||||
esc_html( (string) ( is_numeric( $count ) ? (int) $count : 0 ) )
|
||||
);
|
||||
}
|
||||
$html .= '</tbody></table>';
|
||||
}
|
||||
}
|
||||
|
||||
// Consensus section: only when multiple providers have results.
|
||||
if ( count( $rows ) > 1 ) {
|
||||
$consensus = ResultsConsolidator::get_consensus_domains( $attachment_id );
|
||||
if ( ! empty( $consensus ) ) {
|
||||
$html .= '<div class="mra-consensus">';
|
||||
$html .= '<strong>' . esc_html__( 'Consensus Domains', 'robotstxt-mediaaudit' ) . '</strong>';
|
||||
$html .= '<ul class="mra-usage-list">';
|
||||
foreach ( $consensus as $domain => $count ) {
|
||||
$html .= '<li>' . esc_html( $domain ) . ' <small>(' . esc_html(
|
||||
sprintf(
|
||||
/* translators: %d: number of providers that agree */
|
||||
_n( '%d provider', '%d providers', $count, 'robotstxt-mediaaudit' ),
|
||||
$count
|
||||
)
|
||||
) . ')</small></li>';
|
||||
}
|
||||
$html .= '</ul></div>';
|
||||
}
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Renders the dashboard stats strip.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_dashboard_stats(): void {
|
||||
$stats = self::get_dashboard_stats();
|
||||
|
||||
$scanned_pct = $stats['total_indexed'] > 0
|
||||
? round( $stats['total_scanned'] / $stats['total_indexed'] * 100 )
|
||||
: 0;
|
||||
|
||||
echo '<div class="mra-stats">';
|
||||
|
||||
$this->stat_box(
|
||||
$stats['total_indexed'],
|
||||
__( 'In Index', 'robotstxt-mediaaudit' ),
|
||||
$stats['pending_index'] > 0
|
||||
? sprintf(
|
||||
/* translators: %d: count of images not yet indexed */
|
||||
__( '%d not yet indexed', 'robotstxt-mediaaudit' ),
|
||||
$stats['pending_index']
|
||||
)
|
||||
: __( 'All indexed', 'robotstxt-mediaaudit' )
|
||||
);
|
||||
|
||||
$this->stat_box(
|
||||
$stats['total_scanned'],
|
||||
__( 'Scanned', 'robotstxt-mediaaudit' ),
|
||||
sprintf(
|
||||
/* translators: %d: percentage */
|
||||
__( '%d%% of index', 'robotstxt-mediaaudit' ),
|
||||
$scanned_pct
|
||||
)
|
||||
);
|
||||
|
||||
$this->stat_box(
|
||||
$stats['total_used'],
|
||||
__( 'Used', 'robotstxt-mediaaudit' ),
|
||||
''
|
||||
);
|
||||
|
||||
$this->stat_box(
|
||||
$stats['total_unused'],
|
||||
__( 'Unused', 'robotstxt-mediaaudit' ),
|
||||
''
|
||||
);
|
||||
|
||||
if ( $stats['total_matches'] > 0 ) {
|
||||
$this->stat_box(
|
||||
$stats['total_matches'],
|
||||
__( 'External Matches', 'robotstxt-mediaaudit' ),
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Outputs a single stat box.
|
||||
*
|
||||
* @param int $value Main number.
|
||||
* @param string $label Short label.
|
||||
* @param string $sub Optional sub-label.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function stat_box( int $value, string $label, string $sub ): void {
|
||||
echo '<div class="mra-stat-box">';
|
||||
echo '<strong>' . esc_html( number_format_i18n( $value ) ) . '</strong>';
|
||||
echo '<span>' . esc_html( $label ) . '</span>';
|
||||
if ( '' !== $sub ) {
|
||||
echo '<small>' . esc_html( $sub ) . '</small>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Outputs CSV for selected attachment IDs and terminates the request.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function handle_export_csv(): void {
|
||||
check_admin_referer( 'bulk-mra-attachments' );
|
||||
|
||||
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||
wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) );
|
||||
}
|
||||
|
||||
$ids = $this->collect_attachment_ids();
|
||||
$rows = $this->build_csv_rows( $ids );
|
||||
|
||||
$filename = 'media-audit-' . gmdate( 'Y-m-d' ) . '.csv';
|
||||
|
||||
header( 'Content-Type: text/csv; charset=utf-8' );
|
||||
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
|
||||
header( 'Pragma: no-cache' );
|
||||
|
||||
$out = fopen( 'php://output', 'w' );
|
||||
if ( false === $out ) {
|
||||
wp_die( esc_html__( 'Could not open output stream.', 'robotstxt-mediaaudit' ) );
|
||||
}
|
||||
|
||||
// BOM for Excel UTF-8 compatibility.
|
||||
fwrite( $out, "\xEF\xBB\xBF" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
|
||||
|
||||
fputcsv(
|
||||
$out,
|
||||
array(
|
||||
__( 'Attachment ID', 'robotstxt-mediaaudit' ),
|
||||
__( 'Filename', 'robotstxt-mediaaudit' ),
|
||||
__( 'File URL', 'robotstxt-mediaaudit' ),
|
||||
__( 'MIME Type', 'robotstxt-mediaaudit' ),
|
||||
__( 'File Size (bytes)', 'robotstxt-mediaaudit' ),
|
||||
__( 'External Status', 'robotstxt-mediaaudit' ),
|
||||
__( 'Usage Count', 'robotstxt-mediaaudit' ),
|
||||
__( 'Used In (post titles)', 'robotstxt-mediaaudit' ),
|
||||
)
|
||||
);
|
||||
|
||||
foreach ( $rows as $row ) {
|
||||
fputcsv( $out, $row );
|
||||
}
|
||||
|
||||
fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues selected attachments for external scanning and redirects.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function handle_run_external_scan(): void {
|
||||
check_admin_referer( 'bulk-mra-attachments' );
|
||||
|
||||
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||
wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) );
|
||||
}
|
||||
|
||||
$ids = $this->collect_attachment_ids();
|
||||
|
||||
if ( ! empty( $ids ) ) {
|
||||
ExternalScanner::queue_attachments( $ids );
|
||||
} else {
|
||||
ExternalScanner::schedule();
|
||||
}
|
||||
|
||||
wp_safe_redirect(
|
||||
add_query_arg(
|
||||
array(
|
||||
'page' => 'robotstxt-mediaaudit',
|
||||
'mra_notice' => 'scan_scheduled',
|
||||
),
|
||||
admin_url( 'admin.php' )
|
||||
)
|
||||
);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Purges external scan data for selected attachments and redirects.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function handle_purge_external_data(): void {
|
||||
check_admin_referer( 'bulk-mra-attachments' );
|
||||
|
||||
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||
wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) );
|
||||
}
|
||||
|
||||
$ids = $this->collect_attachment_ids();
|
||||
|
||||
if ( ! empty( $ids ) ) {
|
||||
ExternalScanner::purge( $ids );
|
||||
}
|
||||
|
||||
wp_safe_redirect(
|
||||
add_query_arg(
|
||||
array(
|
||||
'page' => 'robotstxt-mediaaudit',
|
||||
'mra_notice' => 'purge_done',
|
||||
),
|
||||
admin_url( 'admin.php' )
|
||||
)
|
||||
);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects and sanitises attachment IDs from the current bulk action POST data.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function collect_attachment_ids(): array {
|
||||
$ids = array();
|
||||
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended -- nonce verified by all callers.
|
||||
if ( isset( $_REQUEST['attachment_id'] ) && is_array( $_REQUEST['attachment_id'] ) ) {
|
||||
foreach ( $_REQUEST['attachment_id'] as $raw ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||
$id = is_numeric( $raw ) ? (int) $raw : 0;
|
||||
if ( $id > 0 ) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds CSV data rows for the given attachment IDs.
|
||||
*
|
||||
* If $ids is empty, exports all indexed attachments.
|
||||
*
|
||||
* @param array<int, int> $ids Attachment IDs to export (empty = all).
|
||||
*
|
||||
* @return array<int, array<int, string>>
|
||||
*/
|
||||
private function build_csv_rows( array $ids ): array {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
|
||||
if ( empty( $ids ) ) {
|
||||
$index_rows = $wpdb->get_results(
|
||||
"SELECT * FROM {$wpdb->prefix}mra_media_index ORDER BY attachment_id ASC",
|
||||
ARRAY_A
|
||||
);
|
||||
} else {
|
||||
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
|
||||
$index_rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}mra_media_index WHERE attachment_id IN ({$placeholders}) ORDER BY attachment_id ASC",
|
||||
...$ids
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
|
||||
if ( ! $index_rows ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$page_ids = array_map(
|
||||
static function ( $v ) {
|
||||
return (int) $v;
|
||||
},
|
||||
array_column( $index_rows, 'attachment_id' )
|
||||
);
|
||||
$usages = MediaListTable::fetch_usages( $page_ids );
|
||||
|
||||
$usage_titles = array();
|
||||
foreach ( $usages as $u ) {
|
||||
$aid_val = $u['attachment_id'] ?? null;
|
||||
$aid = is_numeric( $aid_val ) ? (int) $aid_val : 0;
|
||||
$t_raw = $u['post_title'] ?? '';
|
||||
$pid_raw = $u['post_id'] ?? null;
|
||||
$title = is_string( $t_raw ) && '' !== $t_raw ? $t_raw : sprintf( '#%d', is_numeric( $pid_raw ) ? (int) $pid_raw : 0 );
|
||||
|
||||
$usage_titles[ $aid ][] = $title;
|
||||
}
|
||||
|
||||
$output = array();
|
||||
foreach ( $index_rows as $r ) {
|
||||
$aid = (int) ( $r['attachment_id'] ?? 0 );
|
||||
$titles = isset( $usage_titles[ $aid ] ) ? implode( '; ', $usage_titles[ $aid ] ) : '';
|
||||
|
||||
$fn_raw = $r['file_name'] ?? '';
|
||||
$url_raw = $r['file_url'] ?? '';
|
||||
$mt_raw = $r['mime_type'] ?? '';
|
||||
$fs_raw = $r['file_size'] ?? '';
|
||||
$es_raw = $r['external_status'] ?? '';
|
||||
|
||||
$output[] = array(
|
||||
(string) $aid,
|
||||
is_string( $fn_raw ) ? $fn_raw : '',
|
||||
is_string( $url_raw ) ? $url_raw : '',
|
||||
is_string( $mt_raw ) ? $mt_raw : '',
|
||||
is_string( $fs_raw ) ? $fs_raw : '',
|
||||
is_string( $es_raw ) ? $es_raw : '',
|
||||
(string) count( $usage_titles[ $aid ] ?? array() ),
|
||||
$titles,
|
||||
);
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the active bulk action from the form submission.
|
||||
*
|
||||
* @return string Action name, or '' if none.
|
||||
*/
|
||||
private function get_current_bulk_action(): string {
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended
|
||||
$action = isset( $_REQUEST['action'] ) && is_string( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : '';
|
||||
if ( '-1' === $action ) {
|
||||
$action = isset( $_REQUEST['action2'] ) && is_string( $_REQUEST['action2'] ) ? sanitize_key( wp_unslash( $_REQUEST['action2'] ) ) : '';
|
||||
}
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended
|
||||
return $action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries and returns dashboard statistics.
|
||||
*
|
||||
* @return array{total_indexed: int, total_scanned: int, total_used: int, total_unused: int, total_matches: int, pending_index: int}
|
||||
*/
|
||||
private static function get_dashboard_stats(): array {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery
|
||||
$total_indexed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index" );
|
||||
$total_scanned = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index WHERE internal_scanned_at IS NOT NULL" );
|
||||
$total_used = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT attachment_id) FROM {$wpdb->prefix}mra_media_usage" );
|
||||
$total_matches = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index WHERE external_status = 'matches'" );
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery
|
||||
|
||||
return array(
|
||||
'total_indexed' => $total_indexed,
|
||||
'total_scanned' => $total_scanned,
|
||||
'total_used' => $total_used,
|
||||
'total_unused' => max( 0, $total_indexed - $total_used ),
|
||||
'total_matches' => $total_matches,
|
||||
'pending_index' => AttachmentIndexer::get_pending_count(),
|
||||
);
|
||||
}
|
||||
}
|
||||
570
includes/Admin/MediaListTable.php
Normal file
570
includes/Admin/MediaListTable.php
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
<?php
|
||||
/**
|
||||
* WP_List_Table subclass for the Media Audit list.
|
||||
*
|
||||
* @package MediaRightsAudit\Admin
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\Admin;
|
||||
|
||||
if ( ! class_exists( 'WP_List_Table' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the indexed media library with usage data.
|
||||
*
|
||||
* Columns: thumbnail, filename, attachment_id, usage_count, usages, external_status.
|
||||
*/
|
||||
class MediaListTable extends \WP_List_Table {
|
||||
|
||||
/**
|
||||
* Allowed values for the external_status column.
|
||||
*/
|
||||
const EXTERNAL_STATUSES = array( 'pending', 'queued', 'scanned', 'matches', 'error' );
|
||||
|
||||
/**
|
||||
* Initialises the list table with singular/plural labels and AJAX support.
|
||||
*/
|
||||
public function __construct() {
|
||||
parent::__construct(
|
||||
array(
|
||||
'singular' => 'mra-attachment',
|
||||
'plural' => 'mra-attachments',
|
||||
'ajax' => false,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// WP_List_Table interface
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns all column headers.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function get_columns() {
|
||||
return array(
|
||||
'cb' => '<input type="checkbox" />',
|
||||
'thumbnail' => __( 'Thumbnail', 'robotstxt-mediaaudit' ),
|
||||
'filename' => __( 'Filename', 'robotstxt-mediaaudit' ),
|
||||
'attachment_id' => __( 'ID', 'robotstxt-mediaaudit' ),
|
||||
'usage_count' => __( 'Usage Count', 'robotstxt-mediaaudit' ),
|
||||
'usages' => __( 'Usages', 'robotstxt-mediaaudit' ),
|
||||
'external_status' => __( 'External Status', 'robotstxt-mediaaudit' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns sortable columns.
|
||||
*
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
protected function get_sortable_columns() {
|
||||
return array(
|
||||
'attachment_id' => array( 'attachment_id', true ),
|
||||
'usage_count' => array( 'usage_count', false ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns bulk actions.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function get_bulk_actions() {
|
||||
return array(
|
||||
'run_external_scan' => __( 'Run External Scan', 'robotstxt-mediaaudit' ),
|
||||
'purge_external_data' => __( 'Purge External Data', 'robotstxt-mediaaudit' ),
|
||||
'export_csv' => __( 'Export CSV', 'robotstxt-mediaaudit' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback renderer for unrecognised columns.
|
||||
*
|
||||
* @param array<string, mixed> $item Row data.
|
||||
* @param string $column_name Column slug.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function column_default( $item, $column_name ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the checkbox column.
|
||||
*
|
||||
* @param array<string, mixed> $item Row data.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function column_cb( $item ) {
|
||||
$aid = $item['attachment_id'];
|
||||
return sprintf(
|
||||
'<input type="checkbox" name="attachment_id[]" value="%d" />',
|
||||
is_numeric( $aid ) ? (int) $aid : 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a 40×40 thumbnail linked to the attachment.
|
||||
*
|
||||
* @param array<string, mixed> $item Row data.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function column_thumbnail( $item ) {
|
||||
$aid_val = $item['attachment_id'];
|
||||
$url_val = $item['file_url'];
|
||||
$id = is_numeric( $aid_val ) ? (int) $aid_val : 0;
|
||||
$url = esc_url( is_string( $url_val ) ? $url_val : '' );
|
||||
|
||||
$thumb = wp_get_attachment_image(
|
||||
$id,
|
||||
array( 40, 40 ),
|
||||
false,
|
||||
array( 'class' => 'mra-thumb' )
|
||||
);
|
||||
|
||||
if ( ! $thumb ) {
|
||||
$thumb = '<span class="dashicons dashicons-format-image" style="font-size:40px;line-height:1;"></span>';
|
||||
}
|
||||
|
||||
return sprintf( '<a href="%s" target="_blank">%s</a>', $url, $thumb );
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the filename column with a link to the original file.
|
||||
*
|
||||
* @param array<string, mixed> $item Row data.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function column_filename( $item ) {
|
||||
$aid_val = $item['attachment_id'];
|
||||
$fn_val = $item['file_name'];
|
||||
$url_val = $item['file_url'];
|
||||
$id = is_numeric( $aid_val ) ? (int) $aid_val : 0;
|
||||
$filename = esc_html( is_string( $fn_val ) ? $fn_val : '' );
|
||||
$url = esc_url( is_string( $url_val ) ? $url_val : '' );
|
||||
|
||||
$title = sprintf(
|
||||
'<a href="%s" target="_blank">%s</a>',
|
||||
$url,
|
||||
( '' !== $filename ? $filename : '—' )
|
||||
);
|
||||
|
||||
$raw_edit_link = get_edit_post_link( $id );
|
||||
$actions = array(
|
||||
'edit' => sprintf(
|
||||
'<a href="%s">%s</a>',
|
||||
esc_url( is_string( $raw_edit_link ) ? $raw_edit_link : '' ),
|
||||
__( 'Edit', 'robotstxt-mediaaudit' )
|
||||
),
|
||||
'view_details' => sprintf(
|
||||
'<a href="#" class="mra-view-details" data-id="%d">%s</a>',
|
||||
$id,
|
||||
__( 'View Details', 'robotstxt-mediaaudit' )
|
||||
),
|
||||
);
|
||||
|
||||
return $title . $this->row_actions( $actions );
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the attachment ID column.
|
||||
*
|
||||
* @param array<string, mixed> $item Row data.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function column_attachment_id( $item ) {
|
||||
$aid_val = $item['attachment_id'];
|
||||
return esc_html( (string) ( is_numeric( $aid_val ) ? (int) $aid_val : 0 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the usage count column.
|
||||
*
|
||||
* @param array<string, mixed> $item Row data.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function column_usage_count( $item ) {
|
||||
$cnt_val = $item['usage_count'];
|
||||
$count = is_numeric( $cnt_val ) ? (int) $cnt_val : 0;
|
||||
if ( 0 === $count ) {
|
||||
return '<span style="color:#999">' . esc_html__( 'Unused', 'robotstxt-mediaaudit' ) . '</span>';
|
||||
}
|
||||
return esc_html( (string) $count );
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the usages column with inline post links (max 3) and a modal link for more.
|
||||
*
|
||||
* @param array<string, mixed> $item Row data.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function column_usages( $item ) {
|
||||
$aid_val = $item['attachment_id'];
|
||||
$cnt_val = $item['usage_count'];
|
||||
$id = is_numeric( $aid_val ) ? (int) $aid_val : 0;
|
||||
$count = is_numeric( $cnt_val ) ? (int) $cnt_val : 0;
|
||||
|
||||
if ( 0 === $count ) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$usages = isset( $item['usages_data'] ) && is_array( $item['usages_data'] )
|
||||
? $item['usages_data']
|
||||
: array();
|
||||
|
||||
$shown = array_slice( $usages, 0, 3 );
|
||||
$html = '<ul class="mra-usage-list">';
|
||||
foreach ( $shown as $u ) {
|
||||
if ( ! is_array( $u ) ) {
|
||||
continue;
|
||||
}
|
||||
$pid_raw = $u['post_id'] ?? 0;
|
||||
$post_id = is_numeric( $pid_raw ) ? (int) $pid_raw : 0;
|
||||
$t = $u['post_title'] ?? '';
|
||||
$raw_title = is_string( $t ) ? $t : '';
|
||||
$post_title = esc_html( '' !== $raw_title ? $raw_title : __( '(no title)', 'robotstxt-mediaaudit' ) );
|
||||
$edit_link = get_edit_post_link( $post_id );
|
||||
if ( $edit_link ) {
|
||||
$html .= sprintf( '<li><a href="%s">%s</a></li>', esc_url( $edit_link ), $post_title );
|
||||
} else {
|
||||
$html .= '<li>' . $post_title . '</li>';
|
||||
}
|
||||
}
|
||||
|
||||
if ( $count > 3 ) {
|
||||
$html .= sprintf(
|
||||
'<li><a href="#" class="mra-view-details" data-id="%d">%s</a></li>',
|
||||
$id,
|
||||
sprintf(
|
||||
/* translators: %d: number of additional usages */
|
||||
esc_html__( '+ %d more…', 'robotstxt-mediaaudit' ),
|
||||
$count - 3
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$html .= '</ul>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the external status badge.
|
||||
*
|
||||
* @param array<string, mixed> $item Row data.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function column_external_status( $item ) {
|
||||
$es_val = $item['external_status'];
|
||||
$status = is_string( $es_val ) ? $es_val : '';
|
||||
|
||||
$labels = array(
|
||||
'pending' => __( 'Pending', 'robotstxt-mediaaudit' ),
|
||||
'queued' => __( 'Queued', 'robotstxt-mediaaudit' ),
|
||||
'scanned' => __( 'Scanned', 'robotstxt-mediaaudit' ),
|
||||
'matches' => __( 'Matches Found', 'robotstxt-mediaaudit' ),
|
||||
'error' => __( 'Error', 'robotstxt-mediaaudit' ),
|
||||
);
|
||||
|
||||
$label = isset( $labels[ $status ] ) ? $labels[ $status ] : ucfirst( $status );
|
||||
|
||||
$out = sprintf(
|
||||
'<span class="mra-badge mra-badge-%s">%s</span>',
|
||||
esc_attr( $status ),
|
||||
esc_html( $label )
|
||||
);
|
||||
|
||||
if ( 'matches' === $status ) {
|
||||
$mc_val = $item['total_match_count'] ?? null;
|
||||
$match_count = is_numeric( $mc_val ) ? (int) $mc_val : 0;
|
||||
if ( $match_count > 0 ) {
|
||||
$out .= sprintf(
|
||||
' <span class="mra-badge mra-badge-match-count">%s</span>',
|
||||
esc_html( number_format_i18n( $match_count ) )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ( in_array( $status, array( 'scanned', 'matches' ), true ) ) {
|
||||
$aid_val = $item['attachment_id'];
|
||||
$aid = is_numeric( $aid_val ) ? (int) $aid_val : 0;
|
||||
$out .= sprintf(
|
||||
' <a href="#" class="mra-view-details" data-id="%d">%s</a>',
|
||||
$aid,
|
||||
esc_html__( 'View Results', 'robotstxt-mediaaudit' )
|
||||
);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders filter controls above the table.
|
||||
*
|
||||
* @param string $which 'top' or 'bottom'.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function extra_tablenav( $which ) {
|
||||
if ( 'top' !== $which ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended
|
||||
$current_status = isset( $_REQUEST['mra_external_status'] ) && is_string( $_REQUEST['mra_external_status'] ) ? sanitize_key( wp_unslash( $_REQUEST['mra_external_status'] ) ) : '';
|
||||
$current_post_type = isset( $_REQUEST['mra_post_type'] ) && is_string( $_REQUEST['mra_post_type'] ) ? sanitize_key( wp_unslash( $_REQUEST['mra_post_type'] ) ) : '';
|
||||
$show_unused = ! empty( $_REQUEST['mra_unused'] );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
|
||||
$post_types = self::get_used_post_types();
|
||||
|
||||
echo '<div class="alignleft actions mra-filters">';
|
||||
|
||||
// External status filter.
|
||||
echo '<select name="mra_external_status">';
|
||||
echo '<option value="">' . esc_html__( 'All statuses', 'robotstxt-mediaaudit' ) . '</option>';
|
||||
foreach ( self::EXTERNAL_STATUSES as $s ) {
|
||||
printf(
|
||||
'<option value="%s"%s>%s</option>',
|
||||
esc_attr( $s ),
|
||||
selected( $current_status, $s, false ),
|
||||
esc_html( ucfirst( $s ) )
|
||||
);
|
||||
}
|
||||
echo '</select>';
|
||||
|
||||
// Post type filter.
|
||||
if ( $post_types ) {
|
||||
echo '<select name="mra_post_type">';
|
||||
echo '<option value="">' . esc_html__( 'All post types', 'robotstxt-mediaaudit' ) . '</option>';
|
||||
foreach ( $post_types as $pt ) {
|
||||
printf(
|
||||
'<option value="%s"%s>%s</option>',
|
||||
esc_attr( $pt ),
|
||||
selected( $current_post_type, $pt, false ),
|
||||
esc_html( $pt )
|
||||
);
|
||||
}
|
||||
echo '</select>';
|
||||
}
|
||||
|
||||
// Unused toggle.
|
||||
echo '<label style="margin-left:8px">';
|
||||
echo '<input type="checkbox" name="mra_unused" value="1"' . checked( $show_unused, true, false ) . ' /> ';
|
||||
echo esc_html__( 'Unused only', 'robotstxt-mediaaudit' );
|
||||
echo '</label>';
|
||||
|
||||
submit_button( __( 'Filter', 'robotstxt-mediaaudit' ), 'button', 'mra_filter', false );
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Message shown when the index is empty.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function no_items() {
|
||||
esc_html_e( 'No indexed attachments found. Run wp mra scan-internal to populate the index.', 'robotstxt-mediaaudit' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the database and sets $this->items.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function prepare_items() {
|
||||
global $wpdb;
|
||||
|
||||
$per_page = 20;
|
||||
$paged = $this->get_pagenum();
|
||||
$offset = ( $paged - 1 ) * $per_page;
|
||||
|
||||
// Sorting.
|
||||
$allowed_orderby = array( 'attachment_id', 'usage_count' );
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended
|
||||
$orderby_raw = isset( $_REQUEST['orderby'] ) && is_string( $_REQUEST['orderby'] ) ? sanitize_key( wp_unslash( $_REQUEST['orderby'] ) ) : '';
|
||||
$orderby = in_array( $orderby_raw, $allowed_orderby, true ) ? $orderby_raw : 'attachment_id';
|
||||
$order_raw = strtoupper( isset( $_REQUEST['order'] ) && is_string( $_REQUEST['order'] ) ? sanitize_key( wp_unslash( $_REQUEST['order'] ) ) : '' );
|
||||
$order = ( 'DESC' === $order_raw ) ? 'DESC' : 'ASC';
|
||||
|
||||
// Filters.
|
||||
$search = isset( $_REQUEST['s'] ) && is_string( $_REQUEST['s'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) : '';
|
||||
$external_status = isset( $_REQUEST['mra_external_status'] ) && is_string( $_REQUEST['mra_external_status'] ) ? sanitize_key( wp_unslash( $_REQUEST['mra_external_status'] ) ) : '';
|
||||
$filter_post_type = isset( $_REQUEST['mra_post_type'] ) && is_string( $_REQUEST['mra_post_type'] ) ? sanitize_key( wp_unslash( $_REQUEST['mra_post_type'] ) ) : '';
|
||||
$show_unused = ! empty( $_REQUEST['mra_unused'] );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
|
||||
if ( ! in_array( $external_status, self::EXTERNAL_STATUSES, true ) ) {
|
||||
$external_status = '';
|
||||
}
|
||||
|
||||
// Build WHERE clause.
|
||||
$where_parts = array( '1=1' );
|
||||
$prepare_args = array();
|
||||
|
||||
if ( '' !== $search ) {
|
||||
$where_parts[] = 'i.file_name LIKE %s';
|
||||
$prepare_args[] = '%' . $wpdb->esc_like( $search ) . '%';
|
||||
}
|
||||
|
||||
if ( '' !== $external_status ) {
|
||||
$where_parts[] = 'i.external_status = %s';
|
||||
$prepare_args[] = $external_status;
|
||||
}
|
||||
|
||||
if ( '' !== $filter_post_type ) {
|
||||
$where_parts[] = 'EXISTS (SELECT 1 FROM ' . $wpdb->prefix . 'mra_media_usage mu WHERE mu.attachment_id = i.attachment_id AND mu.post_type = %s)';
|
||||
$prepare_args[] = $filter_post_type;
|
||||
}
|
||||
|
||||
if ( $show_unused ) {
|
||||
$where_parts[] = 'NOT EXISTS (SELECT 1 FROM ' . $wpdb->prefix . 'mra_media_usage mu WHERE mu.attachment_id = i.attachment_id)';
|
||||
}
|
||||
|
||||
$where = implode( ' AND ', $where_parts );
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
|
||||
if ( 'usage_count' === $orderby ) {
|
||||
$order_sql = "(SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_usage mu WHERE mu.attachment_id = i.attachment_id) {$order}";
|
||||
} else {
|
||||
$order_sql = "i.attachment_id {$order}";
|
||||
}
|
||||
|
||||
$count_sql = "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index i WHERE {$where}";
|
||||
$items_sql = "SELECT i.*,
|
||||
(SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_usage mu WHERE mu.attachment_id = i.attachment_id) AS usage_count,
|
||||
(SELECT COALESCE(SUM(er.match_count), 0) FROM {$wpdb->prefix}mra_external_results er WHERE er.attachment_id = i.attachment_id) AS total_match_count
|
||||
FROM {$wpdb->prefix}mra_media_index i
|
||||
WHERE {$where}
|
||||
ORDER BY {$order_sql}
|
||||
LIMIT %d OFFSET %d";
|
||||
|
||||
$count_prepared = empty( $prepare_args )
|
||||
? $count_sql
|
||||
: $wpdb->prepare( $count_sql, ...$prepare_args );
|
||||
|
||||
$items_prepared = $wpdb->prepare(
|
||||
$items_sql,
|
||||
...array_merge( $prepare_args, array( $per_page, $offset ) )
|
||||
);
|
||||
|
||||
$total = (int) $wpdb->get_var( $count_prepared );
|
||||
$rows = $wpdb->get_results( $items_prepared, ARRAY_A );
|
||||
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
|
||||
if ( ! $rows ) {
|
||||
$this->items = array();
|
||||
} else {
|
||||
// Bulk-load usages for this page (avoid N+1).
|
||||
$ids = array_map( 'intval', array_column( $rows, 'attachment_id' ) );
|
||||
$usages = self::fetch_usages( $ids );
|
||||
$usage_by = array();
|
||||
foreach ( $usages as $u ) {
|
||||
$uid_val = $u['attachment_id'] ?? null;
|
||||
$uid = is_numeric( $uid_val ) ? (int) $uid_val : 0;
|
||||
$usage_by[ $uid ][] = $u;
|
||||
}
|
||||
|
||||
foreach ( $rows as &$row ) {
|
||||
$rid_val = $row['attachment_id'] ?? null;
|
||||
$rid = is_numeric( $rid_val ) ? (int) $rid_val : 0;
|
||||
$row['usages_data'] = $usage_by[ $rid ] ?? array();
|
||||
}
|
||||
unset( $row );
|
||||
|
||||
$this->items = $rows;
|
||||
}
|
||||
|
||||
$this->_column_headers = array(
|
||||
$this->get_columns(),
|
||||
array(),
|
||||
$this->get_sortable_columns(),
|
||||
'filename',
|
||||
);
|
||||
|
||||
$this->set_pagination_args(
|
||||
array(
|
||||
'total_items' => $total,
|
||||
'total_pages' => (int) ceil( $total / $per_page ),
|
||||
'per_page' => $per_page,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public query helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetches usage rows for a set of attachment IDs in one query.
|
||||
*
|
||||
* @param array<int, int> $attachment_ids Attachment IDs to look up.
|
||||
*
|
||||
* @return list<array<array-key, mixed>>
|
||||
*/
|
||||
public static function fetch_usages( array $attachment_ids ): array {
|
||||
global $wpdb;
|
||||
|
||||
if ( empty( $attachment_ids ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$placeholders = implode( ',', array_fill( 0, count( $attachment_ids ), '%d' ) );
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT u.attachment_id, u.post_id, u.post_type, u.context, u.meta_key,
|
||||
p.post_title, p.post_status
|
||||
FROM {$wpdb->prefix}mra_media_usage u
|
||||
JOIN {$wpdb->posts} p ON u.post_id = p.ID
|
||||
WHERE u.attachment_id IN ({$placeholders})
|
||||
ORDER BY u.attachment_id ASC, u.post_id ASC",
|
||||
...$attachment_ids
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
|
||||
return $rows ?? array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns distinct post types that have at least one usage record.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function get_used_post_types(): array {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$rows = $wpdb->get_col(
|
||||
"SELECT DISTINCT post_type FROM {$wpdb->prefix}mra_media_usage ORDER BY post_type ASC"
|
||||
);
|
||||
|
||||
if ( ! is_array( $rows ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$types = array();
|
||||
foreach ( $rows as $r ) {
|
||||
if ( is_string( $r ) ) {
|
||||
$types[] = $r;
|
||||
}
|
||||
}
|
||||
return $types;
|
||||
}
|
||||
}
|
||||
340
includes/Admin/Settings.php
Normal file
340
includes/Admin/Settings.php
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
<?php
|
||||
/**
|
||||
* Settings page handler.
|
||||
*
|
||||
* @package MediaRightsAudit\Admin
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\Admin;
|
||||
|
||||
/**
|
||||
* Registers and renders the plugin Settings page.
|
||||
*
|
||||
* Settings are stored in a single option array: robotstxt_mediaaudit_settings.
|
||||
*/
|
||||
class Settings {
|
||||
|
||||
/**
|
||||
* WordPress Settings API option group name.
|
||||
*/
|
||||
const OPTION_GROUP = 'robotstxt_mediaaudit';
|
||||
|
||||
/**
|
||||
* Option name in the database.
|
||||
*/
|
||||
const OPTION_NAME = 'robotstxt_mediaaudit_settings';
|
||||
|
||||
/**
|
||||
* Registers all settings, sections, and fields via the WordPress Settings API.
|
||||
*
|
||||
* Hooked to admin_init.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register(): void {
|
||||
register_setting(
|
||||
self::OPTION_GROUP,
|
||||
self::OPTION_NAME,
|
||||
array(
|
||||
'type' => 'array',
|
||||
'sanitize_callback' => array( $this, 'sanitize' ),
|
||||
'default' => array(),
|
||||
)
|
||||
);
|
||||
|
||||
add_settings_section(
|
||||
'mra_general',
|
||||
__( 'General', 'robotstxt-mediaaudit' ),
|
||||
'__return_false',
|
||||
'robotstxt-mediaaudit-settings'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'delete_on_uninstall',
|
||||
__( 'Delete data on uninstall', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_delete_on_uninstall' ),
|
||||
'robotstxt-mediaaudit-settings',
|
||||
'mra_general'
|
||||
);
|
||||
|
||||
add_settings_section(
|
||||
'mra_api_credentials',
|
||||
__( 'API Credentials', 'robotstxt-mediaaudit' ),
|
||||
'__return_false',
|
||||
'robotstxt-mediaaudit-settings'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'google_vision_api_key',
|
||||
__( 'Google Cloud Vision API Key', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_google_vision_api_key' ),
|
||||
'robotstxt-mediaaudit-settings',
|
||||
'mra_api_credentials'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'tineye_api_key',
|
||||
__( 'TinEye API Key', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_tineye_api_key' ),
|
||||
'robotstxt-mediaaudit-settings',
|
||||
'mra_api_credentials'
|
||||
);
|
||||
|
||||
add_settings_section(
|
||||
'mra_external_scanning',
|
||||
__( 'External Scanning', 'robotstxt-mediaaudit' ),
|
||||
'__return_false',
|
||||
'robotstxt-mediaaudit-settings'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'external_batch_size',
|
||||
__( 'Batch Size', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_external_batch_size' ),
|
||||
'robotstxt-mediaaudit-settings',
|
||||
'mra_external_scanning'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'rate_limit_per_minute',
|
||||
__( 'Rate Limit (requests/min)', 'robotstxt-mediaaudit' ),
|
||||
array( $this, 'field_rate_limit_per_minute' ),
|
||||
'robotstxt-mediaaudit-settings',
|
||||
'mra_external_scanning'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitises the settings array on save.
|
||||
*
|
||||
* @param mixed $input Raw POST input.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function sanitize( $input ): array {
|
||||
$output = array();
|
||||
|
||||
if ( ! is_array( $input ) ) {
|
||||
return $output;
|
||||
}
|
||||
|
||||
$output['delete_on_uninstall'] = ! empty( $input['delete_on_uninstall'] );
|
||||
|
||||
$key_val = $input['google_vision_api_key'] ?? null;
|
||||
$output['google_vision_api_key'] = is_string( $key_val ) ? sanitize_text_field( $key_val ) : '';
|
||||
|
||||
$tineye_val = $input['tineye_api_key'] ?? null;
|
||||
$output['tineye_api_key'] = is_string( $tineye_val ) ? sanitize_text_field( $tineye_val ) : '';
|
||||
|
||||
$batch_val = $input['external_batch_size'] ?? null;
|
||||
$batch = is_numeric( $batch_val ) ? (int) $batch_val : 10;
|
||||
$output['external_batch_size'] = max( 1, min( 100, $batch ) );
|
||||
|
||||
$rl_val = $input['rate_limit_per_minute'] ?? null;
|
||||
$rl = is_numeric( $rl_val ) ? (int) $rl_val : 10;
|
||||
$output['rate_limit_per_minute'] = max( 1, min( 60, $rl ) );
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the "Delete data on uninstall" checkbox field.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function field_delete_on_uninstall(): void {
|
||||
$options = (array) get_option( self::OPTION_NAME, array() );
|
||||
$checked = ! empty( $options['delete_on_uninstall'] );
|
||||
?>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[delete_on_uninstall]"
|
||||
value="1"
|
||||
<?php checked( $checked ); ?>
|
||||
/>
|
||||
<?php esc_html_e( 'Remove all plugin data (tables and options) when the plugin is uninstalled.', 'robotstxt-mediaaudit' ); ?>
|
||||
</label>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'By default, data is preserved after uninstall. Enable this only if you want a clean removal.', 'robotstxt-mediaaudit' ); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the Google Cloud Vision API key field.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function field_google_vision_api_key(): void {
|
||||
$options = (array) get_option( self::OPTION_NAME, array() );
|
||||
$val = $options['google_vision_api_key'] ?? '';
|
||||
$value = is_string( $val ) ? $val : '';
|
||||
?>
|
||||
<input
|
||||
type="text"
|
||||
id="google_vision_api_key"
|
||||
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[google_vision_api_key]"
|
||||
value="<?php echo esc_attr( $value ); ?>"
|
||||
class="regular-text"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Required for Google Cloud Vision Web Detection. Obtain from the Google Cloud Console.', 'robotstxt-mediaaudit' ); ?>
|
||||
—
|
||||
<a href="https://cloud.google.com/vision/pricing" target="_blank" rel="noopener noreferrer">
|
||||
<?php esc_html_e( 'View pricing', 'robotstxt-mediaaudit' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
<table class="mra-pricing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Monthly requests', 'robotstxt-mediaaudit' ); ?></th>
|
||||
<th><?php esc_html_e( 'Price per 1,000', 'robotstxt-mediaaudit' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'First 1,000', 'robotstxt-mediaaudit' ); ?></td>
|
||||
<td><?php esc_html_e( 'Free', 'robotstxt-mediaaudit' ); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>1,001 – 5,000,000</td>
|
||||
<td>$1.50</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>5,000,001+</td>
|
||||
<td>$0.60</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the TinEye API key field.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function field_tineye_api_key(): void {
|
||||
$options = (array) get_option( self::OPTION_NAME, array() );
|
||||
$val = $options['tineye_api_key'] ?? '';
|
||||
$value = is_string( $val ) ? $val : '';
|
||||
?>
|
||||
<input
|
||||
type="text"
|
||||
id="tineye_api_key"
|
||||
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[tineye_api_key]"
|
||||
value="<?php echo esc_attr( $value ); ?>"
|
||||
class="regular-text"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Required for TinEye reverse image search. Obtain from your TinEye Commercial account.', 'robotstxt-mediaaudit' ); ?>
|
||||
—
|
||||
<a href="https://services.tineye.com/TinEyeAPI" target="_blank" rel="noopener noreferrer">
|
||||
<?php esc_html_e( 'View pricing', 'robotstxt-mediaaudit' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
<table class="mra-pricing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Bundle', 'robotstxt-mediaaudit' ); ?></th>
|
||||
<th><?php esc_html_e( 'Searches', 'robotstxt-mediaaudit' ); ?></th>
|
||||
<th><?php esc_html_e( 'Price', 'robotstxt-mediaaudit' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'Starter', 'robotstxt-mediaaudit' ); ?></td>
|
||||
<td>500</td>
|
||||
<td>$50</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'Standard', 'robotstxt-mediaaudit' ); ?></td>
|
||||
<td>5,000</td>
|
||||
<td>$200</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'Professional', 'robotstxt-mediaaudit' ); ?></td>
|
||||
<td>25,000</td>
|
||||
<td>$600</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the external batch size field.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function field_external_batch_size(): void {
|
||||
$options = (array) get_option( self::OPTION_NAME, array() );
|
||||
$raw = $options['external_batch_size'] ?? 10;
|
||||
$value = is_numeric( $raw ) ? (int) $raw : 10;
|
||||
?>
|
||||
<input
|
||||
type="number"
|
||||
id="external_batch_size"
|
||||
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[external_batch_size]"
|
||||
value="<?php echo esc_attr( (string) $value ); ?>"
|
||||
min="1"
|
||||
max="100"
|
||||
class="small-text"
|
||||
/>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Number of images to scan per scheduled batch (1–100). Default: 10.', 'robotstxt-mediaaudit' ); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the per-minute rate limit field.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function field_rate_limit_per_minute(): void {
|
||||
$options = (array) get_option( self::OPTION_NAME, array() );
|
||||
$raw = $options['rate_limit_per_minute'] ?? 10;
|
||||
$value = is_numeric( $raw ) ? (int) $raw : 10;
|
||||
?>
|
||||
<input
|
||||
type="number"
|
||||
id="rate_limit_per_minute"
|
||||
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[rate_limit_per_minute]"
|
||||
value="<?php echo esc_attr( (string) $value ); ?>"
|
||||
min="1"
|
||||
max="60"
|
||||
class="small-text"
|
||||
/>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Maximum API requests per minute per provider (1–60). Default: 10.', 'robotstxt-mediaaudit' ); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the Settings admin page.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-mediaaudit' ) );
|
||||
}
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
|
||||
<form method="post" action="options.php">
|
||||
<?php
|
||||
settings_fields( self::OPTION_GROUP );
|
||||
do_settings_sections( 'robotstxt-mediaaudit-settings' );
|
||||
submit_button();
|
||||
?>
|
||||
</form>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
239
includes/CLI/Command.php
Normal file
239
includes/CLI/Command.php
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
<?php
|
||||
/**
|
||||
* WP-CLI command handler.
|
||||
*
|
||||
* @package MediaRightsAudit\CLI
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\CLI;
|
||||
|
||||
use MediaRightsAudit\Core\Database;
|
||||
use MediaRightsAudit\External\ExternalScanner;
|
||||
use MediaRightsAudit\Internal\AttachmentIndexer;
|
||||
use MediaRightsAudit\Internal\UsageScanner;
|
||||
|
||||
/**
|
||||
* WP-CLI commands for Media Audit.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* # Install or repair database tables.
|
||||
* $ wp mra install
|
||||
*
|
||||
* @package MediaRightsAudit\CLI
|
||||
*/
|
||||
class Command {
|
||||
|
||||
/**
|
||||
* Creates or repairs the plugin database tables.
|
||||
*
|
||||
* Safe to run multiple times. Uses dbDelta internally so existing tables
|
||||
* are only altered when the schema has changed.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* $ wp mra install
|
||||
* Success: Media Audit tables ready. DB version: 1.0.0
|
||||
*
|
||||
* @subcommand install
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function install(): void {
|
||||
Database::create_tables();
|
||||
update_option( 'robotstxt_mediaaudit_db_version', ROBOTSTXT_MEDIAAUDIT_DB_VERSION );
|
||||
|
||||
\WP_CLI::success(
|
||||
sprintf(
|
||||
/* translators: %s: DB version number */
|
||||
__( 'Media Audit tables ready. DB version: %s', 'robotstxt-mediaaudit' ),
|
||||
ROBOTSTXT_MEDIAAUDIT_DB_VERSION
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the current plugin and schema versions.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* $ wp mra status
|
||||
*
|
||||
* @subcommand status
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function status(): void {
|
||||
$raw = get_option( 'robotstxt_mediaaudit_db_version', 'not installed' );
|
||||
$stored = is_string( $raw ) ? $raw : 'not installed';
|
||||
|
||||
\WP_CLI::line(
|
||||
sprintf(
|
||||
/* translators: %s: plugin version */
|
||||
__( 'Plugin version : %s', 'robotstxt-mediaaudit' ),
|
||||
ROBOTSTXT_MEDIAAUDIT_VERSION
|
||||
)
|
||||
);
|
||||
\WP_CLI::line(
|
||||
sprintf(
|
||||
/* translators: %s: DB version number */
|
||||
__( 'DB version : %s', 'robotstxt-mediaaudit' ),
|
||||
$stored
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the media library for internal attachment usage.
|
||||
*
|
||||
* Runs indexing and usage scanning in a loop until all attachments are processed.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* [--batch=<size>]
|
||||
* : Number of attachments to process per iteration.
|
||||
* ---
|
||||
* default: 50
|
||||
* ---
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* $ wp mra scan-internal
|
||||
* $ wp mra scan-internal --batch=100
|
||||
*
|
||||
* @subcommand scan-internal
|
||||
*
|
||||
* @param array<string> $args Positional arguments (unused).
|
||||
* @param array<string> $assoc_args Named arguments.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function scan_internal( array $args, array $assoc_args ): void {
|
||||
$batch_size = isset( $assoc_args['batch'] ) ? intval( $assoc_args['batch'] ) : 50;
|
||||
if ( $batch_size < 1 ) {
|
||||
$batch_size = 50;
|
||||
}
|
||||
|
||||
// --- Indexing phase ---
|
||||
\WP_CLI::log( __( 'Indexing attachments…', 'robotstxt-mediaaudit' ) );
|
||||
$total_indexed = 0;
|
||||
|
||||
do {
|
||||
$indexed = AttachmentIndexer::index_batch( $batch_size );
|
||||
$total_indexed += $indexed;
|
||||
$pending = AttachmentIndexer::get_pending_count();
|
||||
|
||||
if ( $indexed > 0 ) {
|
||||
\WP_CLI::log(
|
||||
sprintf(
|
||||
/* translators: 1: newly indexed count, 2: remaining count */
|
||||
__( 'Indexed %1$d (%2$d remaining)', 'robotstxt-mediaaudit' ),
|
||||
$indexed,
|
||||
$pending
|
||||
)
|
||||
);
|
||||
}
|
||||
} while ( $indexed > 0 && $pending > 0 );
|
||||
|
||||
\WP_CLI::success(
|
||||
sprintf(
|
||||
/* translators: %d: total indexed count */
|
||||
__( 'Indexing complete. %d attachments in index.', 'robotstxt-mediaaudit' ),
|
||||
AttachmentIndexer::get_indexed_count()
|
||||
)
|
||||
);
|
||||
|
||||
// --- Usage scanning phase ---
|
||||
\WP_CLI::log( __( 'Scanning usage…', 'robotstxt-mediaaudit' ) );
|
||||
$total_scanned = 0;
|
||||
|
||||
do {
|
||||
$scanned = UsageScanner::scan_batch( $batch_size );
|
||||
$total_scanned += $scanned;
|
||||
$pending = UsageScanner::get_pending_count();
|
||||
|
||||
if ( $scanned > 0 ) {
|
||||
\WP_CLI::log(
|
||||
sprintf(
|
||||
/* translators: 1: scanned count, 2: remaining count */
|
||||
__( 'Scanned %1$d (%2$d remaining)', 'robotstxt-mediaaudit' ),
|
||||
$scanned,
|
||||
$pending
|
||||
)
|
||||
);
|
||||
}
|
||||
} while ( $scanned > 0 && $pending > 0 );
|
||||
|
||||
\WP_CLI::success(
|
||||
sprintf(
|
||||
/* translators: %d: total scanned count */
|
||||
__( 'Scan complete. %d attachments scanned.', 'robotstxt-mediaaudit' ),
|
||||
$total_scanned
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs external reverse-image-search scans via all configured providers.
|
||||
*
|
||||
* Promotes any 'pending' attachments to 'queued' before scanning, then
|
||||
* processes all queued items synchronously. Requires API keys to be configured.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* [--batch=<size>]
|
||||
* : Number of attachments to process per iteration.
|
||||
* ---
|
||||
* default: 10
|
||||
* ---
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* $ wp mra scan-external
|
||||
* $ wp mra scan-external --batch=5
|
||||
*
|
||||
* @subcommand scan-external
|
||||
*
|
||||
* @param array<string> $args Positional arguments (unused).
|
||||
* @param array<string> $assoc_args Named arguments.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function scan_external( array $args, array $assoc_args ): void {
|
||||
$batch_size = isset( $assoc_args['batch'] ) ? (int) $assoc_args['batch'] : 10;
|
||||
if ( $batch_size < 1 ) {
|
||||
$batch_size = 10;
|
||||
}
|
||||
|
||||
\WP_CLI::log( __( 'Running external scans…', 'robotstxt-mediaaudit' ) );
|
||||
|
||||
ExternalScanner::queue_all_pending();
|
||||
|
||||
$total_scanned = 0;
|
||||
|
||||
do {
|
||||
$scanned = ExternalScanner::scan_batch( $batch_size );
|
||||
$total_scanned += $scanned;
|
||||
$pending = ExternalScanner::get_pending_count();
|
||||
|
||||
if ( $scanned > 0 ) {
|
||||
\WP_CLI::log(
|
||||
sprintf(
|
||||
/* translators: 1: scanned count, 2: remaining count */
|
||||
__( 'Scanned %1$d (%2$d remaining)', 'robotstxt-mediaaudit' ),
|
||||
$scanned,
|
||||
$pending
|
||||
)
|
||||
);
|
||||
}
|
||||
} while ( $scanned > 0 && $pending > 0 );
|
||||
|
||||
\WP_CLI::success(
|
||||
sprintf(
|
||||
/* translators: %d: total scanned count */
|
||||
__( 'External scan complete. %d attachments processed.', 'robotstxt-mediaaudit' ),
|
||||
$total_scanned
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
25
includes/Core/Activator.php
Normal file
25
includes/Core/Activator.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
/**
|
||||
* Fired during plugin activation.
|
||||
*
|
||||
* @package MediaRightsAudit\Core
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\Core;
|
||||
|
||||
/**
|
||||
* Handles tasks that run once when the plugin is activated.
|
||||
*/
|
||||
class Activator {
|
||||
|
||||
/**
|
||||
* Creates database tables and seeds the initial DB version.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function activate(): void {
|
||||
Database::create_tables();
|
||||
update_option( 'robotstxt_mediaaudit_db_version', ROBOTSTXT_MEDIAAUDIT_DB_VERSION );
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
}
|
||||
141
includes/Core/Database.php
Normal file
141
includes/Core/Database.php
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
/**
|
||||
* Database schema creation and versioned migrations.
|
||||
*
|
||||
* @package MediaRightsAudit\Core
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\Core;
|
||||
|
||||
/**
|
||||
* Manages all custom table creation and schema migrations.
|
||||
*
|
||||
* Migration methods are idempotent: they rely on dbDelta's ALTER TABLE
|
||||
* diffing and can be run multiple times without side effects.
|
||||
*/
|
||||
class Database {
|
||||
|
||||
/**
|
||||
* Creates all tables. Called on plugin activation.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function create_tables(): void {
|
||||
self::migration_100();
|
||||
self::migration_101();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies any pending migrations based on the currently installed schema version.
|
||||
*
|
||||
* Called on admin_init by Plugin::check_db_version().
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function run_migrations(): void {
|
||||
$raw = get_option( 'robotstxt_mediaaudit_db_version', '0.0.0' );
|
||||
$current = is_string( $raw ) ? $raw : '0.0.0';
|
||||
|
||||
if ( version_compare( $current, '1.0.0', '<' ) ) {
|
||||
self::migration_100();
|
||||
}
|
||||
|
||||
if ( version_compare( $current, '1.0.1', '<' ) ) {
|
||||
self::migration_101();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops all plugin tables. Used by uninstall.php.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function drop_tables(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_external_results`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_usage`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_index`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Migrations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Migration 1.0.0 — creates the initial three tables.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function migration_100(): void {
|
||||
global $wpdb;
|
||||
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
|
||||
dbDelta(
|
||||
"CREATE TABLE {$wpdb->prefix}mra_media_index (
|
||||
attachment_id bigint(20) unsigned NOT NULL,
|
||||
file_url text NOT NULL,
|
||||
file_name varchar(255) NOT NULL DEFAULT '',
|
||||
mime_type varchar(100) NOT NULL DEFAULT '',
|
||||
file_size bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||
internal_scanned_at datetime DEFAULT NULL,
|
||||
external_status enum('pending','queued','scanned','matches','error') NOT NULL DEFAULT 'pending',
|
||||
external_scanned_at datetime DEFAULT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
PRIMARY KEY (attachment_id),
|
||||
KEY external_status (external_status)
|
||||
) {$charset_collate};"
|
||||
);
|
||||
|
||||
dbDelta(
|
||||
"CREATE TABLE {$wpdb->prefix}mra_media_usage (
|
||||
usage_id bigint(20) unsigned NOT NULL auto_increment,
|
||||
attachment_id bigint(20) unsigned NOT NULL,
|
||||
post_id bigint(20) unsigned NOT NULL,
|
||||
post_type varchar(20) NOT NULL DEFAULT '',
|
||||
context enum('featured','content','meta') NOT NULL DEFAULT 'featured',
|
||||
meta_key varchar(255) DEFAULT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
PRIMARY KEY (usage_id),
|
||||
KEY attachment_id (attachment_id),
|
||||
KEY post_id (post_id)
|
||||
) {$charset_collate};"
|
||||
);
|
||||
|
||||
dbDelta(
|
||||
"CREATE TABLE {$wpdb->prefix}mra_external_results (
|
||||
result_id bigint(20) unsigned NOT NULL auto_increment,
|
||||
attachment_id bigint(20) unsigned NOT NULL,
|
||||
provider enum('google_vision','tineye') NOT NULL,
|
||||
raw_response json DEFAULT NULL,
|
||||
match_count int(10) unsigned NOT NULL DEFAULT 0,
|
||||
top_domains json DEFAULT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
PRIMARY KEY (result_id),
|
||||
UNIQUE KEY attachment_provider (attachment_id,provider)
|
||||
) {$charset_collate};"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration 1.0.1 — adds 'queued' to the external_status ENUM.
|
||||
*
|
||||
* Uses ALTER TABLE directly because dbDelta cannot modify ENUM column definitions.
|
||||
* Safe to re-run: MariaDB/MySQL treats an identical MODIFY as a no-op.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function migration_101(): void {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.SchemaChange
|
||||
$wpdb->query(
|
||||
"ALTER TABLE `{$wpdb->prefix}mra_media_index`
|
||||
MODIFY COLUMN external_status
|
||||
enum('pending','queued','scanned','matches','error') NOT NULL DEFAULT 'pending'"
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.SchemaChange
|
||||
}
|
||||
}
|
||||
28
includes/Core/Deactivator.php
Normal file
28
includes/Core/Deactivator.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
/**
|
||||
* Fired during plugin deactivation.
|
||||
*
|
||||
* @package MediaRightsAudit\Core
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\Core;
|
||||
|
||||
use MediaRightsAudit\Core\Queue\Scheduler;
|
||||
|
||||
/**
|
||||
* Handles tasks that run once when the plugin is deactivated.
|
||||
*/
|
||||
class Deactivator {
|
||||
|
||||
/**
|
||||
* Cancels all pending scheduled actions and flushes rewrite rules.
|
||||
*
|
||||
* Database tables and stored data are intentionally preserved.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function deactivate(): void {
|
||||
Scheduler::cancel_all();
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
}
|
||||
165
includes/Core/Plugin.php
Normal file
165
includes/Core/Plugin.php
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<?php
|
||||
/**
|
||||
* Core plugin bootstrap class.
|
||||
*
|
||||
* @package MediaRightsAudit\Core
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\Core;
|
||||
|
||||
use MediaRightsAudit\Admin\AuditPage;
|
||||
use MediaRightsAudit\Admin\Settings;
|
||||
use MediaRightsAudit\CLI\Command;
|
||||
use MediaRightsAudit\External\AbstractProvider;
|
||||
use MediaRightsAudit\External\ExternalScanner;
|
||||
use MediaRightsAudit\External\GoogleVisionProvider;
|
||||
use MediaRightsAudit\External\TinEyeProvider;
|
||||
use MediaRightsAudit\Internal\AttachmentIndexer;
|
||||
use MediaRightsAudit\Internal\UsageScanner;
|
||||
use MediaRightsAudit\Privacy\DataEraser;
|
||||
use MediaRightsAudit\Privacy\DataExporter;
|
||||
|
||||
/**
|
||||
* Registers all hooks and initialises the plugin subsystems.
|
||||
*/
|
||||
class Plugin {
|
||||
|
||||
/**
|
||||
* Settings page handler.
|
||||
*
|
||||
* @var Settings
|
||||
*/
|
||||
private Settings $settings;
|
||||
|
||||
/**
|
||||
* Audit list page controller.
|
||||
*
|
||||
* @var AuditPage
|
||||
*/
|
||||
private AuditPage $audit_page;
|
||||
|
||||
/**
|
||||
* Initialises dependencies.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->settings = new Settings();
|
||||
$this->audit_page = new AuditPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches all WordPress hooks.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function run(): void {
|
||||
add_action( 'init', array( $this, 'load_textdomain' ) );
|
||||
add_action( 'admin_menu', array( $this, 'register_admin_pages' ) );
|
||||
add_action( 'admin_init', array( $this->settings, 'register' ) );
|
||||
add_action( 'admin_init', array( $this, 'check_db_version' ) );
|
||||
add_action( AttachmentIndexer::AS_HOOK, array( AttachmentIndexer::class, 'process_scheduled_batch' ) );
|
||||
add_action( UsageScanner::AS_HOOK, array( UsageScanner::class, 'process_scheduled_batch' ) );
|
||||
add_action( ExternalScanner::AS_HOOK, array( ExternalScanner::class, 'process_scheduled_batch' ) );
|
||||
add_filter( 'mra/external/providers', array( $this, 'register_providers' ) );
|
||||
add_filter( 'wp_privacy_personal_data_exporters', array( DataExporter::class, 'register' ) );
|
||||
add_filter( 'wp_privacy_personal_data_erasers', array( DataEraser::class, 'register' ) );
|
||||
$this->audit_page->register_hooks();
|
||||
$this->register_cli_commands();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the plugin text domain.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function load_textdomain(): void {
|
||||
load_plugin_textdomain(
|
||||
'robotstxt-mediaaudit',
|
||||
false,
|
||||
dirname( plugin_basename( ROBOTSTXT_MEDIAAUDIT_PLUGIN_FILE ) ) . '/languages'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers admin menu pages.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_admin_pages(): void {
|
||||
$suffix = add_menu_page(
|
||||
__( 'Media Audit', 'robotstxt-mediaaudit' ),
|
||||
__( 'Media Audit', 'robotstxt-mediaaudit' ),
|
||||
'edit_others_posts',
|
||||
'robotstxt-mediaaudit',
|
||||
array( $this->audit_page, 'render' ),
|
||||
'dashicons-camera',
|
||||
60
|
||||
);
|
||||
|
||||
$this->audit_page->set_hook_suffix( $suffix );
|
||||
|
||||
// Rename the auto-created first submenu entry.
|
||||
add_submenu_page(
|
||||
'robotstxt-mediaaudit',
|
||||
__( 'Media Audit', 'robotstxt-mediaaudit' ),
|
||||
__( 'Media Audit', 'robotstxt-mediaaudit' ),
|
||||
'edit_others_posts',
|
||||
'robotstxt-mediaaudit',
|
||||
array( $this->audit_page, 'render' )
|
||||
);
|
||||
|
||||
add_submenu_page(
|
||||
'robotstxt-mediaaudit',
|
||||
__( 'Settings', 'robotstxt-mediaaudit' ),
|
||||
__( 'Settings', 'robotstxt-mediaaudit' ),
|
||||
'manage_options',
|
||||
'robotstxt-mediaaudit-settings',
|
||||
array( $this->settings, 'render' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs pending DB migrations when the stored schema version is behind the constant.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function check_db_version(): void {
|
||||
$raw = get_option( 'robotstxt_mediaaudit_db_version', '0.0.0' );
|
||||
$stored = is_string( $raw ) ? $raw : '0.0.0';
|
||||
if ( ROBOTSTXT_MEDIAAUDIT_DB_VERSION !== $stored ) {
|
||||
Database::run_migrations();
|
||||
update_option( 'robotstxt_mediaaudit_db_version', ROBOTSTXT_MEDIAAUDIT_DB_VERSION );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers built-in external providers via the mra/external/providers filter.
|
||||
*
|
||||
* @param mixed $providers Accumulated provider list.
|
||||
*
|
||||
* @return array<\MediaRightsAudit\External\AbstractProvider>
|
||||
*/
|
||||
public function register_providers( $providers ): array {
|
||||
$list = array();
|
||||
if ( is_array( $providers ) ) {
|
||||
foreach ( $providers as $p ) {
|
||||
if ( $p instanceof AbstractProvider ) {
|
||||
$list[] = $p;
|
||||
}
|
||||
}
|
||||
}
|
||||
$list[] = new GoogleVisionProvider();
|
||||
$list[] = new TinEyeProvider();
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers WP-CLI commands when running in CLI context.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function register_cli_commands(): void {
|
||||
if ( defined( 'WP_CLI' ) && WP_CLI ) {
|
||||
\WP_CLI::add_command( 'mra', Command::class );
|
||||
}
|
||||
}
|
||||
}
|
||||
77
includes/Core/Queue/Scheduler.php
Normal file
77
includes/Core/Queue/Scheduler.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
/**
|
||||
* Action Scheduler wrapper.
|
||||
*
|
||||
* @package MediaRightsAudit\Core\Queue
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\Core\Queue;
|
||||
|
||||
/**
|
||||
* Thin wrapper around Action Scheduler that namespaces all jobs under a single group.
|
||||
*
|
||||
* Action Scheduler is declared as a required plugin (Requires Plugins: action-scheduler)
|
||||
* so its functions are always available at runtime. The is_available() guard is kept
|
||||
* for defensive unit-test scenarios only.
|
||||
*/
|
||||
class Scheduler {
|
||||
|
||||
/**
|
||||
* Action Scheduler group for all plugin jobs.
|
||||
*/
|
||||
const GROUP = 'robotstxt-mediaaudit';
|
||||
|
||||
/**
|
||||
* Returns true when Action Scheduler functions are available.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_available(): bool {
|
||||
return function_exists( 'as_schedule_single_action' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a single action to run after the given delay.
|
||||
*
|
||||
* @param string $hook Action hook name.
|
||||
* @param array<mixed> $args Arguments passed to the hook.
|
||||
* @param int $delay Seconds from now (default 0 = immediate).
|
||||
*
|
||||
* @return int|null Action Scheduler action ID, or null if unavailable.
|
||||
*/
|
||||
public static function schedule_single( string $hook, array $args = array(), int $delay = 0 ): ?int {
|
||||
if ( ! self::is_available() ) {
|
||||
return null;
|
||||
}
|
||||
return as_schedule_single_action( time() + $delay, $hook, $args, self::GROUP );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a pending (not-yet-run) action exists for this hook.
|
||||
*
|
||||
* @param string $hook Action hook name.
|
||||
* @param array<mixed> $args Arguments to match.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function has_pending( string $hook, array $args = array() ): bool {
|
||||
if ( ! self::is_available() ) {
|
||||
return false;
|
||||
}
|
||||
return as_has_scheduled_action( $hook, $args, self::GROUP );
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels all pending plugin actions across all hooks.
|
||||
*
|
||||
* Called on plugin deactivation.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function cancel_all(): void {
|
||||
if ( ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
as_unschedule_all_actions( '', array(), self::GROUP );
|
||||
}
|
||||
}
|
||||
128
includes/External/AbstractProvider.php
vendored
Normal file
128
includes/External/AbstractProvider.php
vendored
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<?php
|
||||
/**
|
||||
* Base class for all external reverse-image-search providers.
|
||||
*
|
||||
* @package MediaRightsAudit\External
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\External;
|
||||
|
||||
/**
|
||||
* Provides rate-limited scanning via a transient-based per-minute counter.
|
||||
*
|
||||
* Subclasses implement do_scan() with the provider-specific HTTP call.
|
||||
* The public scan() wrapper enforces the rate limit before delegating.
|
||||
*/
|
||||
abstract class AbstractProvider {
|
||||
|
||||
/**
|
||||
* Machine-readable provider identifier stored in mra_external_results.provider.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
abstract public function provider_slug(): string;
|
||||
|
||||
/**
|
||||
* Human-readable provider display name.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
abstract public function provider_name(): string;
|
||||
|
||||
/**
|
||||
* Maximum API requests allowed per minute for this provider.
|
||||
*
|
||||
* Subclasses may override to read a per-provider setting.
|
||||
*
|
||||
* @return positive-int
|
||||
*/
|
||||
public function rate_limit(): int {
|
||||
return 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the provider-specific scan and returns a result.
|
||||
*
|
||||
* @param int $attachment_id WordPress attachment ID.
|
||||
* @param string $file_url Public URL of the image to analyse.
|
||||
*
|
||||
* @return ScanResult
|
||||
*
|
||||
* @throws \RuntimeException On HTTP or parse errors.
|
||||
*/
|
||||
abstract protected function do_scan( int $attachment_id, string $file_url ): ScanResult;
|
||||
|
||||
/**
|
||||
* Scans an image URL after checking the per-minute rate limit.
|
||||
*
|
||||
* @param int $attachment_id WordPress attachment ID.
|
||||
* @param string $file_url Public URL of the image to analyse.
|
||||
*
|
||||
* @return ScanResult
|
||||
*
|
||||
* @throws \RuntimeException When rate limit is exceeded or the scan fails.
|
||||
*/
|
||||
final public function scan( int $attachment_id, string $file_url ): ScanResult {
|
||||
$this->enforce_rate_limit();
|
||||
return $this->do_scan( $attachment_id, $file_url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a domain → count map from a list of URL-bearing items.
|
||||
*
|
||||
* Each item must be an array with a string 'url' key. Items missing the key
|
||||
* or whose host cannot be parsed are silently skipped.
|
||||
*
|
||||
* @param array<mixed> $items URL-bearing items (e.g. pages or backlink objects).
|
||||
*
|
||||
* @return array<string, int> Domain → occurrence count, sorted descending, max 10.
|
||||
*/
|
||||
protected function extract_top_domains( array $items ): array {
|
||||
$counts = array();
|
||||
|
||||
foreach ( $items as $item ) {
|
||||
if ( ! is_array( $item ) ) {
|
||||
continue;
|
||||
}
|
||||
$url_val = $item['url'] ?? null;
|
||||
if ( ! is_string( $url_val ) || '' === $url_val ) {
|
||||
continue;
|
||||
}
|
||||
$host = wp_parse_url( $url_val, PHP_URL_HOST );
|
||||
if ( ! is_string( $host ) || '' === $host ) {
|
||||
continue;
|
||||
}
|
||||
$counts[ $host ] = ( $counts[ $host ] ?? 0 ) + 1;
|
||||
}
|
||||
|
||||
arsort( $counts );
|
||||
return array_slice( $counts, 0, 10, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks and increments the per-minute request counter via transients.
|
||||
*
|
||||
* Throws if the counter has reached the configured limit.
|
||||
*
|
||||
* @throws \RuntimeException When the rate limit for the current minute is exhausted.
|
||||
*/
|
||||
private function enforce_rate_limit(): void {
|
||||
$key = 'mra_rl_' . $this->provider_slug() . '_' . gmdate( 'YmdHi' );
|
||||
$raw = get_transient( $key );
|
||||
$current = is_numeric( $raw ) ? (int) $raw : 0;
|
||||
|
||||
if ( $current >= $this->rate_limit() ) {
|
||||
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
throw new \RuntimeException(
|
||||
sprintf(
|
||||
'Rate limit of %d req/min exceeded for provider "%s".',
|
||||
$this->rate_limit(),
|
||||
$this->provider_slug()
|
||||
)
|
||||
);
|
||||
// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
}
|
||||
|
||||
set_transient( $key, $current + 1, 90 );
|
||||
}
|
||||
}
|
||||
334
includes/External/ExternalScanner.php
vendored
Normal file
334
includes/External/ExternalScanner.php
vendored
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
<?php
|
||||
/**
|
||||
* External scan batch processor.
|
||||
*
|
||||
* @package MediaRightsAudit\External
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\External;
|
||||
|
||||
use MediaRightsAudit\Core\Queue\Scheduler;
|
||||
|
||||
/**
|
||||
* Processes the external scan queue: runs each pending attachment through
|
||||
* all active providers and stores results in mra_external_results.
|
||||
*
|
||||
* Active providers are resolved at runtime via the mra/external/providers filter.
|
||||
* Scanning is triggered manually by an administrator (Run External Scan bulk action
|
||||
* or wp mra scan-external CLI) — never automatically, to control API costs.
|
||||
*
|
||||
* Extension hook: mra/external/providers — filter to register additional providers.
|
||||
*/
|
||||
class ExternalScanner {
|
||||
|
||||
/**
|
||||
* Action Scheduler hook name for background external scanning.
|
||||
*/
|
||||
const AS_HOOK = 'mra_external_scan_batch';
|
||||
|
||||
/**
|
||||
* Scans one batch of pending attachments with all active providers.
|
||||
*
|
||||
* @param int $batch_size Maximum number of attachments to process.
|
||||
*
|
||||
* @return int Number of attachments attempted.
|
||||
*/
|
||||
public static function scan_batch( int $batch_size = 10 ): int {
|
||||
global $wpdb;
|
||||
|
||||
$providers = self::get_active_providers();
|
||||
if ( empty( $providers ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT attachment_id, file_url
|
||||
FROM {$wpdb->prefix}mra_media_index
|
||||
WHERE external_status = 'queued'
|
||||
ORDER BY attachment_id ASC
|
||||
LIMIT %d",
|
||||
$batch_size
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
|
||||
if ( ! $rows ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ( $rows as $row ) {
|
||||
$aid_val = $row['attachment_id'] ?? null;
|
||||
$url_val = $row['file_url'] ?? null;
|
||||
$aid = is_numeric( $aid_val ) ? (int) $aid_val : 0;
|
||||
$url = is_string( $url_val ) ? $url_val : '';
|
||||
|
||||
if ( $aid <= 0 || '' === $url ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self::process_attachment( $aid, $url, $providers );
|
||||
}
|
||||
|
||||
return count( $rows );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the count of attachments still waiting for external scanning.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public static function get_pending_count(): int {
|
||||
global $wpdb;
|
||||
|
||||
$result = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index WHERE external_status = 'queued'"
|
||||
);
|
||||
|
||||
return is_numeric( $result ) ? (int) $result : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes one scheduled batch and re-queues if more remain.
|
||||
*
|
||||
* Called by Action Scheduler via the mra_external_scan_batch hook.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function process_scheduled_batch(): void {
|
||||
self::scan_batch();
|
||||
|
||||
if ( self::get_pending_count() > 0 ) {
|
||||
Scheduler::schedule_single( self::AS_HOOK );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes all attachments with external_status = 'pending' to 'queued'.
|
||||
*
|
||||
* Does not schedule an Action Scheduler action. Useful in CLI context where
|
||||
* processing runs synchronously in a loop rather than via background jobs.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function queue_all_pending(): void {
|
||||
global $wpdb;
|
||||
|
||||
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
"UPDATE {$wpdb->prefix}mra_media_index
|
||||
SET external_status = 'queued'
|
||||
WHERE external_status = 'pending'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all pending attachments as queued and schedules a background scan batch.
|
||||
*
|
||||
* Moves every attachment with external_status = 'pending' to 'queued' so the
|
||||
* status change is visible immediately in the admin list. Only schedules the
|
||||
* Action Scheduler action if there are now queued items and no batch is already running.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function schedule(): void {
|
||||
global $wpdb;
|
||||
|
||||
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
"UPDATE {$wpdb->prefix}mra_media_index
|
||||
SET external_status = 'queued'
|
||||
WHERE external_status = 'pending'"
|
||||
);
|
||||
|
||||
if ( self::get_pending_count() > 0 && ! Scheduler::has_pending( self::AS_HOOK ) ) {
|
||||
Scheduler::schedule_single( self::AS_HOOK );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks specific attachments as pending and schedules a scan batch.
|
||||
*
|
||||
* Idempotent: already-pending attachments are left unchanged;
|
||||
* previously-scanned or error attachments are reset to pending.
|
||||
*
|
||||
* @param array<int, int> $attachment_ids Attachment IDs to queue. Must be non-empty.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function queue_attachments( array $attachment_ids ): void {
|
||||
if ( empty( $attachment_ids ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
$ids = array_map( 'intval', $attachment_ids );
|
||||
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$wpdb->prefix}mra_media_index
|
||||
SET external_status = 'queued', external_scanned_at = NULL
|
||||
WHERE attachment_id IN ({$placeholders})
|
||||
AND external_status != 'queued'",
|
||||
...$ids
|
||||
)
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
|
||||
// Schedule directly — do not call schedule() which would also mark all 'pending' items.
|
||||
if ( ! Scheduler::has_pending( self::AS_HOOK ) ) {
|
||||
Scheduler::schedule_single( self::AS_HOOK );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes external scan results and resets status to pending for given IDs.
|
||||
*
|
||||
* @param array<int, int> $attachment_ids Attachment IDs to purge.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function purge( array $attachment_ids ): void {
|
||||
if ( empty( $attachment_ids ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
$ids = array_map( 'intval', $attachment_ids );
|
||||
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM {$wpdb->prefix}mra_external_results
|
||||
WHERE attachment_id IN ({$placeholders})",
|
||||
...$ids
|
||||
)
|
||||
);
|
||||
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$wpdb->prefix}mra_media_index
|
||||
SET external_status = 'pending', external_scanned_at = NULL
|
||||
WHERE attachment_id IN ({$placeholders})",
|
||||
...$ids
|
||||
)
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scans a single attachment with all providers and updates the index.
|
||||
*
|
||||
* @param int $attachment_id WordPress attachment ID.
|
||||
* @param string $file_url Public URL of the image.
|
||||
* @param array<AbstractProvider> $providers Resolved provider list.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function process_attachment( int $attachment_id, string $file_url, array $providers ): void {
|
||||
global $wpdb;
|
||||
|
||||
$any_success = false;
|
||||
$any_match = false;
|
||||
|
||||
foreach ( $providers as $provider ) {
|
||||
try {
|
||||
$result = $provider->scan( $attachment_id, $file_url );
|
||||
self::save_result( $attachment_id, $provider->provider_slug(), $result );
|
||||
$any_success = true;
|
||||
if ( $result->match_count() > 0 ) {
|
||||
$any_match = true;
|
||||
}
|
||||
} catch ( \RuntimeException $e ) {
|
||||
if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log(
|
||||
sprintf(
|
||||
'[MRA] %s scan failed for attachment %d: %s',
|
||||
$provider->provider_slug(),
|
||||
$attachment_id,
|
||||
$e->getMessage()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $any_success ) {
|
||||
$new_status = 'error';
|
||||
} elseif ( $any_match ) {
|
||||
$new_status = 'matches';
|
||||
} else {
|
||||
$new_status = 'scanned';
|
||||
}
|
||||
|
||||
$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->prefix . 'mra_media_index',
|
||||
array(
|
||||
'external_status' => $new_status,
|
||||
'external_scanned_at' => current_time( 'mysql', true ),
|
||||
),
|
||||
array( 'attachment_id' => $attachment_id ),
|
||||
array( '%s', '%s' ),
|
||||
array( '%d' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a provider's scan result (upsert via REPLACE INTO).
|
||||
*
|
||||
* @param int $attachment_id WordPress attachment ID.
|
||||
* @param string $provider_slug Provider machine-readable slug.
|
||||
* @param ScanResult $result Scan result to store.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function save_result( int $attachment_id, string $provider_slug, ScanResult $result ): void {
|
||||
global $wpdb;
|
||||
|
||||
$raw_json = wp_json_encode( $result->raw_response() );
|
||||
$domains_json = wp_json_encode( $result->top_domains() );
|
||||
|
||||
$wpdb->replace( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->prefix . 'mra_external_results',
|
||||
array(
|
||||
'attachment_id' => $attachment_id,
|
||||
'provider' => $provider_slug,
|
||||
'raw_response' => ( false !== $raw_json ) ? $raw_json : '{}',
|
||||
'match_count' => $result->match_count(),
|
||||
'top_domains' => ( false !== $domains_json ) ? $domains_json : '{}',
|
||||
'created_at' => current_time( 'mysql', true ),
|
||||
),
|
||||
array( '%d', '%s', '%s', '%d', '%s', '%s' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves active providers from the mra/external/providers filter.
|
||||
*
|
||||
* @return array<AbstractProvider>
|
||||
*/
|
||||
private static function get_active_providers(): array {
|
||||
$raw = apply_filters( 'mra/external/providers', array() ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
|
||||
$providers = array();
|
||||
|
||||
if ( is_array( $raw ) ) {
|
||||
foreach ( $raw as $p ) {
|
||||
if ( $p instanceof AbstractProvider ) {
|
||||
$providers[] = $p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $providers;
|
||||
}
|
||||
}
|
||||
177
includes/External/GoogleVisionProvider.php
vendored
Normal file
177
includes/External/GoogleVisionProvider.php
vendored
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
<?php
|
||||
/**
|
||||
* Google Cloud Vision Web Detection provider.
|
||||
*
|
||||
* @package MediaRightsAudit\External
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\External;
|
||||
|
||||
use MediaRightsAudit\Admin\Settings;
|
||||
|
||||
/**
|
||||
* Sends images to the Google Cloud Vision API (WEB_DETECTION feature) and
|
||||
* normalises the response into a ScanResult.
|
||||
*
|
||||
* Reads the API key from the plugin settings (google_vision_api_key).
|
||||
* Supports public-URL image submissions; for private/staging sites the
|
||||
* file_url may not be publicly accessible — a warning is surfaced via
|
||||
* the scan error message in that case.
|
||||
*/
|
||||
class GoogleVisionProvider extends AbstractProvider {
|
||||
|
||||
/**
|
||||
* Google Vision API endpoint (without key).
|
||||
*/
|
||||
const ENDPOINT = 'https://vision.googleapis.com/v1/images:annotate';
|
||||
|
||||
/**
|
||||
* Returns the machine-readable provider identifier.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function provider_slug(): string {
|
||||
return 'google_vision';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the human-readable provider display name.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function provider_name(): string {
|
||||
return __( 'Google Cloud Vision', 'robotstxt-mediaaudit' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the image to Google Cloud Vision Web Detection and returns a ScanResult.
|
||||
*
|
||||
* @param int $attachment_id WordPress attachment ID.
|
||||
* @param string $file_url Public URL of the image.
|
||||
*
|
||||
* @return ScanResult
|
||||
*
|
||||
* @throws \RuntimeException On configuration, HTTP, or parse errors.
|
||||
*/
|
||||
protected function do_scan( int $attachment_id, string $file_url ): ScanResult {
|
||||
$api_key = $this->get_api_key();
|
||||
if ( '' === $api_key ) {
|
||||
throw new \RuntimeException( 'Google Vision API key is not configured.' );
|
||||
}
|
||||
|
||||
$body = wp_json_encode(
|
||||
array(
|
||||
'requests' => array(
|
||||
array(
|
||||
'image' => array( 'source' => array( 'imageUri' => $file_url ) ),
|
||||
'features' => array(
|
||||
array(
|
||||
'type' => 'WEB_DETECTION',
|
||||
'maxResults' => 100,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
if ( false === $body ) {
|
||||
throw new \RuntimeException( 'Failed to encode Google Vision request body.' );
|
||||
}
|
||||
|
||||
$response = wp_remote_post(
|
||||
self::ENDPOINT,
|
||||
array(
|
||||
'headers' => array(
|
||||
'Content-Type' => 'application/json; charset=utf-8',
|
||||
'X-Goog-Api-Key' => $api_key,
|
||||
),
|
||||
'body' => $body,
|
||||
'timeout' => 30,
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
throw new \RuntimeException( $response->get_error_message() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
}
|
||||
|
||||
$http_code = (int) wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 !== $http_code ) {
|
||||
throw new \RuntimeException(
|
||||
sprintf( 'Google Vision API returned HTTP %d.', $http_code ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
);
|
||||
}
|
||||
|
||||
$raw_body = wp_remote_retrieve_body( $response );
|
||||
$decoded = json_decode( $raw_body, true );
|
||||
|
||||
if ( ! is_array( $decoded ) ) {
|
||||
throw new \RuntimeException( 'Failed to parse Google Vision API response.' );
|
||||
}
|
||||
|
||||
return $this->parse_response( $decoded );
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reads the Google Vision API key from plugin settings.
|
||||
*
|
||||
* @return string Empty string if not configured.
|
||||
*/
|
||||
private function get_api_key(): string {
|
||||
$raw = get_option( Settings::OPTION_NAME, array() );
|
||||
$opts = is_array( $raw ) ? $raw : array();
|
||||
$val = $opts['google_vision_api_key'] ?? null;
|
||||
return is_string( $val ) ? trim( $val ) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the decoded Vision API response into a ScanResult.
|
||||
*
|
||||
* @param array<mixed, mixed> $decoded json_decode()'d API response.
|
||||
*
|
||||
* @return ScanResult
|
||||
*/
|
||||
private function parse_response( array $decoded ): ScanResult {
|
||||
$web_detection = $this->extract_web_detection( $decoded );
|
||||
|
||||
$pages_raw = $web_detection['pagesWithMatchingImages'] ?? null;
|
||||
$pages = is_array( $pages_raw ) ? $pages_raw : array();
|
||||
|
||||
$full_raw = $web_detection['fullMatchingImages'] ?? null;
|
||||
$full = is_array( $full_raw ) ? $full_raw : array();
|
||||
|
||||
$partial_raw = $web_detection['partialMatchingImages'] ?? null;
|
||||
$partial = is_array( $partial_raw ) ? $partial_raw : array();
|
||||
|
||||
$match_count = count( $pages ) + count( $full );
|
||||
$top_domains = $this->extract_top_domains( array_merge( $pages, $full, $partial ) );
|
||||
|
||||
return new ScanResult( $match_count, $top_domains, $decoded );
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the webDetection sub-object from the full API response.
|
||||
*
|
||||
* @param array<mixed, mixed> $decoded Full decoded API response.
|
||||
*
|
||||
* @return array<mixed, mixed>
|
||||
*/
|
||||
private function extract_web_detection( array $decoded ): array {
|
||||
$responses_raw = $decoded['responses'] ?? null;
|
||||
if ( ! is_array( $responses_raw ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$first = reset( $responses_raw );
|
||||
if ( ! is_array( $first ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$wd = $first['webDetection'] ?? null;
|
||||
return is_array( $wd ) ? $wd : array();
|
||||
}
|
||||
}
|
||||
99
includes/External/ResultsConsolidator.php
vendored
Normal file
99
includes/External/ResultsConsolidator.php
vendored
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
/**
|
||||
* Cross-provider result consolidation.
|
||||
*
|
||||
* @package MediaRightsAudit\External
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\External;
|
||||
|
||||
/**
|
||||
* Aggregates external scan results across multiple providers to surface
|
||||
* domains that appear in more than one provider's findings (consensus signals).
|
||||
*
|
||||
* Consensus detection provides stronger copyright-risk signals: a domain
|
||||
* independently found by both Google Vision and TinEye is more likely to be
|
||||
* a genuine unauthorised copy than one found by a single provider.
|
||||
*/
|
||||
class ResultsConsolidator {
|
||||
|
||||
/**
|
||||
* Returns domains confirmed by results from at least $min_providers distinct providers.
|
||||
*
|
||||
* Reads top_domains JSON stored in mra_external_results for the given attachment
|
||||
* and counts how many providers reported each domain. Only domains meeting the
|
||||
* threshold are returned, sorted descending by provider-agreement count.
|
||||
*
|
||||
* @param int $attachment_id WordPress attachment ID.
|
||||
* @param int $min_providers Minimum number of providers that must agree (default: 2).
|
||||
*
|
||||
* @return array<string, int> Domain → agreeing-provider count, sorted descending.
|
||||
*/
|
||||
public static function get_consensus_domains( int $attachment_id, int $min_providers = 2 ): array {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT provider, top_domains FROM {$wpdb->prefix}mra_external_results WHERE attachment_id = %d",
|
||||
$attachment_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( ! $rows ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
return self::aggregate_consensus( $rows, $min_providers );
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes consensus domains from an array of provider result rows.
|
||||
*
|
||||
* Each row must contain 'provider' (string) and 'top_domains' (JSON string
|
||||
* mapping domain → count). Designed to be called directly in unit tests
|
||||
* by passing mock row data.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $rows Provider result rows.
|
||||
* @param int $min_providers Minimum provider agreement.
|
||||
*
|
||||
* @return array<string, int> Domain → agreeing-provider count, sorted descending.
|
||||
*/
|
||||
public static function aggregate_consensus( array $rows, int $min_providers = 2 ): array {
|
||||
$domain_to_providers = array();
|
||||
|
||||
foreach ( $rows as $row ) {
|
||||
$provider_val = $row['provider'] ?? null;
|
||||
$provider = is_string( $provider_val ) ? $provider_val : '';
|
||||
if ( '' === $provider ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$domains_val = $row['top_domains'] ?? null;
|
||||
$domains_raw = is_string( $domains_val ) ? json_decode( $domains_val, true ) : null;
|
||||
$domains = is_array( $domains_raw ) ? $domains_raw : array();
|
||||
|
||||
foreach ( $domains as $domain => $count ) {
|
||||
if ( ! is_string( $domain ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( ! isset( $domain_to_providers[ $domain ] ) ) {
|
||||
$domain_to_providers[ $domain ] = array();
|
||||
}
|
||||
$domain_to_providers[ $domain ][ $provider ] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$consensus = array();
|
||||
foreach ( $domain_to_providers as $domain => $providers_map ) {
|
||||
$provider_count = count( $providers_map );
|
||||
if ( $provider_count >= $min_providers ) {
|
||||
$consensus[ $domain ] = $provider_count;
|
||||
}
|
||||
}
|
||||
|
||||
arsort( $consensus );
|
||||
return $consensus;
|
||||
}
|
||||
}
|
||||
75
includes/External/ScanResult.php
vendored
Normal file
75
includes/External/ScanResult.php
vendored
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
/**
|
||||
* Value object representing the outcome of one external provider scan.
|
||||
*
|
||||
* @package MediaRightsAudit\External
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\External;
|
||||
|
||||
/**
|
||||
* Immutable result returned by AbstractProvider::scan().
|
||||
*/
|
||||
class ScanResult {
|
||||
|
||||
/**
|
||||
* Number of pages / images where the attachment was found.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private int $match_count;
|
||||
|
||||
/**
|
||||
* Top domains where matches were found (domain → occurrence count), max 10.
|
||||
*
|
||||
* @var array<string, int>
|
||||
*/
|
||||
private array $top_domains;
|
||||
|
||||
/**
|
||||
* Full decoded API response for storage.
|
||||
*
|
||||
* @var array<mixed, mixed>
|
||||
*/
|
||||
private array $raw_response;
|
||||
|
||||
/**
|
||||
* Constructs a new scan result.
|
||||
*
|
||||
* @param int $match_count Number of pages or images found.
|
||||
* @param array<string, int> $top_domains Domain → count map.
|
||||
* @param array<mixed, mixed> $raw_response Full decoded API payload.
|
||||
*/
|
||||
public function __construct( int $match_count, array $top_domains, array $raw_response ) {
|
||||
$this->match_count = $match_count;
|
||||
$this->top_domains = $top_domains;
|
||||
$this->raw_response = $raw_response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of matching pages or images found.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function match_count(): int {
|
||||
return $this->match_count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the top-domain occurrence map (domain → count), max 10 entries.
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function top_domains(): array {
|
||||
return $this->top_domains;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full decoded API response payload.
|
||||
*
|
||||
* @return array<mixed, mixed>
|
||||
*/
|
||||
public function raw_response(): array {
|
||||
return $this->raw_response;
|
||||
}
|
||||
}
|
||||
151
includes/External/TinEyeProvider.php
vendored
Normal file
151
includes/External/TinEyeProvider.php
vendored
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<?php
|
||||
/**
|
||||
* TinEye reverse image search provider.
|
||||
*
|
||||
* @package MediaRightsAudit\External
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\External;
|
||||
|
||||
use MediaRightsAudit\Admin\Settings;
|
||||
|
||||
/**
|
||||
* Submits images to the TinEye Commercial API and normalises the response
|
||||
* into a ScanResult.
|
||||
*
|
||||
* Uses URL-based image submission (GET request with image_url parameter).
|
||||
* Reads the API key from the plugin settings (tineye_api_key).
|
||||
* Match count reflects the number of distinct matching images found;
|
||||
* top domains are extracted from the backlink URLs of all matches.
|
||||
*/
|
||||
class TinEyeProvider extends AbstractProvider {
|
||||
|
||||
/**
|
||||
* TinEye Commercial API search endpoint.
|
||||
*/
|
||||
const ENDPOINT = 'https://api.tineye.com/rest/search/';
|
||||
|
||||
/**
|
||||
* Returns the machine-readable provider identifier.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function provider_slug(): string {
|
||||
return 'tineye';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the human-readable provider display name.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function provider_name(): string {
|
||||
return 'TinEye';
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the image URL to TinEye and returns a ScanResult.
|
||||
*
|
||||
* @param int $attachment_id WordPress attachment ID.
|
||||
* @param string $file_url Public URL of the image.
|
||||
*
|
||||
* @return ScanResult
|
||||
*
|
||||
* @throws \RuntimeException On configuration, HTTP, or parse errors.
|
||||
*/
|
||||
protected function do_scan( int $attachment_id, string $file_url ): ScanResult {
|
||||
$api_key = $this->get_api_key();
|
||||
if ( '' === $api_key ) {
|
||||
throw new \RuntimeException( 'TinEye API key is not configured.' );
|
||||
}
|
||||
|
||||
$url = add_query_arg(
|
||||
array(
|
||||
'api_key' => $api_key,
|
||||
'image_url' => $file_url,
|
||||
),
|
||||
self::ENDPOINT
|
||||
);
|
||||
|
||||
$response = wp_remote_get( $url, array( 'timeout' => 30 ) );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
throw new \RuntimeException( $response->get_error_message() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
}
|
||||
|
||||
$http_code = (int) wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 !== $http_code ) {
|
||||
throw new \RuntimeException(
|
||||
sprintf( 'TinEye API returned HTTP %d.', $http_code ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
);
|
||||
}
|
||||
|
||||
$raw_body = wp_remote_retrieve_body( $response );
|
||||
$decoded = json_decode( $raw_body, true );
|
||||
|
||||
if ( ! is_array( $decoded ) ) {
|
||||
throw new \RuntimeException( 'Failed to parse TinEye API response.' );
|
||||
}
|
||||
|
||||
return $this->parse_response( $decoded );
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reads the TinEye API key from plugin settings.
|
||||
*
|
||||
* @return string Empty string if not configured.
|
||||
*/
|
||||
private function get_api_key(): string {
|
||||
$raw = get_option( Settings::OPTION_NAME, array() );
|
||||
$opts = is_array( $raw ) ? $raw : array();
|
||||
$val = $opts['tineye_api_key'] ?? null;
|
||||
return is_string( $val ) ? trim( $val ) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a decoded TinEye API response into a ScanResult.
|
||||
*
|
||||
* Navigates results.matches, extracts backlink page URLs to build the
|
||||
* domain-frequency map, and counts distinct image matches.
|
||||
*
|
||||
* @param array<mixed, mixed> $decoded json_decode()'d API response.
|
||||
*
|
||||
* @return ScanResult
|
||||
*/
|
||||
private function parse_response( array $decoded ): ScanResult {
|
||||
$results_raw = $decoded['results'] ?? null;
|
||||
$results = is_array( $results_raw ) ? $results_raw : array();
|
||||
|
||||
$matches_raw = $results['matches'] ?? null;
|
||||
$matches = is_array( $matches_raw ) ? $matches_raw : array();
|
||||
|
||||
$backlink_items = array();
|
||||
foreach ( $matches as $match ) {
|
||||
if ( ! is_array( $match ) ) {
|
||||
continue;
|
||||
}
|
||||
$bls_raw = $match['backlinks'] ?? null;
|
||||
if ( ! is_array( $bls_raw ) ) {
|
||||
continue;
|
||||
}
|
||||
foreach ( $bls_raw as $bl ) {
|
||||
if ( ! is_array( $bl ) ) {
|
||||
continue;
|
||||
}
|
||||
$url_val = $bl['url'] ?? null;
|
||||
if ( is_string( $url_val ) && '' !== $url_val ) {
|
||||
$backlink_items[] = array( 'url' => $url_val );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$match_count = count( $matches );
|
||||
$top_domains = $this->extract_top_domains( $backlink_items );
|
||||
|
||||
return new ScanResult( $match_count, $top_domains, $decoded );
|
||||
}
|
||||
}
|
||||
175
includes/Internal/AttachmentIndexer.php
Normal file
175
includes/Internal/AttachmentIndexer.php
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<?php
|
||||
/**
|
||||
* Indexes image attachments into mra_media_index.
|
||||
*
|
||||
* @package MediaRightsAudit\Internal
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\Internal;
|
||||
|
||||
use MediaRightsAudit\Core\Queue\Scheduler;
|
||||
|
||||
/**
|
||||
* Populates mra_media_index with one row per image attachment.
|
||||
*
|
||||
* Processing order: oldest-first (ASC by attachment ID).
|
||||
* Idempotent: safely re-runnable; existing rows are never overwritten.
|
||||
*/
|
||||
class AttachmentIndexer {
|
||||
|
||||
/**
|
||||
* Action Scheduler hook name for background indexing.
|
||||
*/
|
||||
const AS_HOOK = 'mra_internal_index_batch';
|
||||
|
||||
/**
|
||||
* Indexes one batch of not-yet-indexed image attachments.
|
||||
*
|
||||
* @param int $batch_size Maximum number of attachments to process.
|
||||
*
|
||||
* @return int Number of attachments newly added to the index.
|
||||
*/
|
||||
public static function index_batch( int $batch_size = 50 ): int {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT p.ID, p.guid, p.post_mime_type
|
||||
FROM {$wpdb->posts} p
|
||||
LEFT JOIN {$wpdb->prefix}mra_media_index i ON p.ID = i.attachment_id
|
||||
WHERE p.post_type = 'attachment'
|
||||
AND p.post_mime_type LIKE %s
|
||||
AND p.post_status != 'trash'
|
||||
AND i.attachment_id IS NULL
|
||||
ORDER BY p.ID ASC
|
||||
LIMIT %d",
|
||||
'image/%',
|
||||
$batch_size
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery
|
||||
|
||||
if ( ! $rows ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Prime postmeta cache so get_attached_file() and wp_get_attachment_url() are fast.
|
||||
$ids = array_map( 'intval', array_column( $rows, 'ID' ) );
|
||||
update_meta_cache( 'post', $ids );
|
||||
|
||||
$now = current_time( 'mysql', true );
|
||||
$count = 0;
|
||||
|
||||
foreach ( $rows as $row ) {
|
||||
$attachment_id = intval( $row['ID'] );
|
||||
$guid = strval( $row['guid'] );
|
||||
$mime_type = strval( $row['post_mime_type'] );
|
||||
|
||||
$file_url = wp_get_attachment_url( $attachment_id );
|
||||
$file_url = $file_url ? $file_url : $guid;
|
||||
$file_path = get_attached_file( $attachment_id );
|
||||
$file_size = ( $file_path && file_exists( $file_path ) )
|
||||
? intval( filesize( $file_path ) )
|
||||
: 0;
|
||||
|
||||
$inserted = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->prefix . 'mra_media_index',
|
||||
array(
|
||||
'attachment_id' => $attachment_id,
|
||||
'file_url' => $file_url,
|
||||
'file_name' => self::extract_filename( $file_url ),
|
||||
'mime_type' => $mime_type,
|
||||
'file_size' => $file_size,
|
||||
'created_at' => $now,
|
||||
),
|
||||
array( '%d', '%s', '%s', '%s', '%d', '%s' )
|
||||
);
|
||||
|
||||
if ( false !== $inserted ) {
|
||||
++$count;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the bare filename from a URL.
|
||||
*
|
||||
* Uses PHP's parse_url() so this method is testable without WordPress.
|
||||
*
|
||||
* @param string $url Absolute or relative URL.
|
||||
*
|
||||
* @return string Filename, or the original string if parsing fails.
|
||||
*/
|
||||
public static function extract_filename( string $url ): string {
|
||||
$path = parse_url( $url, PHP_URL_PATH ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url
|
||||
return is_string( $path ) ? basename( $path ) : basename( $url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of image attachments not yet in the index.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public static function get_pending_count(): int {
|
||||
global $wpdb;
|
||||
|
||||
$result = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
"SELECT COUNT(p.ID)
|
||||
FROM {$wpdb->posts} p
|
||||
LEFT JOIN {$wpdb->prefix}mra_media_index i ON p.ID = i.attachment_id
|
||||
WHERE p.post_type = 'attachment'
|
||||
AND p.post_mime_type LIKE 'image/%'
|
||||
AND p.post_status != 'trash'
|
||||
AND i.attachment_id IS NULL"
|
||||
);
|
||||
|
||||
return intval( $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of attachments currently in the index.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public static function get_indexed_count(): int {
|
||||
global $wpdb;
|
||||
|
||||
$result = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index"
|
||||
);
|
||||
|
||||
return intval( $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes one scheduled batch and re-queues or hands off to UsageScanner.
|
||||
*
|
||||
* Called by Action Scheduler via the mra_internal_index_batch hook.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function process_scheduled_batch(): void {
|
||||
self::index_batch();
|
||||
|
||||
if ( self::get_pending_count() > 0 ) {
|
||||
Scheduler::schedule_single( self::AS_HOOK );
|
||||
} else {
|
||||
Scheduler::schedule_single( UsageScanner::AS_HOOK );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues a background indexing job if one is not already pending.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function schedule(): void {
|
||||
if ( ! Scheduler::has_pending( self::AS_HOOK ) ) {
|
||||
Scheduler::schedule_single( self::AS_HOOK );
|
||||
}
|
||||
}
|
||||
}
|
||||
464
includes/Internal/UsageScanner.php
Normal file
464
includes/Internal/UsageScanner.php
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
<?php
|
||||
/**
|
||||
* Scans where each indexed attachment is used across the site.
|
||||
*
|
||||
* @package MediaRightsAudit\Internal
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\Internal;
|
||||
|
||||
use MediaRightsAudit\Core\Queue\Scheduler;
|
||||
|
||||
/**
|
||||
* Populates mra_media_usage with one row per attachment–post reference.
|
||||
*
|
||||
* Three context types are detected:
|
||||
* - featured : the post uses this attachment as its featured image (_thumbnail_id).
|
||||
* - content : the attachment ID appears in post_content (block editor class wp-image-{id}).
|
||||
* - meta : a custom field value contains the attachment filename.
|
||||
*
|
||||
* Scanning is idempotent: existing usage rows are deleted before each attachment's scan.
|
||||
*
|
||||
* Extension hooks:
|
||||
* - mra/internal/scanner/post_types filter to override scanned post types.
|
||||
* - mra/internal/scanner/meta_keys filter to add private meta keys to the meta scan.
|
||||
*/
|
||||
class UsageScanner {
|
||||
|
||||
/**
|
||||
* Action Scheduler hook name for background scanning.
|
||||
*/
|
||||
const AS_HOOK = 'mra_internal_scan_batch';
|
||||
|
||||
/**
|
||||
* Scans one batch of indexed-but-unscanned attachments for usage.
|
||||
*
|
||||
* @param int $batch_size Maximum number of attachments to scan per call.
|
||||
*
|
||||
* @return int Number of attachments scanned.
|
||||
*/
|
||||
public static function scan_batch( int $batch_size = 50 ): int {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT attachment_id, file_url
|
||||
FROM {$wpdb->prefix}mra_media_index
|
||||
WHERE internal_scanned_at IS NULL
|
||||
ORDER BY attachment_id ASC
|
||||
LIMIT %d",
|
||||
$batch_size
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery
|
||||
|
||||
if ( ! $rows ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ( $rows as $row ) {
|
||||
self::scan_attachment(
|
||||
intval( $row['attachment_id'] ),
|
||||
strval( $row['file_url'] )
|
||||
);
|
||||
}
|
||||
|
||||
return count( $rows );
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans a single attachment for usage and marks it as scanned.
|
||||
*
|
||||
* Deletes existing mra_media_usage rows first (idempotent).
|
||||
*
|
||||
* @param int $attachment_id WordPress attachment ID.
|
||||
* @param string $file_url Canonical URL of the original file.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function scan_attachment( int $attachment_id, string $file_url ): void {
|
||||
global $wpdb;
|
||||
|
||||
$wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->prefix . 'mra_media_usage',
|
||||
array( 'attachment_id' => $attachment_id ),
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
self::scan_featured( $attachment_id );
|
||||
self::scan_content( $attachment_id );
|
||||
self::scan_meta( $attachment_id, $file_url );
|
||||
|
||||
$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->prefix . 'mra_media_index',
|
||||
array( 'internal_scanned_at' => current_time( 'mysql', true ) ),
|
||||
array( 'attachment_id' => $attachment_id ),
|
||||
array( '%s' ),
|
||||
array( '%d' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds posts that use this attachment as a featured image.
|
||||
*
|
||||
* @param int $attachment_id Attachment ID to search for.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function scan_featured( int $attachment_id ): void {
|
||||
global $wpdb;
|
||||
|
||||
$raw_types = apply_filters( 'mra/internal/scanner/post_types', get_post_types( array( 'public' => true ) ) ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
|
||||
$post_types = array();
|
||||
if ( is_array( $raw_types ) ) {
|
||||
foreach ( $raw_types as $type ) {
|
||||
if ( is_string( $type ) ) {
|
||||
$post_types[] = $type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( empty( $post_types ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$placeholders = implode( ',', array_fill( 0, count( $post_types ), '%s' ) );
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT pm.post_id, p.post_type
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
|
||||
WHERE pm.meta_key = '_thumbnail_id'
|
||||
AND pm.meta_value = %d
|
||||
AND p.post_status NOT IN ('trash','auto-draft')
|
||||
AND p.post_type IN ({$placeholders})",
|
||||
array_merge( array( $attachment_id ), $post_types )
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
|
||||
if ( ! $results ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( $results as $row ) {
|
||||
self::insert_usage(
|
||||
$attachment_id,
|
||||
intval( $row['post_id'] ),
|
||||
strval( $row['post_type'] ),
|
||||
'featured'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds posts whose post_content references this attachment.
|
||||
*
|
||||
* Detects the block-editor class `wp-image-{id}` written by Gutenberg.
|
||||
*
|
||||
* @param int $attachment_id Attachment ID to search for.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function scan_content( int $attachment_id ): void {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT ID, post_type
|
||||
FROM {$wpdb->posts}
|
||||
WHERE post_content LIKE %s
|
||||
AND post_status NOT IN ('trash','auto-draft')
|
||||
AND post_type NOT IN ('attachment','revision')",
|
||||
'%wp-image-' . $attachment_id . '%'
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( ! $results ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( $results as $row ) {
|
||||
self::insert_usage(
|
||||
$attachment_id,
|
||||
intval( $row['ID'] ),
|
||||
strval( $row['post_type'] ),
|
||||
'content'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds custom field values that reference this attachment by filename.
|
||||
*
|
||||
* Scans non-private meta keys by default. Supports basic serialized values (ACF, etc.).
|
||||
* The mra/internal/scanner/meta_keys filter can add private (underscore-prefixed) keys.
|
||||
*
|
||||
* @param int $attachment_id Attachment ID.
|
||||
* @param string $file_url Canonical URL, used to extract the filename for matching.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function scan_meta( int $attachment_id, string $file_url ): void {
|
||||
global $wpdb;
|
||||
|
||||
$filename = self::url_basename( $file_url );
|
||||
if ( '' === $filename ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$like = '%' . $wpdb->esc_like( $filename ) . '%';
|
||||
|
||||
// esc_like('_') returns '\' + '_'; appending '%' produces the LIKE pattern '\_%'
|
||||
// which matches any meta_key beginning with a literal underscore.
|
||||
$meta_key_like = $wpdb->esc_like( '_' ) . '%';
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT pm.post_id, p.post_type, pm.meta_key, pm.meta_value
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
|
||||
WHERE pm.meta_key NOT LIKE %s
|
||||
AND pm.meta_value LIKE %s
|
||||
AND p.post_status NOT IN ('trash','auto-draft')
|
||||
AND p.post_type NOT IN ('attachment','revision')",
|
||||
$meta_key_like,
|
||||
$like
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||
|
||||
$raw_extra_keys = apply_filters( 'mra/internal/scanner/meta_keys', array() ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
|
||||
$extra_keys = array();
|
||||
if ( is_array( $raw_extra_keys ) ) {
|
||||
foreach ( $raw_extra_keys as $key ) {
|
||||
if ( is_string( $key ) ) {
|
||||
$extra_keys[] = $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( $extra_keys ) {
|
||||
$placeholders = implode( ',', array_fill( 0, count( $extra_keys ), '%s' ) );
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||
$extra_results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT pm.post_id, p.post_type, pm.meta_key, pm.meta_value
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
|
||||
WHERE pm.meta_key IN ({$placeholders})
|
||||
AND pm.meta_value LIKE %s
|
||||
AND p.post_status NOT IN ('trash','auto-draft')
|
||||
AND p.post_type NOT IN ('attachment','revision')",
|
||||
array_merge( $extra_keys, array( $like ) )
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||
|
||||
$results = $results
|
||||
? array_merge( $results, (array) $extra_results )
|
||||
: (array) $extra_results;
|
||||
}
|
||||
|
||||
if ( ! $results ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplicate by post_id + meta_key.
|
||||
$seen = array();
|
||||
foreach ( $results as $row ) {
|
||||
$post_id = intval( $row['post_id'] );
|
||||
$meta_key = strval( $row['meta_key'] );
|
||||
$key = $post_id . '|' . $meta_key;
|
||||
|
||||
if ( isset( $seen[ $key ] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verify match survives unserialisation (handles ACF arrays, etc.).
|
||||
$meta_value = strval( $row['meta_value'] );
|
||||
$values = self::flatten_meta_value( $meta_value );
|
||||
$matched = false;
|
||||
foreach ( $values as $v ) {
|
||||
if ( false !== strpos( $v, $filename ) ) {
|
||||
$matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( $matched ) {
|
||||
$seen[ $key ] = true;
|
||||
self::insert_usage(
|
||||
$attachment_id,
|
||||
$post_id,
|
||||
strval( $row['post_type'] ),
|
||||
'meta',
|
||||
$meta_key
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a single usage record.
|
||||
*
|
||||
* @param int $attachment_id Attachment ID.
|
||||
* @param int $post_id Post that references the attachment.
|
||||
* @param string $post_type Post type.
|
||||
* @param string $context 'featured', 'content', or 'meta'.
|
||||
* @param string|null $meta_key Meta key; only populated when context is 'meta'.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function insert_usage(
|
||||
int $attachment_id,
|
||||
int $post_id,
|
||||
string $post_type,
|
||||
string $context,
|
||||
?string $meta_key = null
|
||||
): void {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||
$wpdb->insert(
|
||||
$wpdb->prefix . 'mra_media_usage',
|
||||
array(
|
||||
'attachment_id' => $attachment_id,
|
||||
'post_id' => $post_id,
|
||||
'post_type' => $post_type,
|
||||
'context' => $context,
|
||||
'meta_key' => $meta_key,
|
||||
'created_at' => current_time( 'mysql', true ),
|
||||
),
|
||||
array( '%d', '%d', '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public helpers (also used in tests)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extracts attachment IDs referenced in post content via the wp-image-{id} class.
|
||||
*
|
||||
* Pure PHP — no WordPress dependency; safe to call in unit tests.
|
||||
*
|
||||
* @param string $content Post content string.
|
||||
*
|
||||
* @return array<int> Unique attachment IDs found.
|
||||
*/
|
||||
public static function extract_attachment_ids_from_content( string $content ): array {
|
||||
if ( ! preg_match_all( '/wp-image-(\d+)/', $content, $matches ) ) {
|
||||
return array();
|
||||
}
|
||||
return array_values( array_unique( array_map( 'intval', $matches[1] ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens a meta value (including serialized arrays) to a list of strings.
|
||||
*
|
||||
* Pure PHP — no WordPress dependency; safe to call in unit tests.
|
||||
*
|
||||
* @param mixed $value Raw meta value.
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function flatten_meta_value( $value ): array {
|
||||
if ( is_string( $value ) ) {
|
||||
// All PHP serialized strings (except the literal "N;") have ':' as the second character.
|
||||
if ( 'N;' === $value || ( strlen( $value ) >= 2 && ':' === $value[1] ) ) {
|
||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
|
||||
$unserialized = unserialize( $value, array( 'allowed_classes' => false ) );
|
||||
if ( false !== $unserialized && is_array( $unserialized ) ) {
|
||||
return self::flatten_meta_value( $unserialized );
|
||||
}
|
||||
}
|
||||
return array( $value );
|
||||
}
|
||||
|
||||
if ( is_array( $value ) ) {
|
||||
$flat = array();
|
||||
foreach ( $value as $item ) {
|
||||
foreach ( self::flatten_meta_value( $item ) as $s ) {
|
||||
$flat[] = $s;
|
||||
}
|
||||
}
|
||||
return $flat;
|
||||
}
|
||||
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the filename component of a URL.
|
||||
*
|
||||
* Pure PHP — no WordPress dependency; safe to call in unit tests.
|
||||
*
|
||||
* @param string $url Full URL.
|
||||
*
|
||||
* @return string Filename, or empty string on failure.
|
||||
*/
|
||||
public static function url_basename( string $url ): string {
|
||||
$path = parse_url( $url, PHP_URL_PATH ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url
|
||||
return is_string( $path ) ? basename( $path ) : '';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Scheduling helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the number of indexed attachments not yet usage-scanned.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public static function get_pending_count(): int {
|
||||
global $wpdb;
|
||||
|
||||
$result = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
"SELECT COUNT(*)
|
||||
FROM {$wpdb->prefix}mra_media_index
|
||||
WHERE internal_scanned_at IS NULL"
|
||||
);
|
||||
|
||||
return intval( $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes one scheduled batch and re-queues if more attachments remain.
|
||||
*
|
||||
* Called by Action Scheduler via the mra_internal_scan_batch hook.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function process_scheduled_batch(): void {
|
||||
self::scan_batch();
|
||||
|
||||
if ( self::get_pending_count() > 0 ) {
|
||||
Scheduler::schedule_single( self::AS_HOOK );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues a background scan job if one is not already pending.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function schedule(): void {
|
||||
if ( ! Scheduler::has_pending( self::AS_HOOK ) ) {
|
||||
Scheduler::schedule_single( self::AS_HOOK );
|
||||
}
|
||||
}
|
||||
}
|
||||
160
includes/Privacy/DataEraser.php
Normal file
160
includes/Privacy/DataEraser.php
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<?php
|
||||
/**
|
||||
* GDPR personal data eraser.
|
||||
*
|
||||
* @package MediaRightsAudit\Privacy
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\Privacy;
|
||||
|
||||
/**
|
||||
* Erases Media Audit data for a given user via the WordPress privacy tools.
|
||||
*
|
||||
* Registered via the wp_privacy_personal_data_erasers filter. Removes all rows
|
||||
* from mra_media_index, mra_media_usage, and mra_external_results that belong
|
||||
* to attachments uploaded by the requested user.
|
||||
*
|
||||
* Erasure is a hard delete: once removed, the data cannot be recovered. The
|
||||
* attachment itself (wp_posts row) is not touched — only plugin-specific data.
|
||||
*/
|
||||
class DataEraser {
|
||||
|
||||
/**
|
||||
* Registers this eraser with the WordPress privacy API.
|
||||
*
|
||||
* Hooked to wp_privacy_personal_data_erasers.
|
||||
*
|
||||
* @param array<string, mixed> $erasers Accumulated eraser list.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function register( array $erasers ): array {
|
||||
$erasers['robotstxt-mediaaudit'] = array(
|
||||
'eraser_friendly_name' => __( 'Media Audit', 'robotstxt-mediaaudit' ),
|
||||
'callback' => array( self::class, 'erase' ),
|
||||
);
|
||||
return $erasers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erases Media Audit data for the user identified by $email_address.
|
||||
*
|
||||
* @param string $email_address User e-mail address.
|
||||
* @param int $page Pagination page (unused; all data erased in one pass).
|
||||
*
|
||||
* @return array{items_removed: int, items_retained: int, messages: array<mixed>, done: bool}
|
||||
*/
|
||||
public static function erase( string $email_address, int $page = 1 ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- required by WP privacy API signature
|
||||
$user = get_user_by( 'email', $email_address );
|
||||
if ( ! $user instanceof \WP_User ) {
|
||||
return self::done_response( 0 );
|
||||
}
|
||||
|
||||
$attachment_ids = self::get_attachment_ids( $user->ID );
|
||||
if ( empty( $attachment_ids ) ) {
|
||||
return self::done_response( 0 );
|
||||
}
|
||||
|
||||
$removed = self::delete_plugin_data( $attachment_ids );
|
||||
|
||||
return self::done_response( $removed );
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Builds a standard "done" response array.
|
||||
*
|
||||
* @param int $removed Number of records removed.
|
||||
*
|
||||
* @return array{items_removed: int, items_retained: int, messages: array<mixed>, done: bool}
|
||||
*/
|
||||
private static function done_response( int $removed ): array {
|
||||
return array(
|
||||
'items_removed' => $removed,
|
||||
'items_retained' => 0,
|
||||
'messages' => array(),
|
||||
'done' => true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all indexed attachment IDs for a given user (no pagination).
|
||||
*
|
||||
* @param int $user_id WordPress user ID.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private static function get_attachment_ids( int $user_id ): array {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$raw = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT p.ID
|
||||
FROM {$wpdb->posts} p
|
||||
INNER JOIN {$wpdb->prefix}mra_media_index mi ON p.ID = mi.attachment_id
|
||||
WHERE p.post_type = 'attachment'
|
||||
AND p.post_author = %d
|
||||
ORDER BY p.ID ASC",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
|
||||
if ( ! is_array( $raw ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
return array_map( static fn ( mixed $v ): int => is_numeric( $v ) ? (int) $v : 0, $raw );
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all plugin data rows for the given attachment IDs.
|
||||
*
|
||||
* Removes records from mra_external_results, mra_media_usage, and
|
||||
* mra_media_index. Returns the total number of affected rows.
|
||||
*
|
||||
* @param array<int, int> $ids Attachment IDs to delete.
|
||||
*
|
||||
* @return int Total rows deleted across all three tables.
|
||||
*/
|
||||
private static function delete_plugin_data( array $ids ): int {
|
||||
global $wpdb;
|
||||
|
||||
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
|
||||
$removed = 0;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM {$wpdb->prefix}mra_external_results WHERE attachment_id IN ({$placeholders})",
|
||||
...$ids
|
||||
)
|
||||
);
|
||||
$removed += (int) $wpdb->rows_affected;
|
||||
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM {$wpdb->prefix}mra_media_usage WHERE attachment_id IN ({$placeholders})",
|
||||
...$ids
|
||||
)
|
||||
);
|
||||
$removed += (int) $wpdb->rows_affected;
|
||||
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM {$wpdb->prefix}mra_media_index WHERE attachment_id IN ({$placeholders})",
|
||||
...$ids
|
||||
)
|
||||
);
|
||||
$removed += (int) $wpdb->rows_affected;
|
||||
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
|
||||
return $removed;
|
||||
}
|
||||
}
|
||||
316
includes/Privacy/DataExporter.php
Normal file
316
includes/Privacy/DataExporter.php
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
<?php
|
||||
/**
|
||||
* GDPR personal data exporter.
|
||||
*
|
||||
* @package MediaRightsAudit\Privacy
|
||||
*/
|
||||
|
||||
namespace MediaRightsAudit\Privacy;
|
||||
|
||||
/**
|
||||
* Exports Media Audit data for a given user via the WordPress privacy tools.
|
||||
*
|
||||
* Registered via the wp_privacy_personal_data_exporters filter. Exports rows
|
||||
* from mra_media_index and mra_external_results for all attachments uploaded
|
||||
* by the requested user. Results are paginated at 20 attachments per page.
|
||||
*/
|
||||
class DataExporter {
|
||||
|
||||
/**
|
||||
* Group ID for media index export items.
|
||||
*/
|
||||
const GROUP_INDEX = 'mra-media-index';
|
||||
|
||||
/**
|
||||
* Group ID for external results export items.
|
||||
*/
|
||||
const GROUP_RESULTS = 'mra-external-results';
|
||||
|
||||
/**
|
||||
* Number of attachments processed per export page.
|
||||
*/
|
||||
const PER_PAGE = 20;
|
||||
|
||||
/**
|
||||
* Registers this exporter with the WordPress privacy API.
|
||||
*
|
||||
* Hooked to wp_privacy_personal_data_exporters.
|
||||
*
|
||||
* @param array<string, mixed> $exporters Accumulated exporter list.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function register( array $exporters ): array {
|
||||
$exporters['robotstxt-mediaaudit'] = array(
|
||||
'exporter_friendly_name' => __( 'Media Audit', 'robotstxt-mediaaudit' ),
|
||||
'callback' => array( self::class, 'export' ),
|
||||
);
|
||||
return $exporters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports Media Audit data for the user identified by $email_address.
|
||||
*
|
||||
* @param string $email_address User e-mail address.
|
||||
* @param int $page Current pagination page (1-based).
|
||||
*
|
||||
* @return array{data: array<mixed>, done: bool}
|
||||
*/
|
||||
public static function export( string $email_address, int $page = 1 ): array {
|
||||
$user = get_user_by( 'email', $email_address );
|
||||
if ( ! $user instanceof \WP_User ) {
|
||||
return array(
|
||||
'data' => array(),
|
||||
'done' => true,
|
||||
);
|
||||
}
|
||||
|
||||
$offset = ( $page - 1 ) * self::PER_PAGE;
|
||||
$attachment_ids = self::get_attachment_ids( $user->ID, $offset, self::PER_PAGE );
|
||||
|
||||
if ( empty( $attachment_ids ) ) {
|
||||
return array(
|
||||
'data' => array(),
|
||||
'done' => true,
|
||||
);
|
||||
}
|
||||
|
||||
$data = array();
|
||||
|
||||
foreach ( self::get_index_rows( $attachment_ids ) as $row ) {
|
||||
if ( ! is_array( $row ) ) {
|
||||
continue;
|
||||
}
|
||||
$aid_val = $row['attachment_id'] ?? null;
|
||||
$aid = is_numeric( $aid_val ) ? (int) $aid_val : 0;
|
||||
$data[] = array(
|
||||
'group_id' => self::GROUP_INDEX,
|
||||
'group_label' => __( 'Media Audit – Indexed Files', 'robotstxt-mediaaudit' ),
|
||||
'item_id' => 'mra-index-' . $aid,
|
||||
'data' => self::format_index_row( $row, $aid ),
|
||||
);
|
||||
}
|
||||
|
||||
foreach ( self::get_result_rows( $attachment_ids ) as $row ) {
|
||||
if ( ! is_array( $row ) ) {
|
||||
continue;
|
||||
}
|
||||
$aid_val = $row['attachment_id'] ?? null;
|
||||
$aid = is_numeric( $aid_val ) ? (int) $aid_val : 0;
|
||||
$provider_val = $row['provider'] ?? null;
|
||||
$provider = is_string( $provider_val ) ? $provider_val : '';
|
||||
$data[] = array(
|
||||
'group_id' => self::GROUP_RESULTS,
|
||||
'group_label' => __( 'Media Audit – External Scan Results', 'robotstxt-mediaaudit' ),
|
||||
'item_id' => 'mra-result-' . $aid . '-' . $provider,
|
||||
'data' => self::format_result_row( $row, $aid, $provider ),
|
||||
);
|
||||
}
|
||||
|
||||
$total = self::count_attachments( $user->ID );
|
||||
$done = ( $page * self::PER_PAGE ) >= $total;
|
||||
|
||||
return array(
|
||||
'data' => $data,
|
||||
'done' => $done,
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns attachment IDs from the plugin index for a given user, paginated.
|
||||
*
|
||||
* @param int $user_id WordPress user ID.
|
||||
* @param int $offset Number of rows to skip.
|
||||
* @param int $limit Maximum number of rows to return.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private static function get_attachment_ids( int $user_id, int $offset, int $limit ): array {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$raw = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT p.ID
|
||||
FROM {$wpdb->posts} p
|
||||
INNER JOIN {$wpdb->prefix}mra_media_index mi ON p.ID = mi.attachment_id
|
||||
WHERE p.post_type = 'attachment'
|
||||
AND p.post_author = %d
|
||||
ORDER BY p.ID ASC
|
||||
LIMIT %d OFFSET %d",
|
||||
$user_id,
|
||||
$limit,
|
||||
$offset
|
||||
)
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
|
||||
if ( ! is_array( $raw ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
return array_map( static fn ( mixed $v ): int => is_numeric( $v ) ? (int) $v : 0, $raw );
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts all indexed attachments for a given user.
|
||||
*
|
||||
* @param int $user_id WordPress user ID.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private static function count_attachments( int $user_id ): int {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$result = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM {$wpdb->posts} p
|
||||
INNER JOIN {$wpdb->prefix}mra_media_index mi ON p.ID = mi.attachment_id
|
||||
WHERE p.post_type = 'attachment'
|
||||
AND p.post_author = %d",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
|
||||
return is_numeric( $result ) ? (int) $result : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches index rows for the given attachment IDs.
|
||||
*
|
||||
* @param array<int, int> $ids Attachment IDs.
|
||||
*
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private static function get_index_rows( array $ids ): array {
|
||||
global $wpdb;
|
||||
|
||||
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT attachment_id, file_name, file_url, mime_type, external_status,
|
||||
internal_scanned_at, external_scanned_at
|
||||
FROM {$wpdb->prefix}mra_media_index
|
||||
WHERE attachment_id IN ({$placeholders})",
|
||||
...$ids
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
|
||||
return is_array( $rows ) ? $rows : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches external result rows for the given attachment IDs.
|
||||
*
|
||||
* @param array<int, int> $ids Attachment IDs.
|
||||
*
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private static function get_result_rows( array $ids ): array {
|
||||
global $wpdb;
|
||||
|
||||
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT attachment_id, provider, match_count, created_at
|
||||
FROM {$wpdb->prefix}mra_external_results
|
||||
WHERE attachment_id IN ({$placeholders})",
|
||||
...$ids
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
|
||||
return is_array( $rows ) ? $rows : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a mra_media_index row into WordPress privacy export data items.
|
||||
*
|
||||
* @param array<mixed, mixed> $row DB row.
|
||||
* @param int $aid Attachment ID.
|
||||
*
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private static function format_index_row( array $row, int $aid ): array {
|
||||
$file_name = is_string( $row['file_name'] ?? null ) ? $row['file_name'] : '';
|
||||
$file_url = is_string( $row['file_url'] ?? null ) ? $row['file_url'] : '';
|
||||
$status = is_string( $row['external_status'] ?? null ) ? $row['external_status'] : '';
|
||||
$int_at = is_string( $row['internal_scanned_at'] ?? null ) ? $row['internal_scanned_at'] : '';
|
||||
$ext_at = is_string( $row['external_scanned_at'] ?? null ) ? $row['external_scanned_at'] : '';
|
||||
|
||||
return array(
|
||||
array(
|
||||
'name' => __( 'Attachment ID', 'robotstxt-mediaaudit' ),
|
||||
'value' => (string) $aid,
|
||||
),
|
||||
array(
|
||||
'name' => __( 'Filename', 'robotstxt-mediaaudit' ),
|
||||
'value' => $file_name,
|
||||
),
|
||||
array(
|
||||
'name' => __( 'File URL', 'robotstxt-mediaaudit' ),
|
||||
'value' => $file_url,
|
||||
),
|
||||
array(
|
||||
'name' => __( 'External Scan Status', 'robotstxt-mediaaudit' ),
|
||||
'value' => $status,
|
||||
),
|
||||
array(
|
||||
'name' => __( 'Last Scanned (internal)', 'robotstxt-mediaaudit' ),
|
||||
'value' => $int_at,
|
||||
),
|
||||
array(
|
||||
'name' => __( 'Last Scanned (external)', 'robotstxt-mediaaudit' ),
|
||||
'value' => $ext_at,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a mra_external_results row into WordPress privacy export data items.
|
||||
*
|
||||
* @param array<mixed, mixed> $row DB row.
|
||||
* @param int $aid Attachment ID.
|
||||
* @param string $provider Provider slug.
|
||||
*
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private static function format_result_row( array $row, int $aid, string $provider ): array {
|
||||
$mc_val = $row['match_count'] ?? null;
|
||||
$match_count = is_numeric( $mc_val ) ? (int) $mc_val : 0;
|
||||
$created_at = is_string( $row['created_at'] ?? null ) ? $row['created_at'] : '';
|
||||
|
||||
return array(
|
||||
array(
|
||||
'name' => __( 'Attachment ID', 'robotstxt-mediaaudit' ),
|
||||
'value' => (string) $aid,
|
||||
),
|
||||
array(
|
||||
'name' => __( 'Provider', 'robotstxt-mediaaudit' ),
|
||||
'value' => $provider,
|
||||
),
|
||||
array(
|
||||
'name' => __( 'Match Count', 'robotstxt-mediaaudit' ),
|
||||
'value' => (string) $match_count,
|
||||
),
|
||||
array(
|
||||
'name' => __( 'Scanned At', 'robotstxt-mediaaudit' ),
|
||||
'value' => $created_at,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
BIN
languages/robotstxt-mediaaudit-ca.mo
Normal file
BIN
languages/robotstxt-mediaaudit-ca.mo
Normal file
Binary file not shown.
535
languages/robotstxt-mediaaudit-ca.po
Normal file
535
languages/robotstxt-mediaaudit-ca.po
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
# Copyright (C) 2026 Javier Casares
|
||||
# This file is distributed under the GPL-3.0-or-later.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Media Audit (by ROBOTSTXT) 1.0.0\n"
|
||||
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/robotstxt-"
|
||||
"mediaaudit\n"
|
||||
"POT-Creation-Date: 2026-05-02T06:06:40+00:00\n"
|
||||
"PO-Revision-Date: 2026-05-01 07:41+0000\n"
|
||||
"Last-Translator: Javier Casares <javier@casares.org>\n"
|
||||
"Language-Team: Catalan <ca@li.org>\n"
|
||||
"Language: ca\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: WP-CLI 2.12.0\n"
|
||||
"X-Domain: robotstxt-mediaaudit\n"
|
||||
|
||||
#. Plugin Name of the plugin
|
||||
#: robotstxt-mediaaudit.php
|
||||
msgid "Media Audit (by ROBOTSTXT)"
|
||||
msgstr "Auditoria de mitjans (by ROBOTSTXT)"
|
||||
|
||||
#. Plugin URI of the plugin
|
||||
#: robotstxt-mediaaudit.php
|
||||
msgid "https://robotstxt.es/plugins/media-audit/"
|
||||
msgstr "https://robotstxt.es/plugins/media-audit/"
|
||||
|
||||
#. Description of the plugin
|
||||
#: robotstxt-mediaaudit.php
|
||||
msgid ""
|
||||
"Internal media library usage auditing and external reverse image search to "
|
||||
"detect potential copyright issues."
|
||||
msgstr ""
|
||||
"Auditoria de l'ús de la biblioteca de fitxers multimèdia interna i cerca "
|
||||
"inversa d'imatges externa per detectar possibles problemes de drets d'autor."
|
||||
|
||||
#. Author of the plugin
|
||||
#: robotstxt-mediaaudit.php
|
||||
msgid "Javier Casares"
|
||||
msgstr "Javier Casares"
|
||||
|
||||
#. Author URI of the plugin
|
||||
#: robotstxt-mediaaudit.php
|
||||
msgid "https://javiercasares.com"
|
||||
msgstr "https://javiercasares.com"
|
||||
|
||||
#: includes/Admin/AuditPage.php:112
|
||||
msgid "Loading…"
|
||||
msgstr "S'està carregant…"
|
||||
|
||||
#: includes/Admin/AuditPage.php:113
|
||||
msgid "Error loading details."
|
||||
msgstr "S'ha produït un error en carregar els detalls."
|
||||
|
||||
#: includes/Admin/AuditPage.php:114 includes/Admin/AuditPage.php:190
|
||||
msgid "This image is not used in any post."
|
||||
msgstr "Aquesta imatge no s'utilitza en cap entrada."
|
||||
|
||||
#: includes/Admin/AuditPage.php:115 includes/Admin/AuditPage.php:152
|
||||
msgid "Usage Details"
|
||||
msgstr "Detalls d'ús"
|
||||
|
||||
#: includes/Admin/AuditPage.php:131 includes/Admin/Settings.php:325
|
||||
msgid "You do not have permission to access this page."
|
||||
msgstr "No teniu permís per accedir a aquesta pàgina."
|
||||
|
||||
#: includes/Admin/AuditPage.php:138 includes/Core/Plugin.php:89
|
||||
#: includes/Core/Plugin.php:90 includes/Core/Plugin.php:103
|
||||
#: includes/Core/Plugin.php:104 includes/Privacy/DataEraser.php:33
|
||||
#: includes/Privacy/DataExporter.php:45
|
||||
msgid "Media Audit"
|
||||
msgstr "Auditoria de mitjans"
|
||||
|
||||
#: includes/Admin/AuditPage.php:144
|
||||
msgid "Search"
|
||||
msgstr "Cerca"
|
||||
|
||||
#: includes/Admin/AuditPage.php:153
|
||||
msgid "Close"
|
||||
msgstr "Tanca"
|
||||
|
||||
#: includes/Admin/AuditPage.php:171 includes/Admin/AuditPage.php:459
|
||||
#: includes/Admin/AuditPage.php:510 includes/Admin/AuditPage.php:542
|
||||
msgid "Insufficient permissions."
|
||||
msgstr "Permisos insuficients."
|
||||
|
||||
#: includes/Admin/AuditPage.php:176
|
||||
msgid "Invalid attachment ID."
|
||||
msgstr "Identificador de fitxer adjunt no vàlid."
|
||||
|
||||
#: includes/Admin/AuditPage.php:184
|
||||
msgid "Featured Image"
|
||||
msgstr "Imatge destacada"
|
||||
|
||||
#: includes/Admin/AuditPage.php:185
|
||||
msgid "Post Content"
|
||||
msgstr "Contingut de l'entrada"
|
||||
|
||||
#: includes/Admin/AuditPage.php:186
|
||||
msgid "Custom Field"
|
||||
msgstr "Camp personalitzat"
|
||||
|
||||
#: includes/Admin/AuditPage.php:194
|
||||
msgid "Post"
|
||||
msgstr "Entrada"
|
||||
|
||||
#: includes/Admin/AuditPage.php:195
|
||||
msgid "Type"
|
||||
msgstr "Tipus"
|
||||
|
||||
#: includes/Admin/AuditPage.php:196
|
||||
msgid "Context"
|
||||
msgstr "Context"
|
||||
|
||||
#: includes/Admin/AuditPage.php:197
|
||||
msgid "Status"
|
||||
msgstr "Estat"
|
||||
|
||||
#: includes/Admin/AuditPage.php:240
|
||||
msgid "Internal Usage"
|
||||
msgstr "Ús intern"
|
||||
|
||||
#: includes/Admin/AuditPage.php:288
|
||||
msgid "External Scan Results"
|
||||
msgstr "Resultats de l'exploració externa"
|
||||
|
||||
#. translators: %d: number of external matches
|
||||
#: includes/Admin/AuditPage.php:312
|
||||
#, php-format
|
||||
msgid "%d match"
|
||||
msgid_plural "%d matches"
|
||||
msgstr[0] "%d coincidència"
|
||||
msgstr[1] "%d coincidències"
|
||||
|
||||
#: includes/Admin/AuditPage.php:317
|
||||
msgid "No matches"
|
||||
msgstr "Sense coincidències"
|
||||
|
||||
#: includes/Admin/AuditPage.php:328
|
||||
msgid "Domain"
|
||||
msgstr "Domini"
|
||||
|
||||
#: includes/Admin/AuditPage.php:329
|
||||
msgid "Occurrences"
|
||||
msgstr "Aparicions"
|
||||
|
||||
#: includes/Admin/AuditPage.php:350
|
||||
msgid "Consensus Domains"
|
||||
msgstr "Dominis de consens"
|
||||
|
||||
#. translators: %d: number of providers that agree
|
||||
#: includes/Admin/AuditPage.php:356
|
||||
#, php-format
|
||||
msgid "%d provider"
|
||||
msgid_plural "%d providers"
|
||||
msgstr[0] "%d proveïdor"
|
||||
msgstr[1] "%d proveïdors"
|
||||
|
||||
#: includes/Admin/AuditPage.php:388
|
||||
msgid "In Index"
|
||||
msgstr "A l'índex"
|
||||
|
||||
#. translators: %d: count of images not yet indexed
|
||||
#: includes/Admin/AuditPage.php:392
|
||||
#, php-format
|
||||
msgid "%d not yet indexed"
|
||||
msgstr "%d encara no indexades"
|
||||
|
||||
#: includes/Admin/AuditPage.php:395
|
||||
msgid "All indexed"
|
||||
msgstr "Totes indexades"
|
||||
|
||||
#: includes/Admin/AuditPage.php:400 includes/Admin/MediaListTable.php:275
|
||||
msgid "Scanned"
|
||||
msgstr "Explorades"
|
||||
|
||||
#. translators: %d: percentage
|
||||
#: includes/Admin/AuditPage.php:403
|
||||
#, php-format
|
||||
msgid "%d%% of index"
|
||||
msgstr "%d%% de l'índex"
|
||||
|
||||
#: includes/Admin/AuditPage.php:410
|
||||
msgid "Used"
|
||||
msgstr "Usades"
|
||||
|
||||
#: includes/Admin/AuditPage.php:416 includes/Admin/MediaListTable.php:200
|
||||
msgid "Unused"
|
||||
msgstr "No usades"
|
||||
|
||||
#: includes/Admin/AuditPage.php:423
|
||||
msgid "External Matches"
|
||||
msgstr "Coincidències externes"
|
||||
|
||||
#: includes/Admin/AuditPage.php:473
|
||||
msgid "Could not open output stream."
|
||||
msgstr "No s'ha pogut obrir el flux de sortida."
|
||||
|
||||
#: includes/Admin/AuditPage.php:482 includes/Privacy/DataExporter.php:257
|
||||
#: includes/Privacy/DataExporter.php:299
|
||||
msgid "Attachment ID"
|
||||
msgstr "ID del fitxer adjunt"
|
||||
|
||||
#: includes/Admin/AuditPage.php:483 includes/Admin/MediaListTable.php:52
|
||||
#: includes/Privacy/DataExporter.php:261
|
||||
msgid "Filename"
|
||||
msgstr "Nom de fitxer"
|
||||
|
||||
#: includes/Admin/AuditPage.php:484 includes/Privacy/DataExporter.php:265
|
||||
msgid "File URL"
|
||||
msgstr "URL del fitxer"
|
||||
|
||||
#: includes/Admin/AuditPage.php:485
|
||||
msgid "MIME Type"
|
||||
msgstr "Tipus MIME"
|
||||
|
||||
#: includes/Admin/AuditPage.php:486
|
||||
msgid "File Size (bytes)"
|
||||
msgstr "Mida del fitxer (bytes)"
|
||||
|
||||
#: includes/Admin/AuditPage.php:487 includes/Admin/MediaListTable.php:56
|
||||
msgid "External Status"
|
||||
msgstr "Estat extern"
|
||||
|
||||
#: includes/Admin/AuditPage.php:488 includes/Admin/MediaListTable.php:54
|
||||
msgid "Usage Count"
|
||||
msgstr "Nombre d'usos"
|
||||
|
||||
#: includes/Admin/AuditPage.php:489
|
||||
msgid "Used In (post titles)"
|
||||
msgstr "Usat a (títols d'entrades)"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:51
|
||||
msgid "Thumbnail"
|
||||
msgstr "Miniatura"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:53
|
||||
msgid "ID"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:55
|
||||
msgid "Usages"
|
||||
msgstr "Usos"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:79
|
||||
msgid "Run External Scan"
|
||||
msgstr "Executar exploració externa"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:80
|
||||
msgid "Purge External Data"
|
||||
msgstr "Eliminar dades externes"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:81
|
||||
msgid "Export CSV"
|
||||
msgstr "Exportar CSV"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:165
|
||||
msgid "Edit"
|
||||
msgstr "Edita"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:170
|
||||
msgid "View Details"
|
||||
msgstr "Visualitza els detalls"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:236
|
||||
msgid "(no title)"
|
||||
msgstr "(sense títol)"
|
||||
|
||||
#. translators: %d: number of additional usages
|
||||
#: includes/Admin/MediaListTable.php:251
|
||||
#, php-format
|
||||
msgid "+ %d more…"
|
||||
msgstr "+ %d més…"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:273
|
||||
msgid "Pending"
|
||||
msgstr "Pendent"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:274
|
||||
msgid "Queued"
|
||||
msgstr "En cua"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:276
|
||||
msgid "Matches Found"
|
||||
msgstr "Coincidències trobades"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:277
|
||||
msgid "Error"
|
||||
msgstr "Error"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:305
|
||||
msgid "View Results"
|
||||
msgstr "Visualitza els resultats"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:336
|
||||
msgid "All statuses"
|
||||
msgstr "Tots els estats"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:350
|
||||
msgid "All post types"
|
||||
msgstr "Tots els tipus d'entrada"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:365
|
||||
msgid "Unused only"
|
||||
msgstr "Només no usades"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:368
|
||||
msgid "Filter"
|
||||
msgstr "Filtra"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:378
|
||||
msgid ""
|
||||
"No indexed attachments found. Run wp mra scan-internal to populate the index."
|
||||
msgstr ""
|
||||
"No s'han trobat fitxers adjunts indexats. Executeu wp mra scan-internal per "
|
||||
"omplir l'índex."
|
||||
|
||||
#: includes/Admin/Settings.php:47
|
||||
msgid "General"
|
||||
msgstr "General"
|
||||
|
||||
#: includes/Admin/Settings.php:54
|
||||
msgid "Delete data on uninstall"
|
||||
msgstr "Elimina les dades en desinstal·lar"
|
||||
|
||||
#: includes/Admin/Settings.php:62
|
||||
msgid "API Credentials"
|
||||
msgstr "Credencials de l'API"
|
||||
|
||||
#: includes/Admin/Settings.php:69
|
||||
msgid "Google Cloud Vision API Key"
|
||||
msgstr "Clau de l'API de Google Cloud Vision"
|
||||
|
||||
#: includes/Admin/Settings.php:77
|
||||
msgid "TinEye API Key"
|
||||
msgstr "Clau de l'API de TinEye"
|
||||
|
||||
#: includes/Admin/Settings.php:85
|
||||
msgid "External Scanning"
|
||||
msgstr "Exploració externa"
|
||||
|
||||
#: includes/Admin/Settings.php:92
|
||||
msgid "Batch Size"
|
||||
msgstr "Mida del lot"
|
||||
|
||||
#: includes/Admin/Settings.php:100
|
||||
msgid "Rate Limit (requests/min)"
|
||||
msgstr "Límit de velocitat (sol·licituds/min)"
|
||||
|
||||
#: includes/Admin/Settings.php:156
|
||||
msgid ""
|
||||
"Remove all plugin data (tables and options) when the plugin is uninstalled."
|
||||
msgstr ""
|
||||
"Elimina totes les dades del connector (taules i opcions) quan es "
|
||||
"desinstal·li el connector."
|
||||
|
||||
#: includes/Admin/Settings.php:159
|
||||
msgid ""
|
||||
"By default, data is preserved after uninstall. Enable this only if you want "
|
||||
"a clean removal."
|
||||
msgstr ""
|
||||
"Per defecte, les dades es conserven després de la desinstal·lació. Activeu-"
|
||||
"ho només si voleu una eliminació completa."
|
||||
|
||||
#: includes/Admin/Settings.php:183
|
||||
msgid ""
|
||||
"Required for Google Cloud Vision Web Detection. Obtain from the Google Cloud "
|
||||
"Console."
|
||||
msgstr ""
|
||||
"Necessari per a la detecció web de Google Cloud Vision. Obteniu-lo a Google "
|
||||
"Cloud Console."
|
||||
|
||||
#: includes/Admin/Settings.php:186 includes/Admin/Settings.php:236
|
||||
msgid "View pricing"
|
||||
msgstr "Veure tarifes"
|
||||
|
||||
#: includes/Admin/Settings.php:192
|
||||
msgid "Monthly requests"
|
||||
msgstr "Sol·licituds mensuals"
|
||||
|
||||
#: includes/Admin/Settings.php:193
|
||||
msgid "Price per 1,000"
|
||||
msgstr "Preu per 1.000"
|
||||
|
||||
#: includes/Admin/Settings.php:198
|
||||
msgid "First 1,000"
|
||||
msgstr "Primeres 1.000"
|
||||
|
||||
#: includes/Admin/Settings.php:199
|
||||
msgid "Free"
|
||||
msgstr "Gratuït"
|
||||
|
||||
#: includes/Admin/Settings.php:233
|
||||
msgid ""
|
||||
"Required for TinEye reverse image search. Obtain from your TinEye Commercial "
|
||||
"account."
|
||||
msgstr ""
|
||||
"Necessari per a la cerca inversa d'imatges de TinEye. Obteniu-lo al vostre "
|
||||
"compte comercial de TinEye."
|
||||
|
||||
#: includes/Admin/Settings.php:242
|
||||
msgid "Bundle"
|
||||
msgstr "Pla"
|
||||
|
||||
#: includes/Admin/Settings.php:243
|
||||
msgid "Searches"
|
||||
msgstr "Cerques"
|
||||
|
||||
#: includes/Admin/Settings.php:244
|
||||
msgid "Price"
|
||||
msgstr "Preu"
|
||||
|
||||
#: includes/Admin/Settings.php:249
|
||||
msgid "Starter"
|
||||
msgstr "Inicial"
|
||||
|
||||
#: includes/Admin/Settings.php:254
|
||||
msgid "Standard"
|
||||
msgstr "Estàndard"
|
||||
|
||||
#: includes/Admin/Settings.php:259
|
||||
msgid "Professional"
|
||||
msgstr "Professional"
|
||||
|
||||
#: includes/Admin/Settings.php:288
|
||||
msgid "Number of images to scan per scheduled batch (1–100). Default: 10."
|
||||
msgstr ""
|
||||
"Nombre d'imatges a explorar per lot programat (1–100). Per defecte: 10."
|
||||
|
||||
#: includes/Admin/Settings.php:313
|
||||
msgid "Maximum API requests per minute per provider (1–60). Default: 10."
|
||||
msgstr ""
|
||||
"Nombre màxim de sol·licituds d'API per minut per proveïdor (1–60). Per "
|
||||
"defecte: 10."
|
||||
|
||||
#. translators: %s: DB version number
|
||||
#: includes/CLI/Command.php:49
|
||||
#, php-format
|
||||
msgid "Media Audit tables ready. DB version: %s"
|
||||
msgstr "Taules d'Auditoria de mitjans llestes. Versió de BD: %s"
|
||||
|
||||
#. translators: %s: plugin version
|
||||
#: includes/CLI/Command.php:73
|
||||
#, php-format
|
||||
msgid "Plugin version : %s"
|
||||
msgstr "Versió del connector : %s"
|
||||
|
||||
#. translators: %s: DB version number
|
||||
#: includes/CLI/Command.php:80
|
||||
#, php-format
|
||||
msgid "DB version : %s"
|
||||
msgstr "Versió de BD : %s"
|
||||
|
||||
#: includes/CLI/Command.php:118
|
||||
msgid "Indexing attachments…"
|
||||
msgstr "S'estan indexant els fitxers adjunts…"
|
||||
|
||||
#. translators: 1: newly indexed count, 2: remaining count
|
||||
#: includes/CLI/Command.php:130
|
||||
#, php-format
|
||||
msgid "Indexed %1$d (%2$d remaining)"
|
||||
msgstr "Indexats %1$d (%2$d restants)"
|
||||
|
||||
#. translators: %d: total indexed count
|
||||
#: includes/CLI/Command.php:141
|
||||
#, php-format
|
||||
msgid "Indexing complete. %d attachments in index."
|
||||
msgstr "Indexació completada. %d fitxers adjunts a l'índex."
|
||||
|
||||
#: includes/CLI/Command.php:147
|
||||
msgid "Scanning usage…"
|
||||
msgstr "S'està explorant l'ús…"
|
||||
|
||||
#. translators: 1: scanned count, 2: remaining count
|
||||
#: includes/CLI/Command.php:159 includes/CLI/Command.php:223
|
||||
#, php-format
|
||||
msgid "Scanned %1$d (%2$d remaining)"
|
||||
msgstr "Explorats %1$d (%2$d restants)"
|
||||
|
||||
#. translators: %d: total scanned count
|
||||
#: includes/CLI/Command.php:170
|
||||
#, php-format
|
||||
msgid "Scan complete. %d attachments scanned."
|
||||
msgstr "Exploració completada. %d fitxers adjunts explorats."
|
||||
|
||||
#: includes/CLI/Command.php:208
|
||||
msgid "Running external scans…"
|
||||
msgstr "S'estan executant les exploracions externes…"
|
||||
|
||||
#. translators: %d: total scanned count
|
||||
#: includes/CLI/Command.php:234
|
||||
#, php-format
|
||||
msgid "External scan complete. %d attachments processed."
|
||||
msgstr "Exploració externa completada. %d fitxers adjunts processats."
|
||||
|
||||
#: includes/Core/Plugin.php:112 includes/Core/Plugin.php:113
|
||||
msgid "Settings"
|
||||
msgstr "Configuració"
|
||||
|
||||
#: includes/External/GoogleVisionProvider.php:43
|
||||
msgid "Google Cloud Vision"
|
||||
msgstr "Google Cloud Vision"
|
||||
|
||||
#: includes/Privacy/DataExporter.php:88
|
||||
msgid "Media Audit – Indexed Files"
|
||||
msgstr "Auditoria de mitjans – Fitxers indexats"
|
||||
|
||||
#: includes/Privacy/DataExporter.php:104
|
||||
msgid "Media Audit – External Scan Results"
|
||||
msgstr "Auditoria de mitjans – Resultats d'exploració externa"
|
||||
|
||||
#: includes/Privacy/DataExporter.php:269
|
||||
msgid "External Scan Status"
|
||||
msgstr "Estat de l'exploració externa"
|
||||
|
||||
#: includes/Privacy/DataExporter.php:273
|
||||
msgid "Last Scanned (internal)"
|
||||
msgstr "Última exploració (interna)"
|
||||
|
||||
#: includes/Privacy/DataExporter.php:277
|
||||
msgid "Last Scanned (external)"
|
||||
msgstr "Última exploració (externa)"
|
||||
|
||||
#: includes/Privacy/DataExporter.php:303
|
||||
msgid "Provider"
|
||||
msgstr "Proveïdor"
|
||||
|
||||
#: includes/Privacy/DataExporter.php:307
|
||||
msgid "Match Count"
|
||||
msgstr "Nombre de coincidències"
|
||||
|
||||
#: includes/Privacy/DataExporter.php:311
|
||||
msgid "Scanned At"
|
||||
msgstr "Explorat el"
|
||||
BIN
languages/robotstxt-mediaaudit-es_ES.mo
Normal file
BIN
languages/robotstxt-mediaaudit-es_ES.mo
Normal file
Binary file not shown.
536
languages/robotstxt-mediaaudit-es_ES.po
Normal file
536
languages/robotstxt-mediaaudit-es_ES.po
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
# Copyright (C) 2026 Javier Casares
|
||||
# This file is distributed under the GPL-3.0-or-later.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Media Audit (by ROBOTSTXT) 1.0.0\n"
|
||||
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/robotstxt-"
|
||||
"mediaaudit\n"
|
||||
"POT-Creation-Date: 2026-05-02T06:06:40+00:00\n"
|
||||
"PO-Revision-Date: 2026-05-01 07:41+0000\n"
|
||||
"Last-Translator: Javier Casares <javier@casares.org>\n"
|
||||
"Language-Team: Spanish (Spain) <es@li.org>\n"
|
||||
"Language: es_ES\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: WP-CLI 2.12.0\n"
|
||||
"X-Domain: robotstxt-mediaaudit\n"
|
||||
|
||||
#. Plugin Name of the plugin
|
||||
#: robotstxt-mediaaudit.php
|
||||
msgid "Media Audit (by ROBOTSTXT)"
|
||||
msgstr "Auditoría de medios (by ROBOTSTXT)"
|
||||
|
||||
#. Plugin URI of the plugin
|
||||
#: robotstxt-mediaaudit.php
|
||||
msgid "https://robotstxt.es/plugins/media-audit/"
|
||||
msgstr "https://robotstxt.es/plugins/media-audit/"
|
||||
|
||||
#. Description of the plugin
|
||||
#: robotstxt-mediaaudit.php
|
||||
msgid ""
|
||||
"Internal media library usage auditing and external reverse image search to "
|
||||
"detect potential copyright issues."
|
||||
msgstr ""
|
||||
"Auditoría del uso de la biblioteca multimedia interna y búsqueda inversa de "
|
||||
"imágenes externa para detectar posibles problemas de derechos de autor."
|
||||
|
||||
#. Author of the plugin
|
||||
#: robotstxt-mediaaudit.php
|
||||
msgid "Javier Casares"
|
||||
msgstr "Javier Casares"
|
||||
|
||||
#. Author URI of the plugin
|
||||
#: robotstxt-mediaaudit.php
|
||||
msgid "https://javiercasares.com"
|
||||
msgstr "https://javiercasares.com"
|
||||
|
||||
#: includes/Admin/AuditPage.php:112
|
||||
msgid "Loading…"
|
||||
msgstr "Cargando…"
|
||||
|
||||
#: includes/Admin/AuditPage.php:113
|
||||
msgid "Error loading details."
|
||||
msgstr "Error al cargar los detalles."
|
||||
|
||||
#: includes/Admin/AuditPage.php:114 includes/Admin/AuditPage.php:190
|
||||
msgid "This image is not used in any post."
|
||||
msgstr "Esta imagen no se usa en ninguna entrada."
|
||||
|
||||
#: includes/Admin/AuditPage.php:115 includes/Admin/AuditPage.php:152
|
||||
msgid "Usage Details"
|
||||
msgstr "Detalles de uso"
|
||||
|
||||
#: includes/Admin/AuditPage.php:131 includes/Admin/Settings.php:325
|
||||
msgid "You do not have permission to access this page."
|
||||
msgstr "No tienes permiso para acceder a esta página."
|
||||
|
||||
#: includes/Admin/AuditPage.php:138 includes/Core/Plugin.php:89
|
||||
#: includes/Core/Plugin.php:90 includes/Core/Plugin.php:103
|
||||
#: includes/Core/Plugin.php:104 includes/Privacy/DataEraser.php:33
|
||||
#: includes/Privacy/DataExporter.php:45
|
||||
msgid "Media Audit"
|
||||
msgstr "Auditoría de medios"
|
||||
|
||||
#: includes/Admin/AuditPage.php:144
|
||||
msgid "Search"
|
||||
msgstr "Buscar"
|
||||
|
||||
#: includes/Admin/AuditPage.php:153
|
||||
msgid "Close"
|
||||
msgstr "Cerrar"
|
||||
|
||||
#: includes/Admin/AuditPage.php:171 includes/Admin/AuditPage.php:459
|
||||
#: includes/Admin/AuditPage.php:510 includes/Admin/AuditPage.php:542
|
||||
msgid "Insufficient permissions."
|
||||
msgstr "Permisos insuficientes."
|
||||
|
||||
#: includes/Admin/AuditPage.php:176
|
||||
msgid "Invalid attachment ID."
|
||||
msgstr "ID de adjunto no válido."
|
||||
|
||||
#: includes/Admin/AuditPage.php:184
|
||||
msgid "Featured Image"
|
||||
msgstr "Imagen destacada"
|
||||
|
||||
#: includes/Admin/AuditPage.php:185
|
||||
msgid "Post Content"
|
||||
msgstr "Contenido de la entrada"
|
||||
|
||||
#: includes/Admin/AuditPage.php:186
|
||||
msgid "Custom Field"
|
||||
msgstr "Campo personalizado"
|
||||
|
||||
#: includes/Admin/AuditPage.php:194
|
||||
msgid "Post"
|
||||
msgstr "Entrada"
|
||||
|
||||
#: includes/Admin/AuditPage.php:195
|
||||
msgid "Type"
|
||||
msgstr "Tipo"
|
||||
|
||||
#: includes/Admin/AuditPage.php:196
|
||||
msgid "Context"
|
||||
msgstr "Contexto"
|
||||
|
||||
#: includes/Admin/AuditPage.php:197
|
||||
msgid "Status"
|
||||
msgstr "Estado"
|
||||
|
||||
#: includes/Admin/AuditPage.php:240
|
||||
msgid "Internal Usage"
|
||||
msgstr "Uso interno"
|
||||
|
||||
#: includes/Admin/AuditPage.php:288
|
||||
msgid "External Scan Results"
|
||||
msgstr "Resultados del escaneo externo"
|
||||
|
||||
#. translators: %d: number of external matches
|
||||
#: includes/Admin/AuditPage.php:312
|
||||
#, php-format
|
||||
msgid "%d match"
|
||||
msgid_plural "%d matches"
|
||||
msgstr[0] "%d coincidencia"
|
||||
msgstr[1] "%d coincidencias"
|
||||
|
||||
#: includes/Admin/AuditPage.php:317
|
||||
msgid "No matches"
|
||||
msgstr "Sin coincidencias"
|
||||
|
||||
#: includes/Admin/AuditPage.php:328
|
||||
msgid "Domain"
|
||||
msgstr "Dominio"
|
||||
|
||||
#: includes/Admin/AuditPage.php:329
|
||||
msgid "Occurrences"
|
||||
msgstr "Apariciones"
|
||||
|
||||
#: includes/Admin/AuditPage.php:350
|
||||
msgid "Consensus Domains"
|
||||
msgstr "Dominios de consenso"
|
||||
|
||||
#. translators: %d: number of providers that agree
|
||||
#: includes/Admin/AuditPage.php:356
|
||||
#, php-format
|
||||
msgid "%d provider"
|
||||
msgid_plural "%d providers"
|
||||
msgstr[0] "%d proveedor"
|
||||
msgstr[1] "%d proveedores"
|
||||
|
||||
#: includes/Admin/AuditPage.php:388
|
||||
msgid "In Index"
|
||||
msgstr "En el índice"
|
||||
|
||||
#. translators: %d: count of images not yet indexed
|
||||
#: includes/Admin/AuditPage.php:392
|
||||
#, php-format
|
||||
msgid "%d not yet indexed"
|
||||
msgstr "%d aún no indexadas"
|
||||
|
||||
#: includes/Admin/AuditPage.php:395
|
||||
msgid "All indexed"
|
||||
msgstr "Todas indexadas"
|
||||
|
||||
#: includes/Admin/AuditPage.php:400 includes/Admin/MediaListTable.php:275
|
||||
msgid "Scanned"
|
||||
msgstr "Escaneadas"
|
||||
|
||||
#. translators: %d: percentage
|
||||
#: includes/Admin/AuditPage.php:403
|
||||
#, php-format
|
||||
msgid "%d%% of index"
|
||||
msgstr "%d%% del índice"
|
||||
|
||||
#: includes/Admin/AuditPage.php:410
|
||||
msgid "Used"
|
||||
msgstr "Usadas"
|
||||
|
||||
#: includes/Admin/AuditPage.php:416 includes/Admin/MediaListTable.php:200
|
||||
msgid "Unused"
|
||||
msgstr "Sin usar"
|
||||
|
||||
#: includes/Admin/AuditPage.php:423
|
||||
msgid "External Matches"
|
||||
msgstr "Coincidencias externas"
|
||||
|
||||
#: includes/Admin/AuditPage.php:473
|
||||
msgid "Could not open output stream."
|
||||
msgstr "No se pudo abrir el flujo de salida."
|
||||
|
||||
#: includes/Admin/AuditPage.php:482 includes/Privacy/DataExporter.php:257
|
||||
#: includes/Privacy/DataExporter.php:299
|
||||
msgid "Attachment ID"
|
||||
msgstr "ID del adjunto"
|
||||
|
||||
#: includes/Admin/AuditPage.php:483 includes/Admin/MediaListTable.php:52
|
||||
#: includes/Privacy/DataExporter.php:261
|
||||
msgid "Filename"
|
||||
msgstr "Nombre de archivo"
|
||||
|
||||
#: includes/Admin/AuditPage.php:484 includes/Privacy/DataExporter.php:265
|
||||
msgid "File URL"
|
||||
msgstr "URL del archivo"
|
||||
|
||||
#: includes/Admin/AuditPage.php:485
|
||||
msgid "MIME Type"
|
||||
msgstr "Tipo MIME"
|
||||
|
||||
#: includes/Admin/AuditPage.php:486
|
||||
msgid "File Size (bytes)"
|
||||
msgstr "Tamaño del archivo (bytes)"
|
||||
|
||||
#: includes/Admin/AuditPage.php:487 includes/Admin/MediaListTable.php:56
|
||||
msgid "External Status"
|
||||
msgstr "Estado externo"
|
||||
|
||||
#: includes/Admin/AuditPage.php:488 includes/Admin/MediaListTable.php:54
|
||||
msgid "Usage Count"
|
||||
msgstr "Número de usos"
|
||||
|
||||
#: includes/Admin/AuditPage.php:489
|
||||
msgid "Used In (post titles)"
|
||||
msgstr "Usado en (títulos de entradas)"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:51
|
||||
msgid "Thumbnail"
|
||||
msgstr "Miniatura"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:53
|
||||
msgid "ID"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:55
|
||||
msgid "Usages"
|
||||
msgstr "Usos"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:79
|
||||
msgid "Run External Scan"
|
||||
msgstr "Ejecutar escaneo externo"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:80
|
||||
msgid "Purge External Data"
|
||||
msgstr "Purgar datos externos"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:81
|
||||
msgid "Export CSV"
|
||||
msgstr "Exportar CSV"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:165
|
||||
msgid "Edit"
|
||||
msgstr "Editar"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:170
|
||||
msgid "View Details"
|
||||
msgstr "Ver detalles"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:236
|
||||
msgid "(no title)"
|
||||
msgstr "(sin título)"
|
||||
|
||||
#. translators: %d: number of additional usages
|
||||
#: includes/Admin/MediaListTable.php:251
|
||||
#, php-format
|
||||
msgid "+ %d more…"
|
||||
msgstr "+ %d más…"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:273
|
||||
msgid "Pending"
|
||||
msgstr "Pendiente"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:274
|
||||
msgid "Queued"
|
||||
msgstr "En cola"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:276
|
||||
msgid "Matches Found"
|
||||
msgstr "Coincidencias encontradas"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:277
|
||||
msgid "Error"
|
||||
msgstr "Error"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:305
|
||||
msgid "View Results"
|
||||
msgstr "Ver resultados"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:336
|
||||
msgid "All statuses"
|
||||
msgstr "Todos los estados"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:350
|
||||
msgid "All post types"
|
||||
msgstr "Todos los tipos de entrada"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:365
|
||||
msgid "Unused only"
|
||||
msgstr "Solo sin usar"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:368
|
||||
msgid "Filter"
|
||||
msgstr "Filtrar"
|
||||
|
||||
#: includes/Admin/MediaListTable.php:378
|
||||
msgid ""
|
||||
"No indexed attachments found. Run wp mra scan-internal to populate the index."
|
||||
msgstr ""
|
||||
"No se encontraron adjuntos indexados. Ejecuta wp mra scan-internal para "
|
||||
"poblar el índice."
|
||||
|
||||
#: includes/Admin/Settings.php:47
|
||||
msgid "General"
|
||||
msgstr "General"
|
||||
|
||||
#: includes/Admin/Settings.php:54
|
||||
msgid "Delete data on uninstall"
|
||||
msgstr "Eliminar datos al desinstalar"
|
||||
|
||||
#: includes/Admin/Settings.php:62
|
||||
msgid "API Credentials"
|
||||
msgstr "Credenciales de API"
|
||||
|
||||
#: includes/Admin/Settings.php:69
|
||||
msgid "Google Cloud Vision API Key"
|
||||
msgstr "Clave de API de Google Cloud Vision"
|
||||
|
||||
#: includes/Admin/Settings.php:77
|
||||
msgid "TinEye API Key"
|
||||
msgstr "Clave de API de TinEye"
|
||||
|
||||
#: includes/Admin/Settings.php:85
|
||||
msgid "External Scanning"
|
||||
msgstr "Escaneo externo"
|
||||
|
||||
#: includes/Admin/Settings.php:92
|
||||
msgid "Batch Size"
|
||||
msgstr "Tamaño de lote"
|
||||
|
||||
#: includes/Admin/Settings.php:100
|
||||
msgid "Rate Limit (requests/min)"
|
||||
msgstr "Límite de velocidad (solicitudes/min)"
|
||||
|
||||
#: includes/Admin/Settings.php:156
|
||||
msgid ""
|
||||
"Remove all plugin data (tables and options) when the plugin is uninstalled."
|
||||
msgstr ""
|
||||
"Elimina todos los datos del plugin (tablas y opciones) cuando se desinstale "
|
||||
"el plugin."
|
||||
|
||||
#: includes/Admin/Settings.php:159
|
||||
msgid ""
|
||||
"By default, data is preserved after uninstall. Enable this only if you want "
|
||||
"a clean removal."
|
||||
msgstr ""
|
||||
"Por defecto, los datos se conservan tras la desinstalación. Activa esto solo "
|
||||
"si deseas una eliminación completa."
|
||||
|
||||
#: includes/Admin/Settings.php:183
|
||||
msgid ""
|
||||
"Required for Google Cloud Vision Web Detection. Obtain from the Google Cloud "
|
||||
"Console."
|
||||
msgstr ""
|
||||
"Necesario para la detección web de Google Cloud Vision. Obtener desde Google "
|
||||
"Cloud Console."
|
||||
|
||||
#: includes/Admin/Settings.php:186 includes/Admin/Settings.php:236
|
||||
msgid "View pricing"
|
||||
msgstr "Ver tarifas"
|
||||
|
||||
#: includes/Admin/Settings.php:192
|
||||
msgid "Monthly requests"
|
||||
msgstr "Solicitudes mensuales"
|
||||
|
||||
#: includes/Admin/Settings.php:193
|
||||
msgid "Price per 1,000"
|
||||
msgstr "Precio por 1.000"
|
||||
|
||||
#: includes/Admin/Settings.php:198
|
||||
msgid "First 1,000"
|
||||
msgstr "Primeras 1.000"
|
||||
|
||||
#: includes/Admin/Settings.php:199
|
||||
msgid "Free"
|
||||
msgstr "Gratis"
|
||||
|
||||
#: includes/Admin/Settings.php:233
|
||||
msgid ""
|
||||
"Required for TinEye reverse image search. Obtain from your TinEye Commercial "
|
||||
"account."
|
||||
msgstr ""
|
||||
"Necesario para la búsqueda inversa de imágenes de TinEye. Obtener desde tu "
|
||||
"cuenta comercial de TinEye."
|
||||
|
||||
#: includes/Admin/Settings.php:242
|
||||
msgid "Bundle"
|
||||
msgstr "Plan"
|
||||
|
||||
#: includes/Admin/Settings.php:243
|
||||
msgid "Searches"
|
||||
msgstr "Búsquedas"
|
||||
|
||||
#: includes/Admin/Settings.php:244
|
||||
msgid "Price"
|
||||
msgstr "Precio"
|
||||
|
||||
#: includes/Admin/Settings.php:249
|
||||
msgid "Starter"
|
||||
msgstr "Inicial"
|
||||
|
||||
#: includes/Admin/Settings.php:254
|
||||
msgid "Standard"
|
||||
msgstr "Estándar"
|
||||
|
||||
#: includes/Admin/Settings.php:259
|
||||
msgid "Professional"
|
||||
msgstr "Profesional"
|
||||
|
||||
#: includes/Admin/Settings.php:288
|
||||
msgid "Number of images to scan per scheduled batch (1–100). Default: 10."
|
||||
msgstr ""
|
||||
"Número de imágenes a escanear por lote programado (1–100). Predeterminado: "
|
||||
"10."
|
||||
|
||||
#: includes/Admin/Settings.php:313
|
||||
msgid "Maximum API requests per minute per provider (1–60). Default: 10."
|
||||
msgstr ""
|
||||
"Máximo de solicitudes de API por minuto por proveedor (1–60). "
|
||||
"Predeterminado: 10."
|
||||
|
||||
#. translators: %s: DB version number
|
||||
#: includes/CLI/Command.php:49
|
||||
#, php-format
|
||||
msgid "Media Audit tables ready. DB version: %s"
|
||||
msgstr "Tablas de Auditoría de medios listas. Versión de BD: %s"
|
||||
|
||||
#. translators: %s: plugin version
|
||||
#: includes/CLI/Command.php:73
|
||||
#, php-format
|
||||
msgid "Plugin version : %s"
|
||||
msgstr "Versión del plugin : %s"
|
||||
|
||||
#. translators: %s: DB version number
|
||||
#: includes/CLI/Command.php:80
|
||||
#, php-format
|
||||
msgid "DB version : %s"
|
||||
msgstr "Versión de BD : %s"
|
||||
|
||||
#: includes/CLI/Command.php:118
|
||||
msgid "Indexing attachments…"
|
||||
msgstr "Indexando adjuntos…"
|
||||
|
||||
#. translators: 1: newly indexed count, 2: remaining count
|
||||
#: includes/CLI/Command.php:130
|
||||
#, php-format
|
||||
msgid "Indexed %1$d (%2$d remaining)"
|
||||
msgstr "Indexados %1$d (%2$d restantes)"
|
||||
|
||||
#. translators: %d: total indexed count
|
||||
#: includes/CLI/Command.php:141
|
||||
#, php-format
|
||||
msgid "Indexing complete. %d attachments in index."
|
||||
msgstr "Indexación completa. %d adjuntos en el índice."
|
||||
|
||||
#: includes/CLI/Command.php:147
|
||||
msgid "Scanning usage…"
|
||||
msgstr "Escaneando uso…"
|
||||
|
||||
#. translators: 1: scanned count, 2: remaining count
|
||||
#: includes/CLI/Command.php:159 includes/CLI/Command.php:223
|
||||
#, php-format
|
||||
msgid "Scanned %1$d (%2$d remaining)"
|
||||
msgstr "Escaneados %1$d (%2$d restantes)"
|
||||
|
||||
#. translators: %d: total scanned count
|
||||
#: includes/CLI/Command.php:170
|
||||
#, php-format
|
||||
msgid "Scan complete. %d attachments scanned."
|
||||
msgstr "Escaneo completo. %d adjuntos escaneados."
|
||||
|
||||
#: includes/CLI/Command.php:208
|
||||
msgid "Running external scans…"
|
||||
msgstr "Ejecutando escaneos externos…"
|
||||
|
||||
#. translators: %d: total scanned count
|
||||
#: includes/CLI/Command.php:234
|
||||
#, php-format
|
||||
msgid "External scan complete. %d attachments processed."
|
||||
msgstr "Escaneo externo completo. %d adjuntos procesados."
|
||||
|
||||
#: includes/Core/Plugin.php:112 includes/Core/Plugin.php:113
|
||||
msgid "Settings"
|
||||
msgstr "Ajustes"
|
||||
|
||||
#: includes/External/GoogleVisionProvider.php:43
|
||||
msgid "Google Cloud Vision"
|
||||
msgstr "Google Cloud Vision"
|
||||
|
||||
#: includes/Privacy/DataExporter.php:88
|
||||
msgid "Media Audit – Indexed Files"
|
||||
msgstr "Auditoría de medios – Archivos indexados"
|
||||
|
||||
#: includes/Privacy/DataExporter.php:104
|
||||
msgid "Media Audit – External Scan Results"
|
||||
msgstr "Auditoría de medios – Resultados de escaneo externo"
|
||||
|
||||
#: includes/Privacy/DataExporter.php:269
|
||||
msgid "External Scan Status"
|
||||
msgstr "Estado del escaneo externo"
|
||||
|
||||
#: includes/Privacy/DataExporter.php:273
|
||||
msgid "Last Scanned (internal)"
|
||||
msgstr "Último escaneo (interno)"
|
||||
|
||||
#: includes/Privacy/DataExporter.php:277
|
||||
msgid "Last Scanned (external)"
|
||||
msgstr "Último escaneo (externo)"
|
||||
|
||||
#: includes/Privacy/DataExporter.php:303
|
||||
msgid "Provider"
|
||||
msgstr "Proveedor"
|
||||
|
||||
#: includes/Privacy/DataExporter.php:307
|
||||
msgid "Match Count"
|
||||
msgstr "Número de coincidencias"
|
||||
|
||||
#: includes/Privacy/DataExporter.php:311
|
||||
msgid "Scanned At"
|
||||
msgstr "Escaneado el"
|
||||
526
languages/robotstxt-mediaaudit.pot
Normal file
526
languages/robotstxt-mediaaudit.pot
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
# Copyright (C) 2026 Javier Casares
|
||||
# This file is distributed under the GPL-3.0-or-later.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Media Audit (by ROBOTSTXT) 1.0.0\n"
|
||||
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/robotstxt-mediaaudit\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"POT-Creation-Date: 2026-05-02T06:06:40+00:00\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"X-Generator: WP-CLI 2.12.0\n"
|
||||
"X-Domain: robotstxt-mediaaudit\n"
|
||||
|
||||
#. Plugin Name of the plugin
|
||||
#: robotstxt-mediaaudit.php
|
||||
msgid "Media Audit (by ROBOTSTXT)"
|
||||
msgstr ""
|
||||
|
||||
#. Plugin URI of the plugin
|
||||
#: robotstxt-mediaaudit.php
|
||||
msgid "https://robotstxt.es/plugins/media-audit/"
|
||||
msgstr ""
|
||||
|
||||
#. Description of the plugin
|
||||
#: robotstxt-mediaaudit.php
|
||||
msgid "Internal media library usage auditing and external reverse image search to detect potential copyright issues."
|
||||
msgstr ""
|
||||
|
||||
#. Author of the plugin
|
||||
#: robotstxt-mediaaudit.php
|
||||
msgid "Javier Casares"
|
||||
msgstr ""
|
||||
|
||||
#. Author URI of the plugin
|
||||
#: robotstxt-mediaaudit.php
|
||||
msgid "https://javiercasares.com"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:112
|
||||
msgid "Loading…"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:113
|
||||
msgid "Error loading details."
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:114
|
||||
#: includes/Admin/AuditPage.php:190
|
||||
msgid "This image is not used in any post."
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:115
|
||||
#: includes/Admin/AuditPage.php:152
|
||||
msgid "Usage Details"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:131
|
||||
#: includes/Admin/Settings.php:325
|
||||
msgid "You do not have permission to access this page."
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:138
|
||||
#: includes/Core/Plugin.php:89
|
||||
#: includes/Core/Plugin.php:90
|
||||
#: includes/Core/Plugin.php:103
|
||||
#: includes/Core/Plugin.php:104
|
||||
#: includes/Privacy/DataEraser.php:33
|
||||
#: includes/Privacy/DataExporter.php:45
|
||||
msgid "Media Audit"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:144
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:153
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:171
|
||||
#: includes/Admin/AuditPage.php:459
|
||||
#: includes/Admin/AuditPage.php:510
|
||||
#: includes/Admin/AuditPage.php:542
|
||||
msgid "Insufficient permissions."
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:176
|
||||
msgid "Invalid attachment ID."
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:184
|
||||
msgid "Featured Image"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:185
|
||||
msgid "Post Content"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:186
|
||||
msgid "Custom Field"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:194
|
||||
msgid "Post"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:195
|
||||
msgid "Type"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:196
|
||||
msgid "Context"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:197
|
||||
msgid "Status"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:240
|
||||
msgid "Internal Usage"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:288
|
||||
msgid "External Scan Results"
|
||||
msgstr ""
|
||||
|
||||
#. translators: %d: number of external matches
|
||||
#: includes/Admin/AuditPage.php:312
|
||||
#, php-format
|
||||
msgid "%d match"
|
||||
msgid_plural "%d matches"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:317
|
||||
msgid "No matches"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:328
|
||||
msgid "Domain"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:329
|
||||
msgid "Occurrences"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:350
|
||||
msgid "Consensus Domains"
|
||||
msgstr ""
|
||||
|
||||
#. translators: %d: number of providers that agree
|
||||
#: includes/Admin/AuditPage.php:356
|
||||
#, php-format
|
||||
msgid "%d provider"
|
||||
msgid_plural "%d providers"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:388
|
||||
msgid "In Index"
|
||||
msgstr ""
|
||||
|
||||
#. translators: %d: count of images not yet indexed
|
||||
#: includes/Admin/AuditPage.php:392
|
||||
#, php-format
|
||||
msgid "%d not yet indexed"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:395
|
||||
msgid "All indexed"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:400
|
||||
#: includes/Admin/MediaListTable.php:275
|
||||
msgid "Scanned"
|
||||
msgstr ""
|
||||
|
||||
#. translators: %d: percentage
|
||||
#: includes/Admin/AuditPage.php:403
|
||||
#, php-format
|
||||
msgid "%d%% of index"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:410
|
||||
msgid "Used"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:416
|
||||
#: includes/Admin/MediaListTable.php:200
|
||||
msgid "Unused"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:423
|
||||
msgid "External Matches"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:473
|
||||
msgid "Could not open output stream."
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:482
|
||||
#: includes/Privacy/DataExporter.php:257
|
||||
#: includes/Privacy/DataExporter.php:299
|
||||
msgid "Attachment ID"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:483
|
||||
#: includes/Admin/MediaListTable.php:52
|
||||
#: includes/Privacy/DataExporter.php:261
|
||||
msgid "Filename"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:484
|
||||
#: includes/Privacy/DataExporter.php:265
|
||||
msgid "File URL"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:485
|
||||
msgid "MIME Type"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:486
|
||||
msgid "File Size (bytes)"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:487
|
||||
#: includes/Admin/MediaListTable.php:56
|
||||
msgid "External Status"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:488
|
||||
#: includes/Admin/MediaListTable.php:54
|
||||
msgid "Usage Count"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/AuditPage.php:489
|
||||
msgid "Used In (post titles)"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:51
|
||||
msgid "Thumbnail"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:53
|
||||
msgid "ID"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:55
|
||||
msgid "Usages"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:79
|
||||
msgid "Run External Scan"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:80
|
||||
msgid "Purge External Data"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:81
|
||||
msgid "Export CSV"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:165
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:170
|
||||
msgid "View Details"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:236
|
||||
msgid "(no title)"
|
||||
msgstr ""
|
||||
|
||||
#. translators: %d: number of additional usages
|
||||
#: includes/Admin/MediaListTable.php:251
|
||||
#, php-format
|
||||
msgid "+ %d more…"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:273
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:274
|
||||
msgid "Queued"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:276
|
||||
msgid "Matches Found"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:277
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:305
|
||||
msgid "View Results"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:336
|
||||
msgid "All statuses"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:350
|
||||
msgid "All post types"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:365
|
||||
msgid "Unused only"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:368
|
||||
msgid "Filter"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/MediaListTable.php:378
|
||||
msgid "No indexed attachments found. Run wp mra scan-internal to populate the index."
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:47
|
||||
msgid "General"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:54
|
||||
msgid "Delete data on uninstall"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:62
|
||||
msgid "API Credentials"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:69
|
||||
msgid "Google Cloud Vision API Key"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:77
|
||||
msgid "TinEye API Key"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:85
|
||||
msgid "External Scanning"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:92
|
||||
msgid "Batch Size"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:100
|
||||
msgid "Rate Limit (requests/min)"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:156
|
||||
msgid "Remove all plugin data (tables and options) when the plugin is uninstalled."
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:159
|
||||
msgid "By default, data is preserved after uninstall. Enable this only if you want a clean removal."
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:183
|
||||
msgid "Required for Google Cloud Vision Web Detection. Obtain from the Google Cloud Console."
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:186
|
||||
#: includes/Admin/Settings.php:236
|
||||
msgid "View pricing"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:192
|
||||
msgid "Monthly requests"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:193
|
||||
msgid "Price per 1,000"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:198
|
||||
msgid "First 1,000"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:199
|
||||
msgid "Free"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:233
|
||||
msgid "Required for TinEye reverse image search. Obtain from your TinEye Commercial account."
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:242
|
||||
msgid "Bundle"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:243
|
||||
msgid "Searches"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:244
|
||||
msgid "Price"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:249
|
||||
msgid "Starter"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:254
|
||||
msgid "Standard"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:259
|
||||
msgid "Professional"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:288
|
||||
msgid "Number of images to scan per scheduled batch (1–100). Default: 10."
|
||||
msgstr ""
|
||||
|
||||
#: includes/Admin/Settings.php:313
|
||||
msgid "Maximum API requests per minute per provider (1–60). Default: 10."
|
||||
msgstr ""
|
||||
|
||||
#. translators: %s: DB version number
|
||||
#: includes/CLI/Command.php:49
|
||||
#, php-format
|
||||
msgid "Media Audit tables ready. DB version: %s"
|
||||
msgstr ""
|
||||
|
||||
#. translators: %s: plugin version
|
||||
#: includes/CLI/Command.php:73
|
||||
#, php-format
|
||||
msgid "Plugin version : %s"
|
||||
msgstr ""
|
||||
|
||||
#. translators: %s: DB version number
|
||||
#: includes/CLI/Command.php:80
|
||||
#, php-format
|
||||
msgid "DB version : %s"
|
||||
msgstr ""
|
||||
|
||||
#: includes/CLI/Command.php:118
|
||||
msgid "Indexing attachments…"
|
||||
msgstr ""
|
||||
|
||||
#. translators: 1: newly indexed count, 2: remaining count
|
||||
#: includes/CLI/Command.php:130
|
||||
#, php-format
|
||||
msgid "Indexed %1$d (%2$d remaining)"
|
||||
msgstr ""
|
||||
|
||||
#. translators: %d: total indexed count
|
||||
#: includes/CLI/Command.php:141
|
||||
#, php-format
|
||||
msgid "Indexing complete. %d attachments in index."
|
||||
msgstr ""
|
||||
|
||||
#: includes/CLI/Command.php:147
|
||||
msgid "Scanning usage…"
|
||||
msgstr ""
|
||||
|
||||
#. translators: 1: scanned count, 2: remaining count
|
||||
#: includes/CLI/Command.php:159
|
||||
#: includes/CLI/Command.php:223
|
||||
#, php-format
|
||||
msgid "Scanned %1$d (%2$d remaining)"
|
||||
msgstr ""
|
||||
|
||||
#. translators: %d: total scanned count
|
||||
#: includes/CLI/Command.php:170
|
||||
#, php-format
|
||||
msgid "Scan complete. %d attachments scanned."
|
||||
msgstr ""
|
||||
|
||||
#: includes/CLI/Command.php:208
|
||||
msgid "Running external scans…"
|
||||
msgstr ""
|
||||
|
||||
#. translators: %d: total scanned count
|
||||
#: includes/CLI/Command.php:234
|
||||
#, php-format
|
||||
msgid "External scan complete. %d attachments processed."
|
||||
msgstr ""
|
||||
|
||||
#: includes/Core/Plugin.php:112
|
||||
#: includes/Core/Plugin.php:113
|
||||
msgid "Settings"
|
||||
msgstr ""
|
||||
|
||||
#: includes/External/GoogleVisionProvider.php:43
|
||||
msgid "Google Cloud Vision"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Privacy/DataExporter.php:88
|
||||
msgid "Media Audit – Indexed Files"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Privacy/DataExporter.php:104
|
||||
msgid "Media Audit – External Scan Results"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Privacy/DataExporter.php:269
|
||||
msgid "External Scan Status"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Privacy/DataExporter.php:273
|
||||
msgid "Last Scanned (internal)"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Privacy/DataExporter.php:277
|
||||
msgid "Last Scanned (external)"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Privacy/DataExporter.php:303
|
||||
msgid "Provider"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Privacy/DataExporter.php:307
|
||||
msgid "Match Count"
|
||||
msgstr ""
|
||||
|
||||
#: includes/Privacy/DataExporter.php:311
|
||||
msgid "Scanned At"
|
||||
msgstr ""
|
||||
BIN
messages.mo
Normal file
BIN
messages.mo
Normal file
Binary file not shown.
117
readme.txt
Normal file
117
readme.txt
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
=== Media Audit (by ROBOTSTXT) ===
|
||||
Contributors: javiercasares
|
||||
Tags: media, copyright, images, reverse image search, media library
|
||||
Requires at least: 6.8
|
||||
Tested up to: 7.0
|
||||
Requires PHP: 8.2
|
||||
Requires Plugins: action-scheduler
|
||||
Stable tag: 1.0.0
|
||||
License: GPL-3.0-or-later
|
||||
License URI: https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
|
||||
Audit your media library for copyright risks: track internal usage and run reverse image search via Google Vision and TinEye.
|
||||
|
||||
== Description ==
|
||||
|
||||
Media Audit helps site owners and editors understand where their media files are used and whether those files appear elsewhere on the internet — a key signal for detecting potential copyright violations.
|
||||
|
||||
= Features =
|
||||
|
||||
* **Internal usage scan** — identifies every post, page, and custom post type that references each attachment as featured image, inline content, or post meta.
|
||||
* **External reverse image search** — submits images to Google Cloud Vision (Web Detection) and TinEye Commercial API to find matching pages across the web.
|
||||
* **Consensus detection** — surfaces domains independently confirmed by multiple providers, providing stronger copyright-risk signals.
|
||||
* **Audit list** — filterable admin page showing each attachment's external scan status and top matching domains.
|
||||
* **WP-CLI support** — run or schedule scans from the command line.
|
||||
* **GDPR compliance** — full WordPress privacy API integration for personal data export and erasure on request.
|
||||
* **Async processing** — all scanning runs via Action Scheduler to avoid blocking web requests.
|
||||
|
||||
= Requirements =
|
||||
|
||||
* [Action Scheduler](https://actionscheduler.org/) plugin (declared as a required dependency).
|
||||
* Google Cloud Vision API key (for Web Detection scanning).
|
||||
* TinEye Commercial API key (for reverse image search).
|
||||
|
||||
== Using the plugin ==
|
||||
|
||||
= WP-CLI =
|
||||
|
||||
Install tables manually (normally handled on activation):
|
||||
|
||||
wp mra install
|
||||
|
||||
Show current index and scan status:
|
||||
|
||||
wp mra status
|
||||
|
||||
Run an internal usage scan (queues a batch):
|
||||
|
||||
wp mra scan-internal [--batch=<n>]
|
||||
|
||||
Run an external reverse image search (queues a batch):
|
||||
|
||||
wp mra scan-external [--batch=<n>]
|
||||
|
||||
= Extension hooks =
|
||||
|
||||
* `mra/internal/scanner/post_types` — filter the post types included in the internal usage scan.
|
||||
* `mra/internal/scanner/meta_keys` — filter the meta keys inspected during the internal scan.
|
||||
* `mra/external/providers` — register additional external scan providers.
|
||||
|
||||
== Installation ==
|
||||
|
||||
= Automatic download =
|
||||
|
||||
Visit the plugin section in your WordPress admin, search for "Media Audit"; download and install the plugin.
|
||||
|
||||
= Manual download =
|
||||
|
||||
Extract the contents of the ZIP and upload the folder to `/wp-content/plugins/robotstxt-mediaaudit/`. Once uploaded it will appear in your plugin list.
|
||||
|
||||
After activation, visit **Media Audit → Settings** to enter your API credentials.
|
||||
|
||||
== Frequently Asked Questions ==
|
||||
|
||||
= Do I need both Google Vision and TinEye keys? =
|
||||
|
||||
No. The plugin works with either provider configured. Results will only be collected from providers with a valid API key.
|
||||
|
||||
= Will scanning slow down my site? =
|
||||
|
||||
No. All scans are queued and processed asynchronously by Action Scheduler in small batches.
|
||||
|
||||
= What is consensus detection? =
|
||||
|
||||
When the same domain appears independently in both Google Vision and TinEye results for the same image, it is flagged as a consensus match — a stronger copyright-risk signal than a single-provider finding.
|
||||
|
||||
= Is data deleted when I uninstall? =
|
||||
|
||||
By default, no. Enable **Settings → Delete data on uninstall** if you want all plugin tables and options removed on uninstall.
|
||||
|
||||
== Compatibility ==
|
||||
|
||||
* WordPress: 6.8 - 7.0
|
||||
* PHP: 8.2 - 8.4
|
||||
* WP-CLI: 2.x
|
||||
* Requires Plugins: action-scheduler
|
||||
|
||||
== Changelog ==
|
||||
|
||||
Only the 3 latest versions. The full changelog is in [changelog.txt](changelog.txt).
|
||||
|
||||
= 1.0.0 =
|
||||
|
||||
* First stable release. Internal usage scanning, external reverse image search (Google Vision + TinEye), consensus detection, WP-CLI commands, GDPR privacy tools, and Spanish/Catalan translations.
|
||||
|
||||
= Previous versions =
|
||||
|
||||
See [changelog.txt](changelog.txt) for the full history.
|
||||
|
||||
== Compliance ==
|
||||
|
||||
This plugin adheres to the following security measures and review protocols for each version:
|
||||
|
||||
* [WordPress Plugin Handbook](https://developer.wordpress.org/plugins/)
|
||||
* [WordPress Plugin Security](https://developer.wordpress.org/plugins/wordpress-org/plugin-security/)
|
||||
* [WordPress APIs Security](https://developer.wordpress.org/apis/security/)
|
||||
* [WordPress Coding Standards](https://github.com/WordPress/WordPress-Coding-Standards)
|
||||
* [Plugin Check (PCP)](https://wordpress.org/plugins/plugin-check/)
|
||||
50
robotstxt-mediaaudit.php
Normal file
50
robotstxt-mediaaudit.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
/**
|
||||
* Plugin Name: Media Audit (by ROBOTSTXT)
|
||||
* Plugin URI: https://robotstxt.es/plugins/media-audit/
|
||||
* Description: Internal media library usage auditing and external reverse image search to detect potential copyright issues.
|
||||
* Version: 1.0.0
|
||||
* Requires at least: 6.8
|
||||
* Tested up to: 7.0
|
||||
* Requires PHP: 8.2
|
||||
* Requires Plugins: action-scheduler
|
||||
* Author: Javier Casares
|
||||
* Author URI: https://javiercasares.com
|
||||
* License: GPL-3.0-or-later
|
||||
* License URI: https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
* Text Domain: robotstxt-mediaaudit
|
||||
* Domain Path: /languages
|
||||
*
|
||||
* @package MediaRightsAudit
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define( 'ROBOTSTXT_MEDIAAUDIT_VERSION', '1.0.0' );
|
||||
define( 'ROBOTSTXT_MEDIAAUDIT_DB_VERSION', '1.0.1' );
|
||||
define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_FILE', __FILE__ );
|
||||
define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
|
||||
require_once ROBOTSTXT_MEDIAAUDIT_PLUGIN_DIR . 'vendor/autoload.php';
|
||||
|
||||
use MediaRightsAudit\Core\Activator;
|
||||
use MediaRightsAudit\Core\Deactivator;
|
||||
use MediaRightsAudit\Core\Plugin;
|
||||
|
||||
register_activation_hook( __FILE__, array( Activator::class, 'activate' ) );
|
||||
register_deactivation_hook( __FILE__, array( Deactivator::class, 'deactivate' ) );
|
||||
|
||||
/**
|
||||
* Bootstraps the plugin.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
function robotstxt_mediaaudit_run(): void {
|
||||
$plugin = new Plugin();
|
||||
$plugin->run();
|
||||
}
|
||||
|
||||
add_action( 'plugins_loaded', 'robotstxt_mediaaudit_run' );
|
||||
31
uninstall.php
Normal file
31
uninstall.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
/**
|
||||
* Fired when the plugin is uninstalled.
|
||||
*
|
||||
* Drops all plugin data only if the user has explicitly opted in via Settings.
|
||||
* By default, data is preserved.
|
||||
*
|
||||
* @package MediaRightsAudit
|
||||
*/
|
||||
|
||||
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$raw = get_option( 'robotstxt_mediaaudit_settings', array() );
|
||||
$settings = is_array( $raw ) ? $raw : array();
|
||||
$delete = ! empty( $settings['delete_on_uninstall'] );
|
||||
|
||||
if ( ! $delete ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// Drop tables in reverse dependency order.
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_external_results`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_usage`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_index`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
|
||||
delete_option( 'robotstxt_mediaaudit_db_version' );
|
||||
delete_option( 'robotstxt_mediaaudit_settings' );
|
||||
25
vendor/autoload.php
vendored
Normal file
25
vendor/autoload.php
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
// autoload.php @generated by Composer
|
||||
|
||||
if (PHP_VERSION_ID < 50600) {
|
||||
if (!headers_sent()) {
|
||||
header('HTTP/1.1 500 Internal Server Error');
|
||||
}
|
||||
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
|
||||
if (!ini_get('display_errors')) {
|
||||
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
|
||||
fwrite(STDERR, $err);
|
||||
} elseif (!headers_sent()) {
|
||||
echo $err;
|
||||
}
|
||||
}
|
||||
trigger_error(
|
||||
$err,
|
||||
E_USER_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/composer/autoload_real.php';
|
||||
|
||||
return ComposerAutoloaderInit957728ab3efa005f456e5f9df13a19d2::getLoader();
|
||||
579
vendor/composer/ClassLoader.php
vendored
Normal file
579
vendor/composer/ClassLoader.php
vendored
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Composer.
|
||||
*
|
||||
* (c) Nils Adermann <naderman@naderman.de>
|
||||
* Jordi Boggiano <j.boggiano@seld.be>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Composer\Autoload;
|
||||
|
||||
/**
|
||||
* ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
|
||||
*
|
||||
* $loader = new \Composer\Autoload\ClassLoader();
|
||||
*
|
||||
* // register classes with namespaces
|
||||
* $loader->add('Symfony\Component', __DIR__.'/component');
|
||||
* $loader->add('Symfony', __DIR__.'/framework');
|
||||
*
|
||||
* // activate the autoloader
|
||||
* $loader->register();
|
||||
*
|
||||
* // to enable searching the include path (eg. for PEAR packages)
|
||||
* $loader->setUseIncludePath(true);
|
||||
*
|
||||
* In this example, if you try to use a class in the Symfony\Component
|
||||
* namespace or one of its children (Symfony\Component\Console for instance),
|
||||
* the autoloader will first look for the class under the component/
|
||||
* directory, and it will then fallback to the framework/ directory if not
|
||||
* found before giving up.
|
||||
*
|
||||
* This class is loosely based on the Symfony UniversalClassLoader.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
* @author Jordi Boggiano <j.boggiano@seld.be>
|
||||
* @see https://www.php-fig.org/psr/psr-0/
|
||||
* @see https://www.php-fig.org/psr/psr-4/
|
||||
*/
|
||||
class ClassLoader
|
||||
{
|
||||
/** @var \Closure(string):void */
|
||||
private static $includeFile;
|
||||
|
||||
/** @var string|null */
|
||||
private $vendorDir;
|
||||
|
||||
// PSR-4
|
||||
/**
|
||||
* @var array<string, array<string, int>>
|
||||
*/
|
||||
private $prefixLengthsPsr4 = array();
|
||||
/**
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
private $prefixDirsPsr4 = array();
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private $fallbackDirsPsr4 = array();
|
||||
|
||||
// PSR-0
|
||||
/**
|
||||
* List of PSR-0 prefixes
|
||||
*
|
||||
* Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
|
||||
*
|
||||
* @var array<string, array<string, list<string>>>
|
||||
*/
|
||||
private $prefixesPsr0 = array();
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private $fallbackDirsPsr0 = array();
|
||||
|
||||
/** @var bool */
|
||||
private $useIncludePath = false;
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private $classMap = array();
|
||||
|
||||
/** @var bool */
|
||||
private $classMapAuthoritative = false;
|
||||
|
||||
/**
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private $missingClasses = array();
|
||||
|
||||
/** @var string|null */
|
||||
private $apcuPrefix;
|
||||
|
||||
/**
|
||||
* @var array<string, self>
|
||||
*/
|
||||
private static $registeredLoaders = array();
|
||||
|
||||
/**
|
||||
* @param string|null $vendorDir
|
||||
*/
|
||||
public function __construct($vendorDir = null)
|
||||
{
|
||||
$this->vendorDir = $vendorDir;
|
||||
self::initializeIncludeClosure();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function getPrefixes()
|
||||
{
|
||||
if (!empty($this->prefixesPsr0)) {
|
||||
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
|
||||
}
|
||||
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function getPrefixesPsr4()
|
||||
{
|
||||
return $this->prefixDirsPsr4;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getFallbackDirs()
|
||||
{
|
||||
return $this->fallbackDirsPsr0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getFallbackDirsPsr4()
|
||||
{
|
||||
return $this->fallbackDirsPsr4;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> Array of classname => path
|
||||
*/
|
||||
public function getClassMap()
|
||||
{
|
||||
return $this->classMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $classMap Class to filename map
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function addClassMap(array $classMap)
|
||||
{
|
||||
if ($this->classMap) {
|
||||
$this->classMap = array_merge($this->classMap, $classMap);
|
||||
} else {
|
||||
$this->classMap = $classMap;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-0 directories for a given prefix, either
|
||||
* appending or prepending to the ones previously set for this prefix.
|
||||
*
|
||||
* @param string $prefix The prefix
|
||||
* @param list<string>|string $paths The PSR-0 root directories
|
||||
* @param bool $prepend Whether to prepend the directories
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function add($prefix, $paths, $prepend = false)
|
||||
{
|
||||
$paths = (array) $paths;
|
||||
if (!$prefix) {
|
||||
if ($prepend) {
|
||||
$this->fallbackDirsPsr0 = array_merge(
|
||||
$paths,
|
||||
$this->fallbackDirsPsr0
|
||||
);
|
||||
} else {
|
||||
$this->fallbackDirsPsr0 = array_merge(
|
||||
$this->fallbackDirsPsr0,
|
||||
$paths
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$first = $prefix[0];
|
||||
if (!isset($this->prefixesPsr0[$first][$prefix])) {
|
||||
$this->prefixesPsr0[$first][$prefix] = $paths;
|
||||
|
||||
return;
|
||||
}
|
||||
if ($prepend) {
|
||||
$this->prefixesPsr0[$first][$prefix] = array_merge(
|
||||
$paths,
|
||||
$this->prefixesPsr0[$first][$prefix]
|
||||
);
|
||||
} else {
|
||||
$this->prefixesPsr0[$first][$prefix] = array_merge(
|
||||
$this->prefixesPsr0[$first][$prefix],
|
||||
$paths
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-4 directories for a given namespace, either
|
||||
* appending or prepending to the ones previously set for this namespace.
|
||||
*
|
||||
* @param string $prefix The prefix/namespace, with trailing '\\'
|
||||
* @param list<string>|string $paths The PSR-4 base directories
|
||||
* @param bool $prepend Whether to prepend the directories
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function addPsr4($prefix, $paths, $prepend = false)
|
||||
{
|
||||
$paths = (array) $paths;
|
||||
if (!$prefix) {
|
||||
// Register directories for the root namespace.
|
||||
if ($prepend) {
|
||||
$this->fallbackDirsPsr4 = array_merge(
|
||||
$paths,
|
||||
$this->fallbackDirsPsr4
|
||||
);
|
||||
} else {
|
||||
$this->fallbackDirsPsr4 = array_merge(
|
||||
$this->fallbackDirsPsr4,
|
||||
$paths
|
||||
);
|
||||
}
|
||||
} elseif (!isset($this->prefixDirsPsr4[$prefix])) {
|
||||
// Register directories for a new namespace.
|
||||
$length = strlen($prefix);
|
||||
if ('\\' !== $prefix[$length - 1]) {
|
||||
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
|
||||
}
|
||||
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
|
||||
$this->prefixDirsPsr4[$prefix] = $paths;
|
||||
} elseif ($prepend) {
|
||||
// Prepend directories for an already registered namespace.
|
||||
$this->prefixDirsPsr4[$prefix] = array_merge(
|
||||
$paths,
|
||||
$this->prefixDirsPsr4[$prefix]
|
||||
);
|
||||
} else {
|
||||
// Append directories for an already registered namespace.
|
||||
$this->prefixDirsPsr4[$prefix] = array_merge(
|
||||
$this->prefixDirsPsr4[$prefix],
|
||||
$paths
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-0 directories for a given prefix,
|
||||
* replacing any others previously set for this prefix.
|
||||
*
|
||||
* @param string $prefix The prefix
|
||||
* @param list<string>|string $paths The PSR-0 base directories
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function set($prefix, $paths)
|
||||
{
|
||||
if (!$prefix) {
|
||||
$this->fallbackDirsPsr0 = (array) $paths;
|
||||
} else {
|
||||
$this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-4 directories for a given namespace,
|
||||
* replacing any others previously set for this namespace.
|
||||
*
|
||||
* @param string $prefix The prefix/namespace, with trailing '\\'
|
||||
* @param list<string>|string $paths The PSR-4 base directories
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setPsr4($prefix, $paths)
|
||||
{
|
||||
if (!$prefix) {
|
||||
$this->fallbackDirsPsr4 = (array) $paths;
|
||||
} else {
|
||||
$length = strlen($prefix);
|
||||
if ('\\' !== $prefix[$length - 1]) {
|
||||
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
|
||||
}
|
||||
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
|
||||
$this->prefixDirsPsr4[$prefix] = (array) $paths;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns on searching the include path for class files.
|
||||
*
|
||||
* @param bool $useIncludePath
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setUseIncludePath($useIncludePath)
|
||||
{
|
||||
$this->useIncludePath = $useIncludePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be used to check if the autoloader uses the include path to check
|
||||
* for classes.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function getUseIncludePath()
|
||||
{
|
||||
return $this->useIncludePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns off searching the prefix and fallback directories for classes
|
||||
* that have not been registered with the class map.
|
||||
*
|
||||
* @param bool $classMapAuthoritative
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setClassMapAuthoritative($classMapAuthoritative)
|
||||
{
|
||||
$this->classMapAuthoritative = $classMapAuthoritative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should class lookup fail if not found in the current class map?
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isClassMapAuthoritative()
|
||||
{
|
||||
return $this->classMapAuthoritative;
|
||||
}
|
||||
|
||||
/**
|
||||
* APCu prefix to use to cache found/not-found classes, if the extension is enabled.
|
||||
*
|
||||
* @param string|null $apcuPrefix
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setApcuPrefix($apcuPrefix)
|
||||
{
|
||||
$this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The APCu prefix in use, or null if APCu caching is not enabled.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getApcuPrefix()
|
||||
{
|
||||
return $this->apcuPrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers this instance as an autoloader.
|
||||
*
|
||||
* @param bool $prepend Whether to prepend the autoloader or not
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register($prepend = false)
|
||||
{
|
||||
spl_autoload_register(array($this, 'loadClass'), true, $prepend);
|
||||
|
||||
if (null === $this->vendorDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($prepend) {
|
||||
self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
|
||||
} else {
|
||||
unset(self::$registeredLoaders[$this->vendorDir]);
|
||||
self::$registeredLoaders[$this->vendorDir] = $this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters this instance as an autoloader.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function unregister()
|
||||
{
|
||||
spl_autoload_unregister(array($this, 'loadClass'));
|
||||
|
||||
if (null !== $this->vendorDir) {
|
||||
unset(self::$registeredLoaders[$this->vendorDir]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the given class or interface.
|
||||
*
|
||||
* @param string $class The name of the class
|
||||
* @return true|null True if loaded, null otherwise
|
||||
*/
|
||||
public function loadClass($class)
|
||||
{
|
||||
if ($file = $this->findFile($class)) {
|
||||
$includeFile = self::$includeFile;
|
||||
$includeFile($file);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the path to the file where the class is defined.
|
||||
*
|
||||
* @param string $class The name of the class
|
||||
*
|
||||
* @return string|false The path if found, false otherwise
|
||||
*/
|
||||
public function findFile($class)
|
||||
{
|
||||
// class map lookup
|
||||
if (isset($this->classMap[$class])) {
|
||||
return $this->classMap[$class];
|
||||
}
|
||||
if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
|
||||
return false;
|
||||
}
|
||||
if (null !== $this->apcuPrefix) {
|
||||
$file = apcu_fetch($this->apcuPrefix.$class, $hit);
|
||||
if ($hit) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
$file = $this->findFileWithExtension($class, '.php');
|
||||
|
||||
// Search for Hack files if we are running on HHVM
|
||||
if (false === $file && defined('HHVM_VERSION')) {
|
||||
$file = $this->findFileWithExtension($class, '.hh');
|
||||
}
|
||||
|
||||
if (null !== $this->apcuPrefix) {
|
||||
apcu_add($this->apcuPrefix.$class, $file);
|
||||
}
|
||||
|
||||
if (false === $file) {
|
||||
// Remember that this class does not exist.
|
||||
$this->missingClasses[$class] = true;
|
||||
}
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently registered loaders keyed by their corresponding vendor directories.
|
||||
*
|
||||
* @return array<string, self>
|
||||
*/
|
||||
public static function getRegisteredLoaders()
|
||||
{
|
||||
return self::$registeredLoaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $class
|
||||
* @param string $ext
|
||||
* @return string|false
|
||||
*/
|
||||
private function findFileWithExtension($class, $ext)
|
||||
{
|
||||
// PSR-4 lookup
|
||||
$logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
|
||||
|
||||
$first = $class[0];
|
||||
if (isset($this->prefixLengthsPsr4[$first])) {
|
||||
$subPath = $class;
|
||||
while (false !== $lastPos = strrpos($subPath, '\\')) {
|
||||
$subPath = substr($subPath, 0, $lastPos);
|
||||
$search = $subPath . '\\';
|
||||
if (isset($this->prefixDirsPsr4[$search])) {
|
||||
$pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
|
||||
foreach ($this->prefixDirsPsr4[$search] as $dir) {
|
||||
if (file_exists($file = $dir . $pathEnd)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-4 fallback dirs
|
||||
foreach ($this->fallbackDirsPsr4 as $dir) {
|
||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0 lookup
|
||||
if (false !== $pos = strrpos($class, '\\')) {
|
||||
// namespaced class name
|
||||
$logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
|
||||
. strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
|
||||
} else {
|
||||
// PEAR-like class name
|
||||
$logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
|
||||
}
|
||||
|
||||
if (isset($this->prefixesPsr0[$first])) {
|
||||
foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
|
||||
if (0 === strpos($class, $prefix)) {
|
||||
foreach ($dirs as $dir) {
|
||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0 fallback dirs
|
||||
foreach ($this->fallbackDirsPsr0 as $dir) {
|
||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0 include paths.
|
||||
if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
private static function initializeIncludeClosure()
|
||||
{
|
||||
if (self::$includeFile !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope isolated include.
|
||||
*
|
||||
* Prevents access to $this/self from included files.
|
||||
*
|
||||
* @param string $file
|
||||
* @return void
|
||||
*/
|
||||
self::$includeFile = \Closure::bind(static function($file) {
|
||||
include $file;
|
||||
}, null, null);
|
||||
}
|
||||
}
|
||||
359
vendor/composer/InstalledVersions.php
vendored
Normal file
359
vendor/composer/InstalledVersions.php
vendored
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Composer.
|
||||
*
|
||||
* (c) Nils Adermann <naderman@naderman.de>
|
||||
* Jordi Boggiano <j.boggiano@seld.be>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Composer;
|
||||
|
||||
use Composer\Autoload\ClassLoader;
|
||||
use Composer\Semver\VersionParser;
|
||||
|
||||
/**
|
||||
* This class is copied in every Composer installed project and available to all
|
||||
*
|
||||
* See also https://getcomposer.org/doc/07-runtime.md#installed-versions
|
||||
*
|
||||
* To require its presence, you can require `composer-runtime-api ^2.0`
|
||||
*
|
||||
* @final
|
||||
*/
|
||||
class InstalledVersions
|
||||
{
|
||||
/**
|
||||
* @var mixed[]|null
|
||||
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
|
||||
*/
|
||||
private static $installed;
|
||||
|
||||
/**
|
||||
* @var bool|null
|
||||
*/
|
||||
private static $canGetVendors;
|
||||
|
||||
/**
|
||||
* @var array[]
|
||||
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
*/
|
||||
private static $installedByVendor = array();
|
||||
|
||||
/**
|
||||
* Returns a list of all package names which are present, either by being installed, replaced or provided
|
||||
*
|
||||
* @return string[]
|
||||
* @psalm-return list<string>
|
||||
*/
|
||||
public static function getInstalledPackages()
|
||||
{
|
||||
$packages = array();
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
$packages[] = array_keys($installed['versions']);
|
||||
}
|
||||
|
||||
if (1 === \count($packages)) {
|
||||
return $packages[0];
|
||||
}
|
||||
|
||||
return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all package names with a specific type e.g. 'library'
|
||||
*
|
||||
* @param string $type
|
||||
* @return string[]
|
||||
* @psalm-return list<string>
|
||||
*/
|
||||
public static function getInstalledPackagesByType($type)
|
||||
{
|
||||
$packagesByType = array();
|
||||
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
foreach ($installed['versions'] as $name => $package) {
|
||||
if (isset($package['type']) && $package['type'] === $type) {
|
||||
$packagesByType[] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $packagesByType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given package is installed
|
||||
*
|
||||
* This also returns true if the package name is provided or replaced by another package
|
||||
*
|
||||
* @param string $packageName
|
||||
* @param bool $includeDevRequirements
|
||||
* @return bool
|
||||
*/
|
||||
public static function isInstalled($packageName, $includeDevRequirements = true)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (isset($installed['versions'][$packageName])) {
|
||||
return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given package satisfies a version constraint
|
||||
*
|
||||
* e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
|
||||
*
|
||||
* Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
|
||||
*
|
||||
* @param VersionParser $parser Install composer/semver to have access to this class and functionality
|
||||
* @param string $packageName
|
||||
* @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
|
||||
* @return bool
|
||||
*/
|
||||
public static function satisfies(VersionParser $parser, $packageName, $constraint)
|
||||
{
|
||||
$constraint = $parser->parseConstraints((string) $constraint);
|
||||
$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
|
||||
|
||||
return $provided->matches($constraint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a version constraint representing all the range(s) which are installed for a given package
|
||||
*
|
||||
* It is easier to use this via isInstalled() with the $constraint argument if you need to check
|
||||
* whether a given version of a package is installed, and not just whether it exists
|
||||
*
|
||||
* @param string $packageName
|
||||
* @return string Version constraint usable with composer/semver
|
||||
*/
|
||||
public static function getVersionRanges($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ranges = array();
|
||||
if (isset($installed['versions'][$packageName]['pretty_version'])) {
|
||||
$ranges[] = $installed['versions'][$packageName]['pretty_version'];
|
||||
}
|
||||
if (array_key_exists('aliases', $installed['versions'][$packageName])) {
|
||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
|
||||
}
|
||||
if (array_key_exists('replaced', $installed['versions'][$packageName])) {
|
||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
|
||||
}
|
||||
if (array_key_exists('provided', $installed['versions'][$packageName])) {
|
||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
|
||||
}
|
||||
|
||||
return implode(' || ', $ranges);
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
|
||||
*/
|
||||
public static function getVersion($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($installed['versions'][$packageName]['version'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $installed['versions'][$packageName]['version'];
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
|
||||
*/
|
||||
public static function getPrettyVersion($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($installed['versions'][$packageName]['pretty_version'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $installed['versions'][$packageName]['pretty_version'];
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
|
||||
*/
|
||||
public static function getReference($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($installed['versions'][$packageName]['reference'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $installed['versions'][$packageName]['reference'];
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
|
||||
*/
|
||||
public static function getInstallPath($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
* @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
|
||||
*/
|
||||
public static function getRootPackage()
|
||||
{
|
||||
$installed = self::getInstalled();
|
||||
|
||||
return $installed[0]['root'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw installed.php data for custom implementations
|
||||
*
|
||||
* @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
|
||||
* @return array[]
|
||||
* @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
|
||||
*/
|
||||
public static function getRawData()
|
||||
{
|
||||
@trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
|
||||
|
||||
if (null === self::$installed) {
|
||||
// only require the installed.php file if this file is loaded from its dumped location,
|
||||
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
||||
if (substr(__DIR__, -8, 1) !== 'C') {
|
||||
self::$installed = include __DIR__ . '/installed.php';
|
||||
} else {
|
||||
self::$installed = array();
|
||||
}
|
||||
}
|
||||
|
||||
return self::$installed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw data of all installed.php which are currently loaded for custom implementations
|
||||
*
|
||||
* @return array[]
|
||||
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
*/
|
||||
public static function getAllRawData()
|
||||
{
|
||||
return self::getInstalled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets you reload the static array from another file
|
||||
*
|
||||
* This is only useful for complex integrations in which a project needs to use
|
||||
* this class but then also needs to execute another project's autoloader in process,
|
||||
* and wants to ensure both projects have access to their version of installed.php.
|
||||
*
|
||||
* A typical case would be PHPUnit, where it would need to make sure it reads all
|
||||
* the data it needs from this class, then call reload() with
|
||||
* `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
|
||||
* the project in which it runs can then also use this class safely, without
|
||||
* interference between PHPUnit's dependencies and the project's dependencies.
|
||||
*
|
||||
* @param array[] $data A vendor/composer/installed.php data set
|
||||
* @return void
|
||||
*
|
||||
* @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
|
||||
*/
|
||||
public static function reload($data)
|
||||
{
|
||||
self::$installed = $data;
|
||||
self::$installedByVendor = array();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array[]
|
||||
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
*/
|
||||
private static function getInstalled()
|
||||
{
|
||||
if (null === self::$canGetVendors) {
|
||||
self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
|
||||
}
|
||||
|
||||
$installed = array();
|
||||
|
||||
if (self::$canGetVendors) {
|
||||
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
|
||||
if (isset(self::$installedByVendor[$vendorDir])) {
|
||||
$installed[] = self::$installedByVendor[$vendorDir];
|
||||
} elseif (is_file($vendorDir.'/composer/installed.php')) {
|
||||
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
||||
$required = require $vendorDir.'/composer/installed.php';
|
||||
$installed[] = self::$installedByVendor[$vendorDir] = $required;
|
||||
if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) {
|
||||
self::$installed = $installed[count($installed) - 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (null === self::$installed) {
|
||||
// only require the installed.php file if this file is loaded from its dumped location,
|
||||
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
||||
if (substr(__DIR__, -8, 1) !== 'C') {
|
||||
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
||||
$required = require __DIR__ . '/installed.php';
|
||||
self::$installed = $required;
|
||||
} else {
|
||||
self::$installed = array();
|
||||
}
|
||||
}
|
||||
|
||||
if (self::$installed !== array()) {
|
||||
$installed[] = self::$installed;
|
||||
}
|
||||
|
||||
return $installed;
|
||||
}
|
||||
}
|
||||
19
vendor/composer/LICENSE
vendored
Normal file
19
vendor/composer/LICENSE
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
Copyright (c) Nils Adermann, Jordi Boggiano
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished
|
||||
to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
29
vendor/composer/autoload_classmap.php
vendored
Normal file
29
vendor/composer/autoload_classmap.php
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
// autoload_classmap.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = dirname($vendorDir);
|
||||
|
||||
return array(
|
||||
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
|
||||
'MediaRightsAudit\\Admin\\AuditPage' => $baseDir . '/includes/Admin/AuditPage.php',
|
||||
'MediaRightsAudit\\Admin\\MediaListTable' => $baseDir . '/includes/Admin/MediaListTable.php',
|
||||
'MediaRightsAudit\\Admin\\Settings' => $baseDir . '/includes/Admin/Settings.php',
|
||||
'MediaRightsAudit\\CLI\\Command' => $baseDir . '/includes/CLI/Command.php',
|
||||
'MediaRightsAudit\\Core\\Activator' => $baseDir . '/includes/Core/Activator.php',
|
||||
'MediaRightsAudit\\Core\\Database' => $baseDir . '/includes/Core/Database.php',
|
||||
'MediaRightsAudit\\Core\\Deactivator' => $baseDir . '/includes/Core/Deactivator.php',
|
||||
'MediaRightsAudit\\Core\\Plugin' => $baseDir . '/includes/Core/Plugin.php',
|
||||
'MediaRightsAudit\\Core\\Queue\\Scheduler' => $baseDir . '/includes/Core/Queue/Scheduler.php',
|
||||
'MediaRightsAudit\\External\\AbstractProvider' => $baseDir . '/includes/External/AbstractProvider.php',
|
||||
'MediaRightsAudit\\External\\ExternalScanner' => $baseDir . '/includes/External/ExternalScanner.php',
|
||||
'MediaRightsAudit\\External\\GoogleVisionProvider' => $baseDir . '/includes/External/GoogleVisionProvider.php',
|
||||
'MediaRightsAudit\\External\\ResultsConsolidator' => $baseDir . '/includes/External/ResultsConsolidator.php',
|
||||
'MediaRightsAudit\\External\\ScanResult' => $baseDir . '/includes/External/ScanResult.php',
|
||||
'MediaRightsAudit\\External\\TinEyeProvider' => $baseDir . '/includes/External/TinEyeProvider.php',
|
||||
'MediaRightsAudit\\Internal\\AttachmentIndexer' => $baseDir . '/includes/Internal/AttachmentIndexer.php',
|
||||
'MediaRightsAudit\\Internal\\UsageScanner' => $baseDir . '/includes/Internal/UsageScanner.php',
|
||||
'MediaRightsAudit\\Privacy\\DataEraser' => $baseDir . '/includes/Privacy/DataEraser.php',
|
||||
'MediaRightsAudit\\Privacy\\DataExporter' => $baseDir . '/includes/Privacy/DataExporter.php',
|
||||
);
|
||||
9
vendor/composer/autoload_namespaces.php
vendored
Normal file
9
vendor/composer/autoload_namespaces.php
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
// autoload_namespaces.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = dirname($vendorDir);
|
||||
|
||||
return array(
|
||||
);
|
||||
10
vendor/composer/autoload_psr4.php
vendored
Normal file
10
vendor/composer/autoload_psr4.php
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
// autoload_psr4.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = dirname($vendorDir);
|
||||
|
||||
return array(
|
||||
'MediaRightsAudit\\' => array($baseDir . '/includes'),
|
||||
);
|
||||
36
vendor/composer/autoload_real.php
vendored
Normal file
36
vendor/composer/autoload_real.php
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
// autoload_real.php @generated by Composer
|
||||
|
||||
class ComposerAutoloaderInit957728ab3efa005f456e5f9df13a19d2
|
||||
{
|
||||
private static $loader;
|
||||
|
||||
public static function loadClassLoader($class)
|
||||
{
|
||||
if ('Composer\Autoload\ClassLoader' === $class) {
|
||||
require __DIR__ . '/ClassLoader.php';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Composer\Autoload\ClassLoader
|
||||
*/
|
||||
public static function getLoader()
|
||||
{
|
||||
if (null !== self::$loader) {
|
||||
return self::$loader;
|
||||
}
|
||||
|
||||
spl_autoload_register(array('ComposerAutoloaderInit957728ab3efa005f456e5f9df13a19d2', 'loadClassLoader'), true, true);
|
||||
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
|
||||
spl_autoload_unregister(array('ComposerAutoloaderInit957728ab3efa005f456e5f9df13a19d2', 'loadClassLoader'));
|
||||
|
||||
require __DIR__ . '/autoload_static.php';
|
||||
call_user_func(\Composer\Autoload\ComposerStaticInit957728ab3efa005f456e5f9df13a19d2::getInitializer($loader));
|
||||
|
||||
$loader->register(true);
|
||||
|
||||
return $loader;
|
||||
}
|
||||
}
|
||||
55
vendor/composer/autoload_static.php
vendored
Normal file
55
vendor/composer/autoload_static.php
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
// autoload_static.php @generated by Composer
|
||||
|
||||
namespace Composer\Autoload;
|
||||
|
||||
class ComposerStaticInit957728ab3efa005f456e5f9df13a19d2
|
||||
{
|
||||
public static $prefixLengthsPsr4 = array (
|
||||
'M' =>
|
||||
array (
|
||||
'MediaRightsAudit\\' => 17,
|
||||
),
|
||||
);
|
||||
|
||||
public static $prefixDirsPsr4 = array (
|
||||
'MediaRightsAudit\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/../..' . '/includes',
|
||||
),
|
||||
);
|
||||
|
||||
public static $classMap = array (
|
||||
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
|
||||
'MediaRightsAudit\\Admin\\AuditPage' => __DIR__ . '/../..' . '/includes/Admin/AuditPage.php',
|
||||
'MediaRightsAudit\\Admin\\MediaListTable' => __DIR__ . '/../..' . '/includes/Admin/MediaListTable.php',
|
||||
'MediaRightsAudit\\Admin\\Settings' => __DIR__ . '/../..' . '/includes/Admin/Settings.php',
|
||||
'MediaRightsAudit\\CLI\\Command' => __DIR__ . '/../..' . '/includes/CLI/Command.php',
|
||||
'MediaRightsAudit\\Core\\Activator' => __DIR__ . '/../..' . '/includes/Core/Activator.php',
|
||||
'MediaRightsAudit\\Core\\Database' => __DIR__ . '/../..' . '/includes/Core/Database.php',
|
||||
'MediaRightsAudit\\Core\\Deactivator' => __DIR__ . '/../..' . '/includes/Core/Deactivator.php',
|
||||
'MediaRightsAudit\\Core\\Plugin' => __DIR__ . '/../..' . '/includes/Core/Plugin.php',
|
||||
'MediaRightsAudit\\Core\\Queue\\Scheduler' => __DIR__ . '/../..' . '/includes/Core/Queue/Scheduler.php',
|
||||
'MediaRightsAudit\\External\\AbstractProvider' => __DIR__ . '/../..' . '/includes/External/AbstractProvider.php',
|
||||
'MediaRightsAudit\\External\\ExternalScanner' => __DIR__ . '/../..' . '/includes/External/ExternalScanner.php',
|
||||
'MediaRightsAudit\\External\\GoogleVisionProvider' => __DIR__ . '/../..' . '/includes/External/GoogleVisionProvider.php',
|
||||
'MediaRightsAudit\\External\\ResultsConsolidator' => __DIR__ . '/../..' . '/includes/External/ResultsConsolidator.php',
|
||||
'MediaRightsAudit\\External\\ScanResult' => __DIR__ . '/../..' . '/includes/External/ScanResult.php',
|
||||
'MediaRightsAudit\\External\\TinEyeProvider' => __DIR__ . '/../..' . '/includes/External/TinEyeProvider.php',
|
||||
'MediaRightsAudit\\Internal\\AttachmentIndexer' => __DIR__ . '/../..' . '/includes/Internal/AttachmentIndexer.php',
|
||||
'MediaRightsAudit\\Internal\\UsageScanner' => __DIR__ . '/../..' . '/includes/Internal/UsageScanner.php',
|
||||
'MediaRightsAudit\\Privacy\\DataEraser' => __DIR__ . '/../..' . '/includes/Privacy/DataEraser.php',
|
||||
'MediaRightsAudit\\Privacy\\DataExporter' => __DIR__ . '/../..' . '/includes/Privacy/DataExporter.php',
|
||||
);
|
||||
|
||||
public static function getInitializer(ClassLoader $loader)
|
||||
{
|
||||
return \Closure::bind(function () use ($loader) {
|
||||
$loader->prefixLengthsPsr4 = ComposerStaticInit957728ab3efa005f456e5f9df13a19d2::$prefixLengthsPsr4;
|
||||
$loader->prefixDirsPsr4 = ComposerStaticInit957728ab3efa005f456e5f9df13a19d2::$prefixDirsPsr4;
|
||||
$loader->classMap = ComposerStaticInit957728ab3efa005f456e5f9df13a19d2::$classMap;
|
||||
|
||||
}, null, ClassLoader::class);
|
||||
}
|
||||
}
|
||||
5
vendor/composer/installed.json
vendored
Normal file
5
vendor/composer/installed.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"packages": [],
|
||||
"dev": false,
|
||||
"dev-package-names": []
|
||||
}
|
||||
23
vendor/composer/installed.php
vendored
Normal file
23
vendor/composer/installed.php
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php return array(
|
||||
'root' => array(
|
||||
'name' => 'robotstxt/mediaaudit',
|
||||
'pretty_version' => '1.0.0+no-version-set',
|
||||
'version' => '1.0.0.0',
|
||||
'reference' => null,
|
||||
'type' => 'wordpress-plugin',
|
||||
'install_path' => __DIR__ . '/../../',
|
||||
'aliases' => array(),
|
||||
'dev' => false,
|
||||
),
|
||||
'versions' => array(
|
||||
'robotstxt/mediaaudit' => array(
|
||||
'pretty_version' => '1.0.0+no-version-set',
|
||||
'version' => '1.0.0.0',
|
||||
'reference' => null,
|
||||
'type' => 'wordpress-plugin',
|
||||
'install_path' => __DIR__ . '/../../',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
),
|
||||
);
|
||||
Loading…
Reference in a new issue