From 6708bbb67fefd558dfef76ed965272618c5b53a8 Mon Sep 17 00:00:00 2001 From: Javier Casares Date: Wed, 3 Jun 2026 06:24:03 +0000 Subject: [PATCH] v1.0.0 --- LICENSE | 674 ++++++++++++++++++++ assets/css/media-audit-admin.css | 254 ++++++++ assets/js/media-audit-admin.js | 67 ++ changelog.txt | 34 + includes/Admin/AuditPage.php | 705 +++++++++++++++++++++ includes/Admin/MediaListTable.php | 570 +++++++++++++++++ includes/Admin/Settings.php | 340 ++++++++++ includes/CLI/Command.php | 239 +++++++ includes/Core/Activator.php | 25 + includes/Core/Database.php | 141 +++++ includes/Core/Deactivator.php | 28 + includes/Core/Plugin.php | 165 +++++ includes/Core/Queue/Scheduler.php | 77 +++ includes/External/AbstractProvider.php | 128 ++++ includes/External/ExternalScanner.php | 334 ++++++++++ includes/External/GoogleVisionProvider.php | 177 ++++++ includes/External/ResultsConsolidator.php | 99 +++ includes/External/ScanResult.php | 75 +++ includes/External/TinEyeProvider.php | 151 +++++ includes/Internal/AttachmentIndexer.php | 175 +++++ includes/Internal/UsageScanner.php | 464 ++++++++++++++ includes/Privacy/DataEraser.php | 160 +++++ includes/Privacy/DataExporter.php | 316 +++++++++ languages/robotstxt-mediaaudit-ca.mo | Bin 0 -> 8366 bytes languages/robotstxt-mediaaudit-ca.po | 535 ++++++++++++++++ languages/robotstxt-mediaaudit-es_ES.mo | Bin 0 -> 8215 bytes languages/robotstxt-mediaaudit-es_ES.po | 536 ++++++++++++++++ languages/robotstxt-mediaaudit.pot | 526 +++++++++++++++ messages.mo | Bin 0 -> 7894 bytes readme.txt | 117 ++++ robotstxt-mediaaudit.php | 50 ++ uninstall.php | 31 + vendor/autoload.php | 25 + vendor/composer/ClassLoader.php | 579 +++++++++++++++++ vendor/composer/InstalledVersions.php | 359 +++++++++++ vendor/composer/LICENSE | 19 + vendor/composer/autoload_classmap.php | 29 + vendor/composer/autoload_namespaces.php | 9 + vendor/composer/autoload_psr4.php | 10 + vendor/composer/autoload_real.php | 36 ++ vendor/composer/autoload_static.php | 55 ++ vendor/composer/installed.json | 5 + vendor/composer/installed.php | 23 + 43 files changed, 8342 insertions(+) create mode 100644 LICENSE create mode 100644 assets/css/media-audit-admin.css create mode 100644 assets/js/media-audit-admin.js create mode 100644 changelog.txt create mode 100644 includes/Admin/AuditPage.php create mode 100644 includes/Admin/MediaListTable.php create mode 100644 includes/Admin/Settings.php create mode 100644 includes/CLI/Command.php create mode 100644 includes/Core/Activator.php create mode 100644 includes/Core/Database.php create mode 100644 includes/Core/Deactivator.php create mode 100644 includes/Core/Plugin.php create mode 100644 includes/Core/Queue/Scheduler.php create mode 100644 includes/External/AbstractProvider.php create mode 100644 includes/External/ExternalScanner.php create mode 100644 includes/External/GoogleVisionProvider.php create mode 100644 includes/External/ResultsConsolidator.php create mode 100644 includes/External/ScanResult.php create mode 100644 includes/External/TinEyeProvider.php create mode 100644 includes/Internal/AttachmentIndexer.php create mode 100644 includes/Internal/UsageScanner.php create mode 100644 includes/Privacy/DataEraser.php create mode 100644 includes/Privacy/DataExporter.php create mode 100644 languages/robotstxt-mediaaudit-ca.mo create mode 100644 languages/robotstxt-mediaaudit-ca.po create mode 100644 languages/robotstxt-mediaaudit-es_ES.mo create mode 100644 languages/robotstxt-mediaaudit-es_ES.po create mode 100644 languages/robotstxt-mediaaudit.pot create mode 100644 messages.mo create mode 100644 readme.txt create mode 100644 robotstxt-mediaaudit.php create mode 100644 uninstall.php create mode 100644 vendor/autoload.php create mode 100644 vendor/composer/ClassLoader.php create mode 100644 vendor/composer/InstalledVersions.php create mode 100644 vendor/composer/LICENSE create mode 100644 vendor/composer/autoload_classmap.php create mode 100644 vendor/composer/autoload_namespaces.php create mode 100644 vendor/composer/autoload_psr4.php create mode 100644 vendor/composer/autoload_real.php create mode 100644 vendor/composer/autoload_static.php create mode 100644 vendor/composer/installed.json create mode 100644 vendor/composer/installed.php diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/assets/css/media-audit-admin.css b/assets/css/media-audit-admin.css new file mode 100644 index 0000000..23de874 --- /dev/null +++ b/assets/css/media-audit-admin.css @@ -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; +} diff --git a/assets/js/media-audit-admin.js b/assets/js/media-audit-admin.js new file mode 100644 index 0000000..3669edc --- /dev/null +++ b/assets/js/media-audit-admin.js @@ -0,0 +1,67 @@ +/* global mraAdmin */ +( function ( $ ) { + 'use strict'; + + var $overlay, $modalTitle, $modalBody; + + function openModal( attachmentId ) { + $modalTitle.text( mraAdmin.i18n.detailsTitle ); + $modalBody.html( '

' + mraAdmin.i18n.loading + '

' ); + $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( '

' + mraAdmin.i18n.errorLoading + '

' ); + } + }, + error: function () { + $modalBody.html( '

' + mraAdmin.i18n.errorLoading + '

' ); + } + } ); + } + + 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 ) ); diff --git a/changelog.txt b/changelog.txt new file mode 100644 index 0000000..91fceb2 --- /dev/null +++ b/changelog.txt @@ -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 diff --git a/includes/Admin/AuditPage.php b/includes/Admin/AuditPage.php new file mode 100644 index 0000000..c4f82a9 --- /dev/null +++ b/includes/Admin/AuditPage.php @@ -0,0 +1,705 @@ +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 '
'; + echo '

' . esc_html__( 'Media Audit', 'robotstxt-mediaaudit' ) . '

'; + + $this->render_dashboard_stats(); + + echo '
'; + echo ''; + $table->search_box( __( 'Search', 'robotstxt-mediaaudit' ), 'mra-search' ); + $table->display(); + echo '
'; + + // Modal overlay (hidden by default, opened via JS). + echo ''; + + echo '
'; // .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 = '

' . esc_html__( 'This image is not used in any post.', 'robotstxt-mediaaudit' ) . '

'; + } else { + $usage_html = ''; + $usage_html .= ''; + $usage_html .= ''; + $usage_html .= ''; + $usage_html .= ''; + $usage_html .= ''; + $usage_html .= ''; + + 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 .= ' ' . esc_html( $meta_key ) . ''; + } + + $edit_link = get_edit_post_link( $post_id ); + $post_cell = $edit_link + ? sprintf( '%s', esc_url( $edit_link ), esc_html( $post_title ) ) + : esc_html( $post_title ); + + $usage_html .= ''; + $usage_html .= ''; + $usage_html .= ''; + $usage_html .= ''; + $usage_html .= ''; + $usage_html .= ''; + } + + $usage_html .= '
' . esc_html__( 'Post', 'robotstxt-mediaaudit' ) . '' . esc_html__( 'Type', 'robotstxt-mediaaudit' ) . '' . esc_html__( 'Context', 'robotstxt-mediaaudit' ) . '' . esc_html__( 'Status', 'robotstxt-mediaaudit' ) . '
' . $post_cell . '' . esc_html( $post_type ) . '' . $ctx_label . '' . esc_html( $status ) . '
'; + } + + // 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 = '

' . esc_html__( 'Internal Usage', 'robotstxt-mediaaudit' ) . '

'; + $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 = '

' . esc_html__( 'External Scan Results', 'robotstxt-mediaaudit' ) . '

'; + + 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 .= '
'; + $html .= '' . esc_html( $name ) . ' — '; + + 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 .= ' ' . esc_html( $scanned_at ) . ''; + } + $html .= '
'; + + if ( ! empty( $domains ) ) { + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + foreach ( $domains as $domain => $count ) { + if ( ! is_string( $domain ) ) { + continue; + } + $html .= sprintf( + '', + esc_html( $domain ), + esc_html( (string) ( is_numeric( $count ) ? (int) $count : 0 ) ) + ); + } + $html .= '
' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . '' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . '
%s%s
'; + } + } + + // Consensus section: only when multiple providers have results. + if ( count( $rows ) > 1 ) { + $consensus = ResultsConsolidator::get_consensus_domains( $attachment_id ); + if ( ! empty( $consensus ) ) { + $html .= '
'; + $html .= '' . esc_html__( 'Consensus Domains', 'robotstxt-mediaaudit' ) . ''; + $html .= '
    '; + foreach ( $consensus as $domain => $count ) { + $html .= '
  • ' . esc_html( $domain ) . ' (' . esc_html( + sprintf( + /* translators: %d: number of providers that agree */ + _n( '%d provider', '%d providers', $count, 'robotstxt-mediaaudit' ), + $count + ) + ) . ')
  • '; + } + $html .= '
'; + } + } + + 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 '
'; + + $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 '
'; + } + + /** + * 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 '
'; + echo '' . esc_html( number_format_i18n( $value ) ) . ''; + echo '' . esc_html( $label ) . ''; + if ( '' !== $sub ) { + echo '' . esc_html( $sub ) . ''; + } + echo '
'; + } + + /** + * 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 + */ + 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 $ids Attachment IDs to export (empty = all). + * + * @return array> + */ + 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(), + ); + } +} diff --git a/includes/Admin/MediaListTable.php b/includes/Admin/MediaListTable.php new file mode 100644 index 0000000..2e32fc7 --- /dev/null +++ b/includes/Admin/MediaListTable.php @@ -0,0 +1,570 @@ + 'mra-attachment', + 'plural' => 'mra-attachments', + 'ajax' => false, + ) + ); + } + + // ------------------------------------------------------------------------- + // WP_List_Table interface + // ------------------------------------------------------------------------- + + /** + * Returns all column headers. + * + * @return array + */ + public function get_columns() { + return array( + 'cb' => '', + '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> + */ + protected function get_sortable_columns() { + return array( + 'attachment_id' => array( 'attachment_id', true ), + 'usage_count' => array( 'usage_count', false ), + ); + } + + /** + * Returns bulk actions. + * + * @return array + */ + 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 $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 $item Row data. + * + * @return string + */ + protected function column_cb( $item ) { + $aid = $item['attachment_id']; + return sprintf( + '', + is_numeric( $aid ) ? (int) $aid : 0 + ); + } + + /** + * Renders a 40×40 thumbnail linked to the attachment. + * + * @param array $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 = ''; + } + + return sprintf( '%s', $url, $thumb ); + } + + /** + * Renders the filename column with a link to the original file. + * + * @param array $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( + '%s', + $url, + ( '' !== $filename ? $filename : '—' ) + ); + + $raw_edit_link = get_edit_post_link( $id ); + $actions = array( + 'edit' => sprintf( + '%s', + esc_url( is_string( $raw_edit_link ) ? $raw_edit_link : '' ), + __( 'Edit', 'robotstxt-mediaaudit' ) + ), + 'view_details' => sprintf( + '%s', + $id, + __( 'View Details', 'robotstxt-mediaaudit' ) + ), + ); + + return $title . $this->row_actions( $actions ); + } + + /** + * Renders the attachment ID column. + * + * @param array $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 $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 '' . esc_html__( 'Unused', 'robotstxt-mediaaudit' ) . ''; + } + return esc_html( (string) $count ); + } + + /** + * Renders the usages column with inline post links (max 3) and a modal link for more. + * + * @param array $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 = '
    '; + 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( '
  • %s
  • ', esc_url( $edit_link ), $post_title ); + } else { + $html .= '
  • ' . $post_title . '
  • '; + } + } + + if ( $count > 3 ) { + $html .= sprintf( + '
  • %s
  • ', + $id, + sprintf( + /* translators: %d: number of additional usages */ + esc_html__( '+ %d more…', 'robotstxt-mediaaudit' ), + $count - 3 + ) + ); + } + + $html .= '
'; + return $html; + } + + /** + * Renders the external status badge. + * + * @param array $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( + '%s', + 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( + ' %s', + 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( + ' %s', + $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 '
'; + + // External status filter. + echo ''; + + // Post type filter. + if ( $post_types ) { + echo ''; + } + + // Unused toggle. + echo ''; + + submit_button( __( 'Filter', 'robotstxt-mediaaudit' ), 'button', 'mra_filter', false ); + echo '
'; + } + + /** + * 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 $attachment_ids Attachment IDs to look up. + * + * @return list> + */ + 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 + */ + 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; + } +} diff --git a/includes/Admin/Settings.php b/includes/Admin/Settings.php new file mode 100644 index 0000000..f141285 --- /dev/null +++ b/includes/Admin/Settings.php @@ -0,0 +1,340 @@ + '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 + */ + 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'] ); + ?> + +

+ +

+ + +

+ + — + + + +

+ + + + + + + + + + + + + + + + + + + + + +
1,001 – 5,000,000$1.50
5,000,001+$0.60
+ + +

+ + — + + + +

+ + + + + + + + + + + + + + + + + + + + + + + + + +
500$50
5,000$200
25,000$600
+ + +

+ +

+ + +

+ +

+ +
+

+
+ +
+
+ ] + * : 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 $args Positional arguments (unused). + * @param array $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=] + * : 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 $args Positional arguments (unused). + * @param array $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 + ) + ); + } +} diff --git a/includes/Core/Activator.php b/includes/Core/Activator.php new file mode 100644 index 0000000..8484403 --- /dev/null +++ b/includes/Core/Activator.php @@ -0,0 +1,25 @@ +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 + } +} diff --git a/includes/Core/Deactivator.php b/includes/Core/Deactivator.php new file mode 100644 index 0000000..a4897a9 --- /dev/null +++ b/includes/Core/Deactivator.php @@ -0,0 +1,28 @@ +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 ); + } + } +} diff --git a/includes/Core/Queue/Scheduler.php b/includes/Core/Queue/Scheduler.php new file mode 100644 index 0000000..dca07bc --- /dev/null +++ b/includes/Core/Queue/Scheduler.php @@ -0,0 +1,77 @@ + $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 $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 ); + } +} diff --git a/includes/External/AbstractProvider.php b/includes/External/AbstractProvider.php new file mode 100644 index 0000000..0c4916b --- /dev/null +++ b/includes/External/AbstractProvider.php @@ -0,0 +1,128 @@ +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 $items URL-bearing items (e.g. pages or backlink objects). + * + * @return array 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 ); + } +} diff --git a/includes/External/ExternalScanner.php b/includes/External/ExternalScanner.php new file mode 100644 index 0000000..806c16d --- /dev/null +++ b/includes/External/ExternalScanner.php @@ -0,0 +1,334 @@ +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 $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 $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 $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 + */ + 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; + } +} diff --git a/includes/External/GoogleVisionProvider.php b/includes/External/GoogleVisionProvider.php new file mode 100644 index 0000000..3a68565 --- /dev/null +++ b/includes/External/GoogleVisionProvider.php @@ -0,0 +1,177 @@ +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 $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 $decoded Full decoded API response. + * + * @return array + */ + 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(); + } +} diff --git a/includes/External/ResultsConsolidator.php b/includes/External/ResultsConsolidator.php new file mode 100644 index 0000000..2298952 --- /dev/null +++ b/includes/External/ResultsConsolidator.php @@ -0,0 +1,99 @@ + 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> $rows Provider result rows. + * @param int $min_providers Minimum provider agreement. + * + * @return array 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; + } +} diff --git a/includes/External/ScanResult.php b/includes/External/ScanResult.php new file mode 100644 index 0000000..3d64845 --- /dev/null +++ b/includes/External/ScanResult.php @@ -0,0 +1,75 @@ + + */ + private array $top_domains; + + /** + * Full decoded API response for storage. + * + * @var array + */ + private array $raw_response; + + /** + * Constructs a new scan result. + * + * @param int $match_count Number of pages or images found. + * @param array $top_domains Domain → count map. + * @param array $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 + */ + public function top_domains(): array { + return $this->top_domains; + } + + /** + * Returns the full decoded API response payload. + * + * @return array + */ + public function raw_response(): array { + return $this->raw_response; + } +} diff --git a/includes/External/TinEyeProvider.php b/includes/External/TinEyeProvider.php new file mode 100644 index 0000000..b10050e --- /dev/null +++ b/includes/External/TinEyeProvider.php @@ -0,0 +1,151 @@ +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 $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 ); + } +} diff --git a/includes/Internal/AttachmentIndexer.php b/includes/Internal/AttachmentIndexer.php new file mode 100644 index 0000000..0ba069f --- /dev/null +++ b/includes/Internal/AttachmentIndexer.php @@ -0,0 +1,175 @@ +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 ); + } + } +} diff --git a/includes/Internal/UsageScanner.php b/includes/Internal/UsageScanner.php new file mode 100644 index 0000000..17558cc --- /dev/null +++ b/includes/Internal/UsageScanner.php @@ -0,0 +1,464 @@ +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 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 + */ + 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 ); + } + } +} diff --git a/includes/Privacy/DataEraser.php b/includes/Privacy/DataEraser.php new file mode 100644 index 0000000..5588485 --- /dev/null +++ b/includes/Privacy/DataEraser.php @@ -0,0 +1,160 @@ + $erasers Accumulated eraser list. + * + * @return array + */ + 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, 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, 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 + */ + 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 $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; + } +} diff --git a/includes/Privacy/DataExporter.php b/includes/Privacy/DataExporter.php new file mode 100644 index 0000000..7dc7cb8 --- /dev/null +++ b/includes/Privacy/DataExporter.php @@ -0,0 +1,316 @@ + $exporters Accumulated exporter list. + * + * @return array + */ + 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, 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 + */ + 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 $ids Attachment IDs. + * + * @return array + */ + 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 $ids Attachment IDs. + * + * @return array + */ + 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 $row DB row. + * @param int $aid Attachment ID. + * + * @return array> + */ + 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 $row DB row. + * @param int $aid Attachment ID. + * @param string $provider Provider slug. + * + * @return array> + */ + 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, + ), + ); + } +} diff --git a/languages/robotstxt-mediaaudit-ca.mo b/languages/robotstxt-mediaaudit-ca.mo new file mode 100644 index 0000000000000000000000000000000000000000..f18b88b7722dcfbb38a9873664c286cfed7eb8dc GIT binary patch literal 8366 zcmbuDTZ|-EdB=}qLtp`OF}8z4!ZBt&Gv4iB)~(WU||Vs6D%;G#RMw?0WWqVBox_-l1N|?1>~m)A~{4#;z*Im z?|-Vgr+apmmz4VJf7PjT`ObGg^)GL^<}VG`Iq2=slUEz_7%d=v*MhI3liuG1YTOv8ad+_dYVaudAb1>n z75F6hX7DSZSotpaTj2LVzYZwAUIxX_)hwoY-VR;^-U4c#aqtGP3DVWfgKq+t!Rx^Q zd@Fb!)Ow!+#s3rFH25`8{QtCm|1$U*p09!l;Q>(VUI+5Z+yrXLCt?N!kGgPgPQ*wsC_;P zYM-9~wg1n7uxy?HrH8MA{LJ6f?azXe!waCs{ScHKe-3K@{{h|yz8)q{foE#`8Yn$o z!=L)y0Nx5tf#TxY5vDSt@~Fsex=4|KyPQD`h5?)1^hcu@_ZRY z1m=H0?e8@>gZjMzlpNk#qXi}J!}a?XsQxpc>}>&*Tpp}3s&N~fr2UUU?dutE0(>6) z;#KG$d?(LOV63u(XF>Vvk3i|^KSA;LY8KJ_*Ml^fcY=u0+y%-{j@Qo**YCTa`hNn% z_06AykAR;ArMEY;7)zTQLCNPfa11GtFM_->M^Rc6{4glFeG1fk&w>2R^Zdy_{tXmguYj_P>)uiE`!-PeyBXBD$-2D- zs^2m2F!(<3wO|Q~&p)i+KUTMY9+Z823DkVws`2}v_We(w_Vpr&2+dDH$>o1R>E}9I z`z5`0(KxZMzT{687dLN|yu0Xmz1j&xhKp%ikLAu@p$M}_M+>0Xw%;ra}T%#ov5D$hoDCN+yP~mAA~*veHeNbbSrckdNrcz7c zE)-xHSCUG*F0pA@bjt$XGZ%CiUFJpFu?vxlLo;{GZo4dx(!}a&*@L;6bFnL2#Ww8& zHchpTnGd7F%x77eRsS|lgD^@q*>Mp>ao#lZ7rJRy*xA+7+_Kdqi0zVOgyaSdt8MnQ z;&NoPw{_Kvd9*9S+8k=x(<9ep?X=Ss>rH7m7-&e6PunifUDz}W4h^DkyVwafomq$w zyA)@~*LMn+PgE_ZR!;bvB3m$)p5&G9tdWEQkihW2zM zVeZ#vWo!4j9kZC&MOiO5-iGbL>34;8{NNG((J`uIs}t2*iFIfvGFd-L?N{7xlvFm{ zq;FnsY((uyhT3&mC(6YhDq6%;yk1V_lEdJ!8%dBhcR_{^6sfGhwOK7KDyM?a?oJkMZsD|fUOK-xb{q}s2rp`y_Xpb%elZ*5 z0j^?B1jt)flAx~f{<;%;+M{|Sv{z;*87(a?&D%BH)_AkCctlcE_b#^!JcT@6h&pA* zt0i;iV64DWSich`CHAk|erU1d(=UDOFYcV0Xxh2Tty^|_s%e(Iv}Poq;d>dAT{(H| zX4xeQBxX4-H!o8%{o?IuECUzquRBPIiAqxFz?DovM7A5mL4!vLQm!tEMSu$Jqo=rO)m$gZL zHfXoyCd`US3`8XdREvVruJcRHxjap;uN;Zg4exFGZrt?o3L#IE$=PR$s-u?lA;_=f zBaB(~fU&H)LP3d}0!;)NlNUjzNL{CoHD%K(I&q`Ifz%yh$2LfId}eQ&wJmHoA#0gh z9{nKggU6{vBTNN8F&iH)>v&S#qcml`>Wx4nw9sN=$E*55g$JqSbmY!U$G*FN=%+vb z(8Zo*{U0RphpA_HE5QDC zv2rtStfej6XXkvHg(`XSW}0nI=4Dr%>(wU*95p-}o{Iy^CmSnn+b7Tl9_m{5$kdTL z8&h{Qrl#%GU9ERdA0qiq9Z+IutYugr!70sJb~JI=dn!pB?Tdo-?`2T3St75-8dXq> zBtzwe>E6>0j>eH#-0jk`?cjh?$gV8d@PRt+G-Mhrt4fRA;H2-d?X7@JSiJYt+Ct;* z-G1V5!)1;6q@60Rwd~#NQE}i*qpBP{>klk9W=|~IBhBd}EODk$)mSaNuYgqHw$mtS zM`7@xCLDZrUBuclGn%VAgr2|%v|s3kh+adyH9nk96B41^)C6!WL%XcVZV2& zNaKFV-21Zk_^Mo1#K_6@jKO8h8rdFa(_p_G&$)Hl1Yp2EI8?3G;D4V#s*jz(U<&tMnOa70a)!Dwv`=;eFoC;E@{} zYj|Fj*Ux=34%$)gnWmi~wr;z!u_fo~sB0eCZ31aYtPWt8B#`*N{fHJ2Ap)eS(wDN# z&+S(ns+2V=B85mlwoJj@7dr>h7S^l`rSYOi}SjP#EuOy=TSBD=bCIQC*?XE;msqD79c#dx$*`M&_|g`1<``7z zw5m>@9WpCP)>nDhO%X9ElF7uD`qon$Rll;QsHa!o)+l5;V!G<2Y99ktH9R!-;C1|y&i3qo za10nV)+-2$a=!NKrW}bgKq^<|WKbOpkYVp}wWzQ+zL-rj4gr2ky>C~V5A1rh9!I!& z+rQ~^)ZzFRb$UHnMF>3O%b5q5BwF7#;P^3SaU5tYQcABS+@U&1+&3*x`<~ z!_3L*#)6VSiJJWx9hL(EnBLdgtwM%^-FELHhl-PX+|+T@P9HnQu7DI;~jgd0@x`b3#ANLY|VIHnRXjuhdi>buQOaH&B^J z*7Bzvonv-I8!Me9cm)>jWjtSJO_d%L5Lg5zLlmx?morxqve=)Ps6y8A-t$kL^6R96 z*;J{sAK>Rn4`pCZ^e*WH#Pr)PPBBOAW5hVCGf_V`6+Yp6%klFmuQ(#4UvyqJ+}7Jw7U&H5|5Q+H={!qH4agYJ#ZSMiNl%(0{Wsl zUX`}$QE^e7&lF!&nhh699NR{ksSe0_gclr_FNqdqM|^P*b9|PFyf1com#S|`I<1Ao Ym%s+KK)St)eA}QN?4OhVKgYKJ1C0^`=>Px# literal 0 HcmV?d00001 diff --git a/languages/robotstxt-mediaaudit-ca.po b/languages/robotstxt-mediaaudit-ca.po new file mode 100644 index 0000000..4adb207 --- /dev/null +++ b/languages/robotstxt-mediaaudit-ca.po @@ -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 \n" +"Language-Team: Catalan \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" diff --git a/languages/robotstxt-mediaaudit-es_ES.mo b/languages/robotstxt-mediaaudit-es_ES.mo new file mode 100644 index 0000000000000000000000000000000000000000..aea4c2bebaf3f799a8fdd8b0887d9722861d8e98 GIT binary patch literal 8215 zcmb`LdyE~|UB^#IpsAa-v`qsEg`OmJvWtE1?mCZ#Ot7hHrqcqmtg=0rlOzP~Sbk>8Idv_yjx!e-d7W zZ-QTh8p>b7pNC(Az8|P@y$Us+8|h5#ybaz2?}ln;4BiI2kg8@L{xn>KZ-o)O6<&hs z?{}ca|2a4XzYI10e{9~r3UB8A1_mJ>g6j7zkXPmosQ&MP8ut;Xaoz`i4xWPgt_L-r z6#fkSZFmHJ9^MYW0oCshU?2WF)I1!*)AOgHzWZduFF?)jFCZc}e*-n1Z$f_N`_!8u2o^PK2HIy8_0rlPYpyc>NsQLd0o`$zDh*fx@;g_M-=_XF9cN@G1PC||2 zF{trB2{qmiL4M}roYelOq58ej@QV#!fL_m_>U|B~4PSb)LH z4!1V6Q1U+7yq|`ue*#M1PD07$iH1qTEjYpRk3h}q1vn1>4nBVa>klpWe~Pk7U;mbq z^ywd<*6BZ?`n`ot)cze%a=jNKO7k$(dY^_&!K_2g=W_G@V^HJ#6fEFppyu~RlurHK z1|_#6@E!1esP-R+(!)NK+@FDx;}@X5{}R;tf37og<$B-D2UsPCSJ>gS_S-+cz&1FypK@MZV}yo*KNgue;Z&kvyFcLR$keZ3RP z?oLAs&qJ;EC8%-#7SuSNg{t>isQ&&0Y8+o_?q7te_YzdQUvJ+38$?yie?jT%t#mHE zyd7#i?}E~c$%eMcUGw+i{0Cpc+bPeZNaC!pl<`>+eILYB&W3#$KrftaB`ex0*) zN#oXmY5KY|a9R&I)TR#2>D`o`Za5?}9kQV@igZowe}JOHQQk?JqdZBOrRdO@j!`aB zSPDPZ)9_a)nzwX8hjip2$_2`MDXSD64^!@D^NlraC;dMv~hv zQch8%13K=bNParBt~uo-2+)H^6Wu2m9 zffD(vy{AXHRm?&a%n8c7C^~+E@)$+-G*8hHQ>G}=ox3SICMePsOvoQ8=K)3fqC@tu z;jfH&1fHdwY3_w1lumQshqANBDZfT}KSh4;9*XSeeoA}%G8ggzIxusuzvggk`%%@~ z9A3CmmswufZCBYOi```x>(QXdx02Wuqt~i?_^{2_Lq#)|<+e(y)Qy`XR!!%Hd*#D_ zWKJwD*xACxE~}C#EzOBE9X74&L0(q2+8)rkZp$jF>QXmV74f00FfUxztxU!V(9M<*vI&B?+!bIN6|h*EPZ&o^j=RjFfpK9MjFdS<0-r`@($ z$n1i&mkY1M_VCm@Vmo&D7-!a)rDUq(;jP3vG83t+|4PrVJKZD;I^3mhS+B1ry+n#S za78~UH9VGR0afwqvRXq@ zRmsvz7CMTzX>DkK-tq_ShJ%qGx*akC_ci~1`L zgb{gHKGrN@gf--YO7=8vFKyy!C8nk(j(q4i<#(3Quta`mJumD59>YH7*3d1P4h?qg zxix%gX4eZ`wVK`YiCll4x_zy#B-#A7W8JT{%f0OpSqj_Kp3VDxSM&&eHtO|cCbWu5 z3`Hda)Dtm$EdyzP=|?HLSPmt8vltIxbI*$=FX+p`yCsjj+cGd#3H1)C!T_u5n$4;jl+zOv7rp!J1@GA_Bu2>)t&549$In=^PRKhMiO^s>W#9ql26-q zIG6GwR?1U$^I~J7tOu&x^gb~hQO6I%kK)kcxz3W?@&UAig}P~bZ1UKHoyi9}lT&u` zozwSE9U=Hm9#UZFtQ2S<&M7aZ?cTs)9|@8;-im_$-_1waMvc5WD`Y{_c6ktGNx5mq zxWLx!Bd$E2CK~E))oJU>C+C+BDVpqxg}EPUTu?_^GHsP~F)`YC*!DIff@1Zq)s>T- zhj!~}u_~k25vug3ZiBur-zZgxX(+HLzFea4pa@dT`&$+ZC(*|Bd*QD9PjZ# z@kLxvZdRgxwDY`@2^y&I@+n?u5!Xppjh^O5eY?10njr`2DHkrba7DCFpAq#KJZ+~@ zFWGqkhk63Xx)rga3Q~E>DN!Xy^*ZBuN(9J4<<_4?B+ITK5mM$$Ru<;q`@;W{X8UwI zS4dJNyh7?&bb@8ys0rdO><^$Hv^v!gJHIQ zg81g$=?n+s^KiyCnu$}h7Zn>(5yXS5Nm-yaA?LAD^4Sr!DO;Cb5}}leYcc38MNl|n zmpAWoGw&DbIdQT^Tj}N@O%Ds+=IH94C%Q8mw5js9A}_JzP5YATaQEefRy#5xun6DEm2RYX-%n*S}0pH1a}X-Z)M?1{on zsz0A;y<3~{xtJH?o~)jUpe)3I@II5OG&3LO^!t_1VxMg#EN7&bE}4>Yd6o(#ubTv4 z3%y#(+JoGETACL-&&e86f?qJF$gU-8X@XVtBHL%l5sGsDCh@b|c5Uayl6*MUGvyNc zoDg+;;K!@5Rx5Ro&6Y#+_S!fBS*xD?n&%zVYR-1Rnt;Y2lVj>VOww-b|h+S<8- ziR9Ph|2v=7*wDa6f0``-uIQRK&6LNnGPED*N~kM%9V_2$9^HA7h4<4R^_ki+?|M)s zhI0-MiY$8P3fqHV!Fftxu4>d#BbO==%EC55Ida%0c$Ye|PgvBy3M_=YN=#VPc45zo zScx6achJNA8;BJ)4MCs>)=m@jtL)mn{=}!v2iDitVvo5GW!&2Abw4{1Y-;G|nZWXh zeE3JCtQ8a*r`U?L;E#l%F8iH?w{$yC)=~5K$bmZ$uV4eT3H91FF_KBb(%y9(S>Rng zm9v-U@yaQWs7+mwZxlEa>~Ca4a^PCgJIkJ<@nK&=>~fMT_u z4z&@FE@%uFBzUC>PH53@S9W(RAu?;b!mxw;Z`OoANi^*=GG}(4W4Ge{(w0keQh+at6&V#cyR$tZg2P6FEn& zb}O4k@ldS2(W!kQ5j3w_kC*$*XTGW5{QI&miZvSNqgbfv)2{XsM|f~p7*;^iO~CZ4 zfYX*Mo2luwFf*-V#%o;UZ#-0!u)K3c+aDqe%g>%S1Zt14`|&9t%gIhkVMH4_pYa5Z z\n" +"Language-Team: Spanish (Spain) \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" diff --git a/languages/robotstxt-mediaaudit.pot b/languages/robotstxt-mediaaudit.pot new file mode 100644 index 0000000..3109190 --- /dev/null +++ b/languages/robotstxt-mediaaudit.pot @@ -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 \n" +"Language-Team: LANGUAGE \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 "" diff --git a/messages.mo b/messages.mo new file mode 100644 index 0000000000000000000000000000000000000000..3c3d11f47365ee9504e67cdaeecc03d7d6568578 GIT binary patch literal 7894 zcmbuDTWn=ldB;}^G~l)g4I#8m3#(*|?M%+`g^&c3o5%K~2gZ!CJ(HoNE!$_WWAD*E zd!Ou!#~uaBV+!p+-Gd^8dc&AK&`MKQ~+*+HcX$y}_722A}*Tez>l^*_gM1Uj%Oj zUjTm{{0Hz?!JmLr;D3O>0{#zp3wYC8jQM5o9pKI2ouIxifg1NPsBs(M zDNyVEB`CR{0~f(>gOd9{>+jkOTQj%Tcn7F;Pl1y2G$^^=3*G@f3aWn_l>9OH4)D|9 zY4ESW-vGZ4YMq~gUGTp^$?e#z-aiRy+-GY15{O9Vn;@z%-vt?BUIwMtAAzgjzt!)X zy!=g`m%&-^0yqtR5|mtj4Q_zn0X6SiS#%A&1Jt}92erQ#)cy)kdVUII3G=6*_W5-E z{Y#+q@*=4H{T=ur@L#|S;3+xb!dyTGg9eW3jO>!9ND8=&;|kKhdW!}|UIf|BEAHY5E^gVI9_)cDOB zFM`_F8u(7|pFl)2uY>A$6O%s#-Ue!a+n~mUp!WGBDE)p0)Vf~) zCFeCz^E?Bd0>1^y&OZTV_t!w}V~WY%2QGoUGW($V{So+i@YA65|8A6~_0EAB|FIg6 zKvZBp4{F?3LH^8({4mtK3|<0%3aZ}3`YOI3 z(gPRWeLPBLUDp3rTvFfq^>u%S7S=D9z;pGzK(aPyAJ#*C$^NqW;FA3|Xzv-k0N+*L z?}FR3MVj_^k@gYVdD`)7l^602#m_WNcDjcqJxX`?(LPRdv=y3oa)I`Tv^ARIS~izo z4=(9a*B?}19tPzfLH&Fe_$clDwBM`WJyN6iU)Kj|ioFMDAE3z|zeAHBv}y7$`Ti#D z_i3`{dufl+PSJFIl(t1XOOt;n7M5v)>kZtTra_1Px|d)1;e#~!nSApWntc9ontXkS zCQkW9+V9fj$7VXT-Jodi4{uzq+axXQkt=MJgznIVdezI)gD7;__`Uj0PusLpbu_a{ zYKy3d-JCgXHFcV~S3mV-v$EN;tC>an;U^CBq9Tn|MN zwD-FVv#m9AMuOVy=&Cbkj%?^UK^Yf!+b}2s8|4V-a+e*r&<35tWwuPBgqh;FY1flr zH+Htzr(2rDM>gu%qqMYF0%i_uJ9a^0GuKTIg1Bi`<1}|>HBEAtY&8jDyCLPEvf+#EHhbD~ zIXXK2cH8TBvMb8k8+&uCN3O}*X}2fQn=)-MG>tT$wq2gPuxZXZOoydys~hY&b2dWl zvY4ISJt|y2SG~Egbu^c;_tG-H$V}i>y{V2V^;qIcO^3^7N^Rm;4+L0Wx=dx~; zOFS&n!ZW-XF67$w@UiQ{D6ny~n+4gCE%ng`B?k7AB%#Hhs)3o4qc|H$Z#H*9hOZZ? zjPBa3mKIg;AZPC=i}v;j&O9%jUmUlM#!W)BG|dNtgNQI#4f24@zeZj_X` zyKV;*VrLg${nY2~U6^aywJM62?czeyY=HE?VU?Ia}%Em1$K_1 zgYbyihT-NTZEQZvg7)OtINodENAlg=d^jC2)wt)_jyB!2TV-Oe^lUc^tU{zQP@gQ< zOM7J;u=Qdev&uP}n$6R#CN8_X=tSHpljj=Z!%UjWiRG}2vDB_7J+H9Eh3hnSzTGaf z%q4`M*>s6$!feLn9zHwZ9j}Q^2-fHa+hJj>w>hBm%`(H`hN@YEP0dG2SHk*RlKx!O zjS4$Eu<1PZoHJYEN?cjoT`lKXc(yi2fM=(@La{Mtuk1sT(m~x54jyuzC}Rt6i9{Kl zG_xl}zkST@;wK6NCT!aCyU<`_I~gpiX(xt=wbR%gn{_8j){h)sa(#?V&PP(nY~6b` z?RH((CSlp2-ImKUEA}3$O2MQzBA8Ji%Z9n4MxSrYD(XfgHhnj426zMd(qszUm7?mX zB|V+^l{{ORZC8j_a#QpBj?!JlC)_BdlT<@o(FVzp&$&&rvyU4lq!m-|dO)i_+%F^= zVJh&M(|Cfb8KAnyG)ha=7lB4-p;lsNtIV#F0PepSxht}y@9rN4`RSu1<1qCy?gu#5 zh+GvGsGsywrSBfG(lq-;(aV?T=P!GXYgZK4gu;hkvUE2s^5U>)x_rL!ynNo%al;ev zydvN-KGkT2jf+)|Ubf?0Fv&W0vANJ(nA&nmy^W20FA5uH%Dud?lP=qVI9JjvR2h;t z(`;`(FMH}-uRcGNsNu!%QcP{0Z)~{(pXC~ao?Es{3rqJl7T(ubShNfGE#I?vdVy<7 z?A_SOa4je$&6e#XyW0mUO*}GC1?xY=pk%K^UyU6qlVvgi)et`UKs$IOjwIr!%d%|; zQ{wrNF4*u?%@z%L!?IQN!)}Pa57_p8K)Na(ys&e&asQ~FB<#4Xv7WS3vC*=W&(vHIZGyDd}U%-5HfdT_anEKN* zZ4g$km2q7ExsY0lPNS!~a1EV}XZp|j@?loh9%S8rwn*ba{o4Nci@?;?Mbf!y(lK6xr9!In+Ojvp8zy8hPy-!vY(zC^ab{NZ8n$vyt3uW8gWp zLk%izifnof{EX70E1|1Ss=jef^Ye=C)i{8ZYj4-hM53)L^OaEm`MOD(n<&7MGAZEx z?HLJNm(n_R$u`IKIjqMgo$>}sp{}lDx2b@k7*7?Bl;~B3j;{Jo>y0XGH z>c3Q3IIz3XZX6NHZU1E;QI}&=)a^f`^1UwVY-HQi1A0~&LH8MwE4CQ84>956R+S2F zM(WF%o6qnL*yWD3!_3LMW`Ytj#GL+6jK~2brSG*BD_Rht9rUkJ-CtMF_n(%8gz=a= z4a)jCc7DRwdLy+l`qu`5k`&VXj;Af<0Qt&pCLf!iUc%#8*c5Ecb6-86+rP#sK;tOF zAR-J=l%aO3vO?N{R$vX?jmUWHIDj1)?TN2C5=@wlLi95EWzv@Q%j1WH9sW5Wh0mNc zgN^wHdpGR_CHYeM#Qr)xj8Vghyl}&TXFbsmQUwX1rckP4>905*1~`~<3c*gT>Drv0 z_)o=2hYYDvXL1;L6bjoujg6;Frxh!M4n210HyDChNIEjeUKZfdl|8D{#`Om21sP@~ z4fz9yjuIms#>!6#?vE>bWv(F@_m~;JS zbqrzjfs0ezN@7kpS#>ZO)?VS1f_Nv8q(5S~Bq|#*wd`aM6jPm7PMG^fY{M#5u|7F* zOm5ddp^&)SN%9Z>QUsc!R?JwM%`1XqDmhyRy~nKn}Ne6#;QmeW=cBIURmYpDo2|Y|8EEg zyGogGX+BdHk*`LoI-v*nB8`7OQ1Iw~S4?$0@m1Qc2vWhz!As&3R4S^NcJ#v2=1fT{ zGCJr`ctAl2%87_q9GEl^(7zpLt6Eb%Dk-WnQeIZ|(s=d4(P`o}XABY)6d`dq&qt+P jvfF>Q`k#vqOd+fg*q~NHuYZky3Q(2}jw=73lhgkJu8f=l literal 0 HcmV?d00001 diff --git a/readme.txt b/readme.txt new file mode 100644 index 0000000..646b45e --- /dev/null +++ b/readme.txt @@ -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=] + +Run an external reverse image search (queues a batch): + + wp mra scan-external [--batch=] + += 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/) diff --git a/robotstxt-mediaaudit.php b/robotstxt-mediaaudit.php new file mode 100644 index 0000000..589cab1 --- /dev/null +++ b/robotstxt-mediaaudit.php @@ -0,0 +1,50 @@ +run(); +} + +add_action( 'plugins_loaded', 'robotstxt_mediaaudit_run' ); diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..b4afef9 --- /dev/null +++ b/uninstall.php @@ -0,0 +1,31 @@ +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' ); diff --git a/vendor/autoload.php b/vendor/autoload.php new file mode 100644 index 0000000..ebcfd49 --- /dev/null +++ b/vendor/autoload.php @@ -0,0 +1,25 @@ + + * Jordi Boggiano + * + * 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 + * @author Jordi Boggiano + * @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> + */ + private $prefixLengthsPsr4 = array(); + /** + * @var array> + */ + private $prefixDirsPsr4 = array(); + /** + * @var list + */ + 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>> + */ + private $prefixesPsr0 = array(); + /** + * @var list + */ + private $fallbackDirsPsr0 = array(); + + /** @var bool */ + private $useIncludePath = false; + + /** + * @var array + */ + private $classMap = array(); + + /** @var bool */ + private $classMapAuthoritative = false; + + /** + * @var array + */ + private $missingClasses = array(); + + /** @var string|null */ + private $apcuPrefix; + + /** + * @var array + */ + private static $registeredLoaders = array(); + + /** + * @param string|null $vendorDir + */ + public function __construct($vendorDir = null) + { + $this->vendorDir = $vendorDir; + self::initializeIncludeClosure(); + } + + /** + * @return array> + */ + public function getPrefixes() + { + if (!empty($this->prefixesPsr0)) { + return call_user_func_array('array_merge', array_values($this->prefixesPsr0)); + } + + return array(); + } + + /** + * @return array> + */ + public function getPrefixesPsr4() + { + return $this->prefixDirsPsr4; + } + + /** + * @return list + */ + public function getFallbackDirs() + { + return $this->fallbackDirsPsr0; + } + + /** + * @return list + */ + public function getFallbackDirsPsr4() + { + return $this->fallbackDirsPsr4; + } + + /** + * @return array Array of classname => path + */ + public function getClassMap() + { + return $this->classMap; + } + + /** + * @param array $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 $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 $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 $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 $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 + */ + 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); + } +} diff --git a/vendor/composer/InstalledVersions.php b/vendor/composer/InstalledVersions.php new file mode 100644 index 0000000..51e734a --- /dev/null +++ b/vendor/composer/InstalledVersions.php @@ -0,0 +1,359 @@ + + * Jordi Boggiano + * + * 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}|array{}|null + */ + private static $installed; + + /** + * @var bool|null + */ + private static $canGetVendors; + + /** + * @var array[] + * @psalm-var array}> + */ + 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 + */ + 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 + */ + 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} + */ + 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}> + */ + 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} $data + */ + public static function reload($data) + { + self::$installed = $data; + self::$installedByVendor = array(); + } + + /** + * @return array[] + * @psalm-return list}> + */ + 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} $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} $required */ + $required = require __DIR__ . '/installed.php'; + self::$installed = $required; + } else { + self::$installed = array(); + } + } + + if (self::$installed !== array()) { + $installed[] = self::$installed; + } + + return $installed; + } +} diff --git a/vendor/composer/LICENSE b/vendor/composer/LICENSE new file mode 100644 index 0000000..62ecfd8 --- /dev/null +++ b/vendor/composer/LICENSE @@ -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. diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php new file mode 100644 index 0000000..d7e255c --- /dev/null +++ b/vendor/composer/autoload_classmap.php @@ -0,0 +1,29 @@ + $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', +); diff --git a/vendor/composer/autoload_namespaces.php b/vendor/composer/autoload_namespaces.php new file mode 100644 index 0000000..15a2ff3 --- /dev/null +++ b/vendor/composer/autoload_namespaces.php @@ -0,0 +1,9 @@ + array($baseDir . '/includes'), +); diff --git a/vendor/composer/autoload_real.php b/vendor/composer/autoload_real.php new file mode 100644 index 0000000..b777424 --- /dev/null +++ b/vendor/composer/autoload_real.php @@ -0,0 +1,36 @@ +register(true); + + return $loader; + } +} diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php new file mode 100644 index 0000000..7b57174 --- /dev/null +++ b/vendor/composer/autoload_static.php @@ -0,0 +1,55 @@ + + 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); + } +} diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json new file mode 100644 index 0000000..f20a6c4 --- /dev/null +++ b/vendor/composer/installed.json @@ -0,0 +1,5 @@ +{ + "packages": [], + "dev": false, + "dev-package-names": [] +} diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php new file mode 100644 index 0000000..446e747 --- /dev/null +++ b/vendor/composer/installed.php @@ -0,0 +1,23 @@ + 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, + ), + ), +);