commit 05d5c4016f3de79335c1a7a41c90bad8556307b9 Author: black_zero Date: Sun Mar 1 21:26:34 2026 +0800 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbc6d7d --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# Star Manager + +[中文文档 (Simplified Chinese)](./README.zh-CN.md) + +Star Manager is a local-first web app for organizing GitHub starred repositories with Star Lists and optional LLM-assisted classification. + +## Why Star Manager + +If you have hundreds of starred repositories, manual list management is slow and inconsistent. Star Manager helps you: + +- Sync your starred repositories and Star Lists from GitHub. +- Classify repositories in batches with an OpenAI-compatible LLM. +- Review diffs before any write-back. +- Apply updates to GitHub with explicit progress and issue reporting. +- Keep your working data in browser local storage (IndexedDB via Dexie). + +## Key Capabilities + +- GitHub sync with progress: + - Fetches starred repositories and Star Lists. + - Scans list memberships. + - Supports retry for failed list membership scans. +- Classification pipeline: + - Two-stage tagging (repo tagging + tag compression). + - Test mode (small sample) and strict single-tag mode. + - Diff view against existing Star Lists or a previous classification run. +- Write-back workflow: + - Preview planned changes (`Current` vs `After`) before apply. + - Creates missing lists when needed during apply. + - Optional re-plan before apply. + - Confirms behavior for repos not currently starred. +- Local editing: + - Assign list memberships per repo in UI before write-back. +- i18n: + - English/Chinese UI with browser language auto-detection and manual override. + +## Prerequisites + +- Node.js 20+ (LTS recommended) +- `pnpm` +- A GitHub Personal Access Token (PAT) +- Optional: an OpenAI-compatible LLM endpoint for classification + +## Quick Start + +```bash +pnpm install +pnpm dev +``` + +Build and preview: + +```bash +pnpm build +pnpm preview +``` + +## Configuration + +### 1) GitHub PAT + +In the app: + +1. Open `Settings` or `Connect PAT`. +2. Paste your token. +3. Validate and save. + +The token must be able to read your starred repositories and access Star Lists operations for your account. If validation fails, check token scopes/permissions and account feature availability. + +### 2) LLM (Optional) + +In `Settings -> LLM Configuration`, set: + +- `baseUrl` (default: `https://api.openai.com/v1`) +- `apiKey` +- `model` (default: `gpt-4o-mini`) +- `temperature` +- `maxTokens` + +You can run classification without changing prompts, or customize prompts for: + +- default tagging mode +- strict single-tag mode +- existing-list constrained modes +- English/Chinese prompt variants + +## Typical Workflow + +1. Connect PAT. +2. Click `Sync Star Lists`. +3. (Optional) Retry failed list membership scans from the status panel. +4. Open `Run Classification` and run LLM classification. +5. Review preview and diffs. +6. Open `Apply Updates` to preview planned GitHub changes. +7. Apply updates to GitHub. + +## Project Structure + +```text +src/app/ + App.tsx # Main UI shell + core/ # Use-cases and orchestration + services/ # GitHub + LLM clients + data/ # Dexie DB and reactive query helpers + store/ # Local preferences and LLM config stores + ui/ # UI components and modals + i18n/ # Translation resources and language helpers + styles/ # CSS styles +``` + +## Data & Privacy + +- Local-first storage: + - App data is stored in browser IndexedDB (`star-manager` database). + - Preferences and LLM config are stored in browser local storage. +- PAT and LLM API key are stored locally in your browser. +- The app calls GitHub and your configured LLM endpoint directly from the client. +- Sensitive values should not be logged; avoid sharing browser storage exports. + +## Troubleshooting + +- `Star Lists API not available for this token/account` + - Your account or token may not have access to Star Lists GraphQL fields. +- PAT validation failed + - Re-check token value and permissions; regenerate token if needed. +- LLM test/classification fails + - Verify `baseUrl`, `apiKey`, `model`, and endpoint compatibility with `/chat/completions`. +- No repos shown after sync + - Confirm your account has starred repositories and sync completed successfully. + +## Known Limitations & Roadmap + +Planned next steps include: + +- Incremental sync strategy to reduce repeated work. +- Better rate-limit handling and failure recovery. +- Clearer error attribution (permission/token/network categories). +- README cache policy implementation (`ETag/hash` and truncation strategy). +- Future quality scripts (`lint`/`test`) are not configured yet. + +## Contributing + +Contributions are welcome. For substantial changes, open an issue first to discuss scope. + +Local commands: + +```bash +pnpm dev +pnpm build +pnpm preview +``` + +## License + +No `LICENSE` file is published yet. +Until a license is added, redistribution and commercial use terms are not explicitly granted. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..ff690f5 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,156 @@ +# Star Manager + +[English README](./README.md) + +Star Manager 是一个本地优先(local-first)的 Web 应用,用于整理你的 GitHub Star 仓库与 Star Lists,并支持可选的 LLM 辅助分类。 + +## 为什么使用 Star Manager + +当 Star 仓库数量变多后,手工维护列表效率低且容易不一致。Star Manager 可以帮助你: + +- 从 GitHub 同步 Star 仓库与 Star Lists。 +- 使用 OpenAI 兼容接口批量分类仓库。 +- 在写回前查看变更差异预览。 +- 将更新写回 GitHub,并查看进度与失败原因。 +- 将工作数据保存在浏览器本地(Dexie + IndexedDB)。 + +## 核心能力 + +- GitHub 同步与进度反馈: + - 拉取 Star 仓库与 Star Lists。 + - 扫描列表成员关系。 + - 失败列表支持重试。 +- 分类流程: + - 两阶段分类(仓库打标 + 标签压缩)。 + - 支持测试模式(小样本)与单标签严格模式。 + - 支持与现有 Star Lists 或历史分类运行结果对比。 +- 写回流程: + - 应用前预览仓库 `Current` 与 `After` 列表差异。 + - 写回时可自动创建缺失列表。 + - 支持“应用前重新规划”开关。 + - 对未 Star 仓库提供确认逻辑。 +- 本地编辑: + - 支持在 UI 中为单个仓库手动调整列表归属。 +- 国际化: + - 支持中英 UI,自动识别浏览器语言,也可手动覆盖。 + +## 运行前准备 + +- Node.js 20+(建议 LTS) +- `pnpm` +- GitHub Personal Access Token(PAT) +- 可选:OpenAI 兼容的 LLM 服务端点(用于分类) + +## 快速开始 + +```bash +pnpm install +pnpm dev +``` + +构建与预览: + +```bash +pnpm build +pnpm preview +``` + +## 配置说明 + +### 1)GitHub PAT + +在应用中: + +1. 打开 `Settings` 或 `Connect PAT`。 +2. 粘贴 PAT。 +3. 校验并保存。 + +该 PAT 需要能够读取你的 Star 仓库,并具备对应 Star Lists 操作能力。若校验失败,请优先检查 token 权限范围与账号功能可用性。 + +### 2)LLM(可选) + +在 `Settings -> LLM Configuration` 中配置: + +- `baseUrl`(默认:`https://api.openai.com/v1`) +- `apiKey` +- `model`(默认:`gpt-4o-mini`) +- `temperature` +- `maxTokens` + +可以直接使用默认 Prompt,也可以按需自定义: + +- 默认打标模式 +- 严格单标签模式 +- 基于 Existing Lists 的约束模式 +- 中英文 Prompt 变体 + +## 典型使用流程 + +1. 连接 PAT。 +2. 点击 `Sync Star Lists`。 +3. (可选)在状态面板重试失败的列表成员扫描。 +4. 打开 `Run Classification` 执行分类。 +5. 查看分类预览与差异视图。 +6. 打开 `Apply Updates` 查看将写回 GitHub 的变更。 +7. 执行写回。 + +## 项目结构 + +```text +src/app/ + App.tsx # 主界面 + core/ # 用例与编排逻辑 + services/ # GitHub / LLM 客户端 + data/ # Dexie 数据层与响应式查询辅助 + store/ # 偏好设置与 LLM 配置存储 + ui/ # UI 组件与弹窗 + i18n/ # 国际化资源与语言处理 + styles/ # 样式文件 +``` + +## 数据与隐私 + +- 本地优先存储: + - 应用数据保存在浏览器 IndexedDB(数据库名:`star-manager`)。 + - 偏好设置与 LLM 配置保存在浏览器 localStorage。 +- PAT 与 LLM API Key 存储在本地浏览器环境中。 +- 应用直接从前端调用 GitHub 与你配置的 LLM 服务。 +- 请避免导出或分享包含敏感字段的浏览器存储数据。 + +## 常见问题排查 + +- `Star Lists API not available for this token/account` + - 当前账号或 token 可能无法访问 Star Lists 的 GraphQL 字段。 +- PAT 校验失败 + - 请检查 token 内容、权限范围,必要时重新生成。 +- LLM 测试或分类失败 + - 检查 `baseUrl`、`apiKey`、`model` 以及 `/chat/completions` 兼容性。 +- 同步后没有仓库 + - 确认账号确实有 Star 仓库,并检查同步流程是否完成。 + +## 已知限制与路线图 + +短期规划包括: + +- 增量同步,减少重复拉取与重复计算。 +- 更完善的速率限制与失败恢复策略。 +- 更清晰的错误归因(权限、Token、网络)。 +- README 缓存策略落地(`ETag/hash` 与截断规则)。 +- 当前尚未配置 `lint` / `test` 脚本。 + +## 贡献 + +欢迎提交 Issue 和 PR。对于较大改动,建议先通过 Issue 讨论范围与方案。 + +本地常用命令: + +```bash +pnpm dev +pnpm build +pnpm preview +``` + +## 许可证 + +当前仓库尚未发布 `LICENSE` 文件。 +在补充许可证前,分发与商用授权条款尚未明确授予。 diff --git a/index.html b/index.html new file mode 100644 index 0000000..c353307 --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + + + Star Manager + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..bd25399 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "star-manager", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "dexie": "^4.0.7", + "i18next": "^25.5.2", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-i18next": "^16.1.0" + }, + "devDependencies": { + "@types/react": "^19.1.11", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.2", + "typescript": "~5.9.3", + "vite": "^7.3.1" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..2eae215 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1215 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + dexie: + specifier: ^4.0.7 + version: 4.3.0 + i18next: + specifier: ^25.5.2 + version: 25.8.13(typescript@5.9.3) + react: + specifier: ^19.1.1 + version: 19.2.4 + react-dom: + specifier: ^19.1.1 + version: 19.2.4(react@19.2.4) + react-i18next: + specifier: ^16.1.0 + version: 16.5.4(i18next@25.8.13(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + devDependencies: + '@types/react': + specifier: ^19.1.11 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.1.7 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^5.0.2 + version: 5.1.4(vite@7.3.1) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: ^7.3.1 + version: 7.3.1 + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rollup/rollup-android-arm-eabi@4.57.1': + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.57.1': + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.57.1': + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.57.1': + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.57.1': + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.57.1': + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.57.1': + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.57.1': + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.57.1': + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.57.1': + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@vitejs/plugin-react@5.1.4': + resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + baseline-browser-mapping@2.9.19: + resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + hasBin: true + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001769: + resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dexie@4.3.0: + resolution: {integrity: sha512-5EeoQpJvMKHe6zWt/FSIIuRa3CWlZeIl6zKXt+Lz7BU6RoRRLgX9dZEynRfXrkLcldKYCBiz7xekTEylnie1Ug==} + + electron-to-chromium@1.5.286: + resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + i18next@25.8.13: + resolution: {integrity: sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA==} + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-i18next@16.5.4: + resolution: {integrity: sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==} + peerDependencies: + i18next: '>= 25.6.2' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/runtime@7.28.6': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rollup/rollup-android-arm-eabi@4.57.1': + optional: true + + '@rollup/rollup-android-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-x64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.57.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.57.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.57.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.57.1': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/estree@1.0.8': {} + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@5.1.4(vite@7.3.1)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.1 + transitivePeerDependencies: + - supports-color + + baseline-browser-mapping@2.9.19: {} + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001769 + electron-to-chromium: 1.5.286 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + caniuse-lite@1.0.30001769: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dexie@4.3.0: {} + + electron-to-chromium@1.5.286: {} + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escalade@3.2.0: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + i18next@25.8.13(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.28.6 + optionalDependencies: + typescript: 5.9.3 + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + node-releases@2.0.27: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-i18next@16.5.4(i18next@25.8.13(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.28.6 + html-parse-stringify: 3.0.1 + i18next: 25.8.13(typescript@5.9.3) + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + typescript: 5.9.3 + + react-refresh@0.18.0: {} + + react@19.2.4: {} + + rollup@4.57.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 + fsevents: 2.3.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + source-map-js@1.2.1: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + typescript@5.9.3: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + + vite@7.3.1: + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + + void-elements@3.1.0: {} + + yallist@3.1.1: {} diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 0000000..a95346a --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,494 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { detectBrowserLanguage, resolveEffectiveLanguage } from "./i18n/language"; +import { retryListMembership, syncFromGitHub } from "./core/githubSync"; +import { db } from "./data/db"; +import { useLiveQuery } from "./data/useLiveQuery"; +import { validatePat } from "./services/githubAuth"; +import { usePreferenceStore } from "./store/preferences"; +import { ApplyUpdatesModal } from "./ui/ApplyUpdatesModal"; +import { AssignListModal } from "./ui/AssignListModal"; +import { FirstRunPrompt } from "./ui/FirstRunPrompt"; +import { LlmClassificationModal } from "./ui/LlmClassificationModal"; +import { PatModal } from "./ui/PatModal"; +import { SettingsModal } from "./ui/SettingsModal"; + +type RepoPreview = { + id: string; + name: string; + description: string; + tags: string[]; + language?: string | null; + stars?: number; + updatedAt?: string; +}; + +type SyncStatusCode = "idle" | "running" | "completed" | "failed" | "ready" | "retrying"; + +type SyncMessage = { + key: string; + values?: Record; +}; + +const previewRepos: RepoPreview[] = []; +const previewLists: { id: string; name: string; count: number }[] = [ + { id: "all", name: "", count: 0 }, +]; + +export default function App() { + const { t, i18n } = useTranslation(); + const { preferences, setReadmeOptIn, markOnboarded, setPatToken, setLastSyncedAt } = + usePreferenceStore(); + const [activeList, setActiveList] = useState("all"); + const [isPatModalOpen, setIsPatModalOpen] = useState(false); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isClassificationOpen, setIsClassificationOpen] = useState(false); + const [isApplyUpdatesOpen, setIsApplyUpdatesOpen] = useState(false); + const [assignRepo, setAssignRepo] = useState<{ id: string; name: string } | null>(null); + const [syncStatus, setSyncStatus] = useState("idle"); + const [syncMessage, setSyncMessage] = useState({ + key: "app.sync.detail.connectPat", + }); + const [syncError, setSyncError] = useState(""); + const [syncStage, setSyncStage] = useState("idle"); + const [syncCurrent, setSyncCurrent] = useState(0); + const [syncTotal, setSyncTotal] = useState(0); + const [syncFailed, setSyncFailed] = useState(0); + const [failedListIds, setFailedListIds] = useState([]); + const [languageFilter, setLanguageFilter] = useState("all"); + const [showUnlisted, setShowUnlisted] = useState(false); + const [recentOnly, setRecentOnly] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + + const browserLanguage = useMemo(() => detectBrowserLanguage(), []); + const effectiveLanguage = useMemo( + () => resolveEffectiveLanguage(preferences.uiLanguage, browserLanguage), + [preferences.uiLanguage, browserLanguage] + ); + + useEffect(() => { + void i18n.changeLanguage(effectiveLanguage); + document.documentElement.lang = effectiveLanguage; + }, [i18n, effectiveLanguage]); + + useEffect(() => { + if (!preferences.hasCompletedOnboarding) { + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = ""; + }; + } + return undefined; + }, [preferences.hasCompletedOnboarding]); + + const lists = useLiveQuery( + async () => { + const listRows = await db.lists.toArray(); + const repoListRows = await db.repoLists.toArray(); + const countMap = new Map(); + for (const row of repoListRows) { + for (const listId of row.listIds) { + countMap.set(listId, (countMap.get(listId) ?? 0) + 1); + } + } + + const listCounts = listRows.map((list) => ({ + id: list.id, + name: list.name, + count: countMap.get(list.id) ?? 0, + })); + const total = await db.repos.count(); + return [{ id: "all", name: "", count: total }, ...listCounts]; + }, + [], + previewLists + ); + + const repos = useLiveQuery( + async () => { + const repoRows = await db.repos.toArray(); + const listMap = new Map(); + const listRows = await db.lists.toArray(); + for (const list of listRows) listMap.set(list.id, list.name); + + const repoListRows = await db.repoLists.toArray(); + const repoListLookup = new Map(repoListRows.map((row) => [row.repoId, row.listIds])); + + return repoRows.map((repo) => ({ + id: repo.id, + name: repo.fullName, + description: repo.description || "", + tags: (repoListLookup.get(repo.id) || []) + .map((listId) => listMap.get(listId)) + .filter((name): name is string => Boolean(name)) + .slice(0, 4), + language: repo.language, + stars: repo.stargazerCount, + updatedAt: repo.updatedAt, + })); + }, + [], + previewRepos + ); + + const languageOptions = useMemo(() => { + const set = new Set(); + for (const repo of repos) { + if (repo.language) set.add(repo.language); + } + return ["all", ...Array.from(set).sort()]; + }, [repos]); + + const visibleRepos = useMemo(() => { + if (activeList === "all") return repos; + const listName = lists.find((list) => list.id === activeList)?.name; + if (!listName) return []; + return repos.filter((repo) => repo.tags.includes(listName)); + }, [activeList, repos, lists]); + + const filteredRepos = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + const threshold = Date.now() - 1000 * 60 * 60 * 24 * 180; + return visibleRepos.filter((repo) => { + if (query) { + const haystack = `${repo.name} ${repo.description}`.toLowerCase(); + if (!haystack.includes(query)) return false; + } + if (languageFilter !== "all" && repo.language !== languageFilter) return false; + if (showUnlisted && repo.tags.length > 0) return false; + if (recentOnly && repo.updatedAt) { + const updatedAt = Date.parse(repo.updatedAt); + if (!Number.isNaN(updatedAt) && updatedAt < threshold) return false; + } + return true; + }); + }, [visibleRepos, languageFilter, showUnlisted, recentOnly, searchQuery]); + + const lastSyncText = useMemo(() => { + if (!preferences.lastSyncedAt) return ""; + const parsed = Date.parse(preferences.lastSyncedAt); + if (Number.isNaN(parsed)) return preferences.lastSyncedAt; + return new Date(parsed).toLocaleString(i18n.language); + }, [preferences.lastSyncedAt, i18n.language]); + + const syncDetail = useMemo(() => { + if (syncError) return syncError; + if (syncStatus === "running" || syncStatus === "retrying") { + const stageText = t(`progress.sync.${syncStage}`, { defaultValue: syncStage }); + return t("app.sync.detail.progress", { + stage: stageText, + current: syncCurrent, + total: syncTotal, + }); + } + return t(syncMessage.key, syncMessage.values); + }, [syncError, syncStatus, syncStage, syncCurrent, syncTotal, syncMessage, t]); + + useEffect(() => { + if (activeList === "all") return; + const exists = lists.some((list) => list.id === activeList); + if (!exists) setActiveList("all"); + }, [activeList, lists]); + + return ( +
+
+
+
+
+

{t("common.appName")}

+

{t("app.subtitle")}

+
+
+
+ + + +
+
+ +
+
+

{t("app.sections.starLists")}

+ {lists.length === 1 && lists[0].id === "all" ? ( +
+

{t("app.empty.noListsTitle")}

+

{t("app.empty.noListsHint")}

+
+ ) : ( + lists.map((list) => ( +
setActiveList(list.id)} + onKeyDown={(event) => { + if (event.key === "Enter") setActiveList(list.id); + }} + > + {list.id === "all" ? t("common.values.allStarred") : list.name} + {list.count} +
+ )) + )} +
+ +
+

{t("app.sections.repositories")}

+ {repos.length === 0 ? ( +
+

{t("app.empty.noDataTitle")}

+

{t("app.empty.noDataHint")}

+
+ ) : ( +
+
+ + +
+ + +
+ + setSearchQuery(event.target.value)} + placeholder={t("app.filters.searchPlaceholder")} + /> +
+
+ )} + {repos.length > 0 && filteredRepos.length === 0 ? ( +
+

{t("app.empty.noFilteredTitle")}

+

{t("app.empty.noFilteredHint")}

+
+ ) : repos.length === 0 ? null : ( +
+ {filteredRepos.map((repo) => ( +
+

{repo.name}

+

{repo.description || t("app.values.noDescription")}

+
+ {repo.tags.map((tag) => ( + + {tag} + + ))} +
+
+ {repo.language || t("common.labels.unknown")} + ★ {repo.stars ?? 0} +
+ +
+ ))} +
+ )} +
+ +
+
+

{t("app.sections.syncStatus")}

+

+ {t("common.labels.status")}: {t(`app.sync.status.${syncStatus}`, { defaultValue: syncStatus })} +

+

{syncDetail}

+

+ {t("common.labels.stage")}: {t(`progress.sync.${syncStage}`, { defaultValue: syncStage })} +

+

+ {t("common.labels.progress")}: {syncCurrent}/{syncTotal} · {t("common.labels.failed")}: {syncFailed} +

+ {preferences.lastSyncedAt ? ( +

+ {t("common.labels.lastSync")}: {lastSyncText} +

+ ) : null} + {failedListIds.length > 0 ? ( + + ) : null} +
+
+

{t("app.sections.llmClassification")}

+

{t("app.sidebar.llmDesc")}

+ + +
+
+

{t("app.sections.batchActions")}

+

{t("app.sidebar.batchDesc")}

+ +
+
+
+ + {!preferences.hasCompletedOnboarding ? ( + { + setReadmeOptIn(value); + markOnboarded(); + }} + onSkip={() => { + setReadmeOptIn(false); + markOnboarded(); + }} + /> + ) : null} + setIsPatModalOpen(false)} + onSave={async (token) => { + try { + const viewer = await validatePat(token); + setPatToken(token, viewer.login); + setSyncStatus("ready"); + setSyncError(""); + setSyncMessage({ key: "app.sync.detail.tokenOk", values: { login: viewer.login } }); + setIsPatModalOpen(false); + } catch (error) { + setSyncStatus("failed"); + setSyncError((error as Error).message || t("app.sync.detail.tokenValidationFailed")); + } + }} + /> + setIsSettingsOpen(false)} /> + setIsClassificationOpen(false)} + /> + setIsApplyUpdatesOpen(false)} + token={preferences.patToken} + onRequestToken={() => setIsPatModalOpen(true)} + /> + {assignRepo ? ( + setAssignRepo(null)} + /> + ) : null} +
+ ); +} diff --git a/src/app/core/githubSync.ts b/src/app/core/githubSync.ts new file mode 100644 index 0000000..50f79c9 --- /dev/null +++ b/src/app/core/githubSync.ts @@ -0,0 +1,161 @@ +import { db } from "../data/db"; +import { fetchListMembership, fetchStarLists, fetchStarredRepos, detectStarListApi } from "../services/githubStarLists"; +import type { GitHubConfig } from "../services/githubClient"; +import { runWithConcurrency } from "./queues"; + +export type SyncProgress = { + stage: string; + current: number; + total: number; + failed: number; +}; + +type ListMembershipResult = { + listId: string; + repoIds: string[]; + failed?: boolean; +}; + +export type SyncResult = { + lists: number; + repos: number; + failedListIds: string[]; +}; + +type RetryResult = { + failedListIds: string[]; +}; + +export async function syncFromGitHub( + config: GitHubConfig, + onProgress?: (progress: SyncProgress) => void +): Promise { + const listApi = await detectStarListApi(config); + + onProgress?.({ stage: "fetching_star_lists", current: 0, total: 1, failed: 0 }); + const lists = await fetchStarLists(config, listApi); + onProgress?.({ stage: "fetching_star_lists", current: 1, total: 1, failed: 0 }); + + onProgress?.({ stage: "fetching_starred_repos", current: 0, total: 1, failed: 0 }); + const repos = await fetchStarredRepos(config); + onProgress?.({ stage: "fetching_starred_repos", current: 1, total: 1, failed: 0 }); + + onProgress?.({ stage: "scanning_list_membership", current: 0, total: lists.length || 1, failed: 0 }); + let processed = 0; + let failed = 0; + const membershipTasks = lists.map((list) => async (): Promise => { + try { + const membership = await fetchListMembership(config, list.id); + processed += 1; + onProgress?.({ + stage: "scanning_list_membership", + current: processed, + total: lists.length || 1, + failed, + }); + return { ...membership, failed: false }; + } catch { + processed += 1; + failed += 1; + onProgress?.({ + stage: "scanning_list_membership", + current: processed, + total: lists.length || 1, + failed, + }); + return { listId: list.id, repoIds: [], failed: true }; + } + }); + + const memberships = await runWithConcurrency(membershipTasks, 2); + const failedListIds = memberships.filter((membership) => membership.failed).map((m) => m.listId); + + const repoListMap = new Map>(); + for (const membership of memberships) { + for (const repoId of membership.repoIds) { + const existing = repoListMap.get(repoId) ?? new Set(); + existing.add(membership.listId); + repoListMap.set(repoId, existing); + } + } + + await db.transaction("rw", db.lists, db.repos, db.repoLists, async () => { + await db.lists.clear(); + await db.repos.clear(); + await db.repoLists.clear(); + + await db.lists.bulkPut( + lists.map((list) => ({ + id: list.id, + name: list.name, + description: list.description ?? "", + isPrivate: false, + })) + ); + + await db.repos.bulkPut( + repos.map((repo) => ({ + id: repo.id, + fullName: repo.fullName, + description: repo.description ?? "", + repoUrl: repo.url, + topics: repo.topics, + language: repo.language, + updatedAt: repo.updatedAt, + readmeExcerpt: "", + stargazerCount: repo.stargazerCount, + })) + ); + + await db.repoLists.bulkPut( + Array.from(repoListMap.entries()).map(([repoId, listIds]) => ({ + repoId, + listIds: Array.from(listIds), + })) + ); + }); + + return { lists: lists.length, repos: repos.length, failedListIds }; +} + +export async function retryListMembership( + config: GitHubConfig, + listIds: string[], + onProgress?: (progress: SyncProgress) => void +): Promise { + if (listIds.length === 0) return { failedListIds: [] }; + + let processed = 0; + let failed = 0; + onProgress?.({ stage: "retrying_list_membership", current: 0, total: listIds.length, failed: 0 }); + + const tasks = listIds.map((listId) => async () => { + try { + const membership = await fetchListMembership(config, listId); + processed += 1; + onProgress?.({ stage: "retrying_list_membership", current: processed, total: listIds.length, failed }); + return { listId, repoIds: membership.repoIds, failed: false }; + } catch { + processed += 1; + failed += 1; + onProgress?.({ stage: "retrying_list_membership", current: processed, total: listIds.length, failed }); + return { listId, repoIds: [], failed: true }; + } + }); + + const results = await runWithConcurrency(tasks, 2); + const failedListIds = results.filter((item) => item.failed).map((item) => item.listId); + + await db.transaction("rw", db.repoLists, async () => { + for (const membership of results) { + for (const repoId of membership.repoIds) { + const existing = await db.repoLists.get(repoId); + const listIdsForRepo = existing?.listIds ?? []; + const next = Array.from(new Set([...listIdsForRepo, membership.listId])); + await db.repoLists.put({ repoId, listIds: next }); + } + } + }); + + return { failedListIds }; +} diff --git a/src/app/core/githubWriteback.ts b/src/app/core/githubWriteback.ts new file mode 100644 index 0000000..34b364f --- /dev/null +++ b/src/app/core/githubWriteback.ts @@ -0,0 +1,607 @@ +import { db } from "../data/db"; +import type { GitHubConfig } from "../services/githubClient"; +import { + addStar, + buildRepoMembershipIndex, + createUserList, + detectStarListApi, + fetchStarLists, + getRepositoryMeta, + type UserListRef, + updateUserListsForItem, +} from "../services/githubStarLists"; +import { runWithConcurrency } from "./queues"; + +export type WritebackIssueReason = + | "empty_tags" + | "repo_not_found" + | "invalid_repo_name" + | "repo_meta_missing" + | "repo_meta_fetch_failed" + | "missing_list" + | "create_list_failed" + | "membership_scan_failed" + | "no_changes" + | "unstarred_requires_confirmation" + | "add_star_failed" + | "update_failed"; + +export type WritebackIssue = { + repoId?: string; + repoFullName?: string; + reason: WritebackIssueReason; + message: string; +}; + +export type WritebackCandidate = { + repoId: string; + repoFullName: string; + itemId: string; + viewerHasStarred: boolean; + targetTags: string[]; + targetListIds: string[]; + targetListNames: string[]; + currentListIds: string[]; + currentListNames: string[]; + finalListIds: string[]; + finalListNames: string[]; +}; + +export type WritebackPlan = { + totalClassifications: number; + validTaggedRepos: number; + actionableRepos: number; + unchangedRepos: number; + scanFailures: number; + missingLists: string[]; + createdLists: string[]; + unstarredActionableRepos: number; + skipped: WritebackIssue[]; + candidates: WritebackCandidate[]; +}; + +export type WritebackResult = { + plan: WritebackPlan; + applied: number; + failed: WritebackIssue[]; + skipped: WritebackIssue[]; + createdLists: string[]; + locallyUpdated: number; +}; + +export type WritebackProgress = { + stage: string; + current: number; + total: number; + failed: number; + skipped: number; +}; + +export type PlanWritebackOptions = { + createMissingLists?: boolean; + createMissingListsAsPrivate?: boolean; +}; + +export type ApplyWritebackOptions = { + autoStarForUnstarred?: boolean; + resolveAutoStarForUnstarred?: (unstarredCount: number) => boolean | Promise; + createMissingListsAsPrivate?: boolean; + reusePlan?: WritebackPlan | null; +}; + +type PreparedWriteback = { + plan: WritebackPlan; + listIdToName: Map; + createdListRefs: UserListRef[]; +}; + +const REPO_META_CONCURRENCY = 3; + +export async function planWriteback( + config: GitHubConfig, + options: PlanWritebackOptions = {}, + onProgress?: (progress: WritebackProgress) => void +): Promise { + const prepared = await prepareWriteback( + config, + options.createMissingLists ?? false, + options.createMissingListsAsPrivate, + onProgress + ); + return prepared.plan; +} + +export async function applyWriteback( + config: GitHubConfig, + options: ApplyWritebackOptions, + onProgress?: (progress: WritebackProgress) => void +): Promise { + const prepared = + options.reusePlan != null + ? { + plan: options.reusePlan, + listIdToName: new Map(), + createdListRefs: [], + } + : await prepareWriteback(config, true, options.createMissingListsAsPrivate, onProgress); + + return applyPreparedWriteback(config, prepared, options, onProgress); +} + +async function applyPreparedWriteback( + config: GitHubConfig, + prepared: PreparedWriteback, + options: ApplyWritebackOptions, + onProgress?: (progress: WritebackProgress) => void +): Promise { + const failed: WritebackIssue[] = []; + const skipped: WritebackIssue[] = [...prepared.plan.skipped]; + const successfulCandidates: WritebackCandidate[] = []; + let applied = 0; + let current = 0; + const total = prepared.plan.candidates.length; + let autoStarForUnstarred = options.autoStarForUnstarred ?? false; + + if (prepared.plan.unstarredActionableRepos > 0 && options.resolveAutoStarForUnstarred) { + autoStarForUnstarred = await options.resolveAutoStarForUnstarred( + prepared.plan.unstarredActionableRepos + ); + } + + emitProgress(onProgress, { + stage: "applying_updates", + current: 0, + total: total || 1, + failed: failed.length, + skipped: skipped.length, + }); + + for (const candidate of prepared.plan.candidates) { + if (!autoStarForUnstarred && !candidate.viewerHasStarred) { + skipped.push({ + repoId: candidate.repoId, + repoFullName: candidate.repoFullName, + reason: "unstarred_requires_confirmation", + message: "Repo is not starred and auto-star was declined.", + }); + current += 1; + emitProgress(onProgress, { + stage: "applying_updates", + current, + total: total || 1, + failed: failed.length, + skipped: skipped.length, + }); + continue; + } + + try { + if (autoStarForUnstarred && !candidate.viewerHasStarred) { + await addStar(config, candidate.itemId); + } + } catch (error) { + failed.push({ + repoId: candidate.repoId, + repoFullName: candidate.repoFullName, + reason: "add_star_failed", + message: `Failed to star repo before writeback: ${(error as Error).message || "unknown"}`, + }); + current += 1; + emitProgress(onProgress, { + stage: "applying_updates", + current, + total: total || 1, + failed: failed.length, + skipped: skipped.length, + }); + continue; + } + + try { + await updateUserListsForItem(config, candidate.itemId, candidate.finalListIds); + applied += 1; + successfulCandidates.push(candidate); + } catch (error) { + failed.push({ + repoId: candidate.repoId, + repoFullName: candidate.repoFullName, + reason: "update_failed", + message: `Failed to update repo lists: ${(error as Error).message || "unknown"}`, + }); + } + + current += 1; + emitProgress(onProgress, { + stage: "applying_updates", + current, + total: total || 1, + failed: failed.length, + skipped: skipped.length, + }); + } + + const locallyUpdated = await syncLocalWritebackState( + prepared, + successfulCandidates, + options.createMissingListsAsPrivate ?? false + ); + + return { + plan: prepared.plan, + applied, + failed, + skipped, + createdLists: prepared.plan.createdLists, + locallyUpdated, + }; +} + +async function prepareWriteback( + config: GitHubConfig, + createMissingLists: boolean, + createMissingListsAsPrivate = false, + onProgress?: (progress: WritebackProgress) => void +): Promise { + const skipped: WritebackIssue[] = []; + const createdLists: string[] = []; + const createdListRefs: UserListRef[] = []; + + emitProgress(onProgress, { + stage: "loading_local_classifications", + current: 0, + total: 1, + failed: 0, + skipped: 0, + }); + const [classificationRows, repoRows] = await Promise.all([db.classifications.toArray(), db.repos.toArray()]); + emitProgress(onProgress, { + stage: "loading_local_classifications", + current: 1, + total: 1, + failed: 0, + skipped: skipped.length, + }); + + const repoById = new Map(repoRows.map((repo) => [repo.id, repo])); + const repoTargetTags = new Map(); + const allTargetTags = new Set(); + + for (const row of classificationRows) { + const validTags = normalizeTags(row.tags); + if (validTags.length === 0) { + skipped.push({ + repoId: row.repoId, + reason: "empty_tags", + message: "Classification has no valid tags.", + }); + continue; + } + if (!repoById.has(row.repoId)) { + skipped.push({ + repoId: row.repoId, + reason: "repo_not_found", + message: "Repo is not present in local database.", + }); + continue; + } + repoTargetTags.set(row.repoId, validTags); + for (const tag of validTags) { + allTargetTags.add(tag); + } + } + + emitProgress(onProgress, { + stage: "fetching_star_lists", + current: 0, + total: 1, + failed: 0, + skipped: skipped.length, + }); + const listApi = await detectStarListApi(config); + const remoteLists = await fetchStarLists(config, listApi); + const listNameToId = new Map(remoteLists.map((list) => [list.name, list.id])); + const listIdToName = new Map(remoteLists.map((list) => [list.id, list.name])); + emitProgress(onProgress, { + stage: "fetching_star_lists", + current: 1, + total: 1, + failed: 0, + skipped: skipped.length, + }); + + const missingLists: string[] = []; + for (const tag of allTargetTags) { + if (!listNameToId.has(tag)) { + missingLists.push(tag); + } + } + + if (createMissingLists && missingLists.length > 0) { + let current = 0; + emitProgress(onProgress, { + stage: "creating_missing_lists", + current: 0, + total: missingLists.length, + failed: 0, + skipped: skipped.length, + }); + for (const listName of missingLists) { + try { + const created = await createUserList(config, listName, createMissingListsAsPrivate, ""); + listNameToId.set(created.name, created.id); + listIdToName.set(created.id, created.name); + createdLists.push(created.name); + createdListRefs.push(created); + } catch (error) { + skipped.push({ + reason: "create_list_failed", + message: `Failed to create list "${listName}": ${(error as Error).message || "unknown"}`, + }); + } + current += 1; + emitProgress(onProgress, { + stage: "creating_missing_lists", + current, + total: missingLists.length, + failed: 0, + skipped: skipped.length, + }); + } + } + + const resolvableRepoIds = Array.from(repoTargetTags.keys()); + const repoMetaByRepoId = new Map(); + let repoMetaCurrent = 0; + + emitProgress(onProgress, { + stage: "resolving_repositories", + current: 0, + total: resolvableRepoIds.length || 1, + failed: 0, + skipped: skipped.length, + }); + + const repoMetaTasks = resolvableRepoIds.map((repoId) => async () => { + const repo = repoById.get(repoId); + if (!repo) return; + const parsed = parseRepoFullName(repo.fullName); + if (!parsed) { + skipped.push({ + repoId, + repoFullName: repo.fullName, + reason: "invalid_repo_name", + message: `Invalid fullName format: ${repo.fullName}`, + }); + repoMetaCurrent += 1; + emitProgress(onProgress, { + stage: "resolving_repositories", + current: repoMetaCurrent, + total: resolvableRepoIds.length || 1, + failed: 0, + skipped: skipped.length, + }); + return; + } + + try { + const meta = await getRepositoryMeta(config, parsed.owner, parsed.name); + if (!meta) { + skipped.push({ + repoId, + repoFullName: repo.fullName, + reason: "repo_meta_missing", + message: "Repository metadata cannot be loaded from GitHub.", + }); + } else { + repoMetaByRepoId.set(repoId, { + itemId: meta.id, + viewerHasStarred: meta.viewerHasStarred, + repoFullName: repo.fullName, + }); + } + } catch (error) { + skipped.push({ + repoId, + repoFullName: repo.fullName, + reason: "repo_meta_fetch_failed", + message: `Repository metadata request failed: ${(error as Error).message || "unknown"}`, + }); + } + + repoMetaCurrent += 1; + emitProgress(onProgress, { + stage: "resolving_repositories", + current: repoMetaCurrent, + total: resolvableRepoIds.length || 1, + failed: 0, + skipped: skipped.length, + }); + }); + await runWithConcurrency(repoMetaTasks, REPO_META_CONCURRENCY); + + const targetRepoIds = new Set(); + for (const meta of repoMetaByRepoId.values()) { + targetRepoIds.add(meta.itemId); + } + + emitProgress(onProgress, { + stage: "scanning_existing_memberships", + current: 0, + total: listNameToId.size || 1, + failed: 0, + skipped: skipped.length, + }); + const repoMembershipResult = await buildRepoMembershipIndex( + config, + Array.from(listNameToId.values()), + targetRepoIds, + (progress) => { + emitProgress(onProgress, { + stage: "scanning_existing_memberships", + current: progress.current, + total: progress.total || 1, + failed: 0, + skipped: skipped.length, + }); + } + ); + const repoMembershipIndex = repoMembershipResult.index; + const scanFailures = repoMembershipResult.failedListIds.length; + for (const failedListId of repoMembershipResult.failedListIds) { + skipped.push({ + reason: "membership_scan_failed", + message: `Membership scan failed for list ${listIdToName.get(failedListId) || failedListId}.`, + }); + } + + const candidates: WritebackCandidate[] = []; + let unchangedRepos = 0; + let unstarredActionableRepos = 0; + + for (const [repoId, tags] of repoTargetTags.entries()) { + const meta = repoMetaByRepoId.get(repoId); + if (!meta) continue; + + const targetListIds: string[] = []; + let hasMissingList = false; + for (const tag of tags) { + const listId = listNameToId.get(tag); + if (!listId) { + hasMissingList = true; + break; + } + targetListIds.push(listId); + } + if (hasMissingList) { + skipped.push({ + repoId, + repoFullName: meta.repoFullName, + reason: "missing_list", + message: "One or more target lists do not exist on GitHub.", + }); + continue; + } + + const currentSet = repoMembershipIndex.get(meta.itemId) ?? new Set(); + const finalSet = new Set([...currentSet, ...targetListIds]); + const changed = finalSet.size !== currentSet.size; + + if (!changed) { + unchangedRepos += 1; + skipped.push({ + repoId, + repoFullName: meta.repoFullName, + reason: "no_changes", + message: "Repo already contains all target lists.", + }); + continue; + } + + if (!meta.viewerHasStarred) { + unstarredActionableRepos += 1; + } + + candidates.push({ + repoId, + repoFullName: meta.repoFullName, + itemId: meta.itemId, + viewerHasStarred: meta.viewerHasStarred, + targetTags: tags, + targetListIds: uniqueArray(targetListIds), + targetListNames: uniqueArray( + targetListIds.map((id) => listIdToName.get(id)).filter((name): name is string => Boolean(name)) + ), + currentListIds: Array.from(currentSet), + currentListNames: Array.from(currentSet) + .map((id) => listIdToName.get(id)) + .filter((name): name is string => Boolean(name)), + finalListIds: Array.from(finalSet), + finalListNames: Array.from(finalSet) + .map((id) => listIdToName.get(id)) + .filter((name): name is string => Boolean(name)), + }); + } + + return { + plan: { + totalClassifications: classificationRows.length, + validTaggedRepos: repoTargetTags.size, + actionableRepos: candidates.length, + unchangedRepos, + scanFailures, + missingLists, + createdLists, + unstarredActionableRepos, + skipped, + candidates, + }, + listIdToName, + createdListRefs, + }; +} + +async function syncLocalWritebackState( + prepared: PreparedWriteback, + successfulCandidates: WritebackCandidate[], + createMissingListsAsPrivate: boolean +): Promise { + const updates = successfulCandidates.map((candidate) => ({ + repoId: candidate.repoId, + listIds: candidate.finalListIds, + })); + + await db.transaction("rw", db.lists, db.repoLists, async () => { + if (prepared.createdListRefs.length > 0) { + await db.lists.bulkPut( + prepared.createdListRefs.map((list) => ({ + id: list.id, + name: list.name, + description: "", + isPrivate: createMissingListsAsPrivate, + })) + ); + } + + for (const update of updates) { + await db.repoLists.put(update); + } + }); + + return updates.length; +} + +function normalizeTags(tags: string[]): string[] { + const out: string[] = []; + const seen = new Set(); + + for (const raw of tags) { + const tag = raw.trim(); + if (!tag) continue; + if (tag.toLowerCase() === "unclassified") continue; + const key = tag.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(tag); + } + return out; +} + +function parseRepoFullName(fullName: string): { owner: string; name: string } | null { + const parts = fullName.split("/"); + if (parts.length !== 2) return null; + const owner = parts[0].trim(); + const name = parts[1].trim(); + if (!owner || !name) return null; + return { owner, name }; +} + +function uniqueArray(items: string[]): string[] { + return Array.from(new Set(items)); +} + +function emitProgress( + onProgress: ((progress: WritebackProgress) => void) | undefined, + progress: WritebackProgress +) { + onProgress?.(progress); +} diff --git a/src/app/core/llmClassification.ts b/src/app/core/llmClassification.ts new file mode 100644 index 0000000..c3a0c60 --- /dev/null +++ b/src/app/core/llmClassification.ts @@ -0,0 +1,224 @@ +import type { LLMConfig } from "../services/llmClient"; +import { requestCompletion } from "../services/llmClient"; +import { resolvePrompts } from "./llmPromptResolver"; +import { runWithConcurrency } from "./queues"; + +export type RepoForClassification = { + id: string; + fullName: string; + description: string; + topics: string[]; + language: string | null; + readmeExcerpt: string; + existingLists?: string[]; +}; + +export type ClassificationProgress = { + stage: string; + current: number; + total: number; +}; + +export type ClassificationResult = { + repoTags: Record; + compressedTags: string[]; + tagMap: Record; + runId: string; +}; + +export type ClassificationOptions = { + strictSingleTag?: boolean; + pass1Prompt?: string; + pass1StrictPrompt?: string; + pass2Prompt?: string; + useExistingLists?: boolean; + allowNewTagsWithExistingLists?: boolean; + language?: "zh" | "en"; +}; + +const DEFAULT_PASS2_PROMPT = + "You consolidate tag lists by merging synonyms and near-duplicates. Output only JSON mapping each original tag to a compressed tag. Do not invent new tags beyond the provided list."; + +export async function runTwoStageClassification( + config: LLMConfig, + repos: RepoForClassification[], + options: ClassificationOptions = {}, + onProgress?: (progress: ClassificationProgress) => void +): Promise { + const mode = { + strictSingleTag: options.strictSingleTag ?? false, + useExistingLists: options.useExistingLists ?? false, + allowNewTagsWithExistingLists: options.allowNewTagsWithExistingLists !== false, + language: options.language ?? "en", + } as const; + const prompts = resolvePrompts(config, mode); + const repoTags: Record = {}; + let completed = 0; + const runId = createRunId(); + + const tasks = repos.map((repo) => async () => { + const tags = await classifyRepo(config, repo, { + strictSingleTag: mode.strictSingleTag, + systemPrompt: prompts.selectedPass1, + useExistingLists: mode.useExistingLists, + allowNewTagsWithExistingLists: mode.allowNewTagsWithExistingLists, + }); + completed += 1; + onProgress?.({ stage: "classifying_repos", current: completed, total: repos.length }); + return { id: repo.id, tags }; + }); + + const results = await runWithConcurrency(tasks, 3); + const allTags: string[] = []; + for (const result of results) { + repoTags[result.id] = result.tags; + allTags.push(...result.tags); + } + + const uniqueTags = normalizeTags(allTags); + onProgress?.({ stage: "compressing_tags", current: 0, total: 1 }); + const tagMap = await compressTags(config, uniqueTags, prompts.pass2); + const compressedTags = Array.from(new Set(Object.values(tagMap))); + + for (const repoId of Object.keys(repoTags)) { + repoTags[repoId] = repoTags[repoId].map((tag) => tagMap[tag] ?? tag); + } + + onProgress?.({ stage: "compressing_tags", current: 1, total: 1 }); + return { repoTags, compressedTags, tagMap, runId }; +} + +async function classifyRepo( + config: LLMConfig, + repo: RepoForClassification, + options: { + strictSingleTag: boolean; + systemPrompt: string; + useExistingLists?: boolean; + allowNewTagsWithExistingLists?: boolean; + } +): Promise { + const userContent = [ + `Name: ${repo.fullName}`, + `Description: ${repo.description || ""}`, + `Topics: ${repo.topics.join(", ") || ""}`, + `Language: ${repo.language || ""}`, + `README: ${repo.readmeExcerpt.slice(0, 800)}`, + options.useExistingLists + ? `Existing Lists: ${repo.existingLists && repo.existingLists.length > 0 ? repo.existingLists.join(", ") : "(none)"}` + : "", + ] + .filter(Boolean) + .join("\n"); + + const response = await requestCompletion(config, [ + { role: "system", content: options.systemPrompt }, + { role: "user", content: userContent }, + ]); + + const tags = parseTags(response, options.strictSingleTag); + if (options.useExistingLists && options.allowNewTagsWithExistingLists === false) { + const existing = new Set((repo.existingLists || []).map((tag) => tag.toLowerCase())); + return tags.filter((tag) => existing.has(tag.toLowerCase())); + } + if (tags.length === 0) { + return ["Unclassified"]; + } + return tags; +} + +async function compressTags( + config: LLMConfig, + tags: string[], + pass2Prompt?: string +): Promise> { + if (tags.length === 0) return {}; + if (tags.length === 1) return { [tags[0]]: tags[0] }; + + const userContent = JSON.stringify({ tags }); + try { + const response = await requestCompletion(config, [ + { role: "system", content: pass2Prompt || DEFAULT_PASS2_PROMPT }, + { role: "user", content: userContent }, + ]); + return parseTagMap(response, tags); + } catch { + return Object.fromEntries(tags.map((tag) => [tag, tag])); + } +} + +function parseTags(response: string, strictSingleTag: boolean): string[] { + const trimmed = response.trim(); + if (!trimmed) return []; + + if (trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed) as string[]; + if (Array.isArray(parsed)) { + const normalized = normalizeTags(parsed); + return strictSingleTag ? normalized.slice(0, 1) : normalized; + } + } catch { + // fall through + } + } + + const parts = trimmed + .split(/[,\n]/) + .map((part) => part.replace(/^[-*\d.\s]+/, "").trim()) + .filter(Boolean); + const normalized = normalizeTags(parts); + return strictSingleTag ? normalized.slice(0, 1) : normalized; +} + +function parseTagMap(response: string, originals: string[]): Record { + const fallback = Object.fromEntries(originals.map((tag) => [tag, tag])); + const raw = extractJson(response); + if (!raw) return fallback; + + try { + const parsed = JSON.parse(raw) as Record | Array<{ from: string; to: string }>; + const map: Record = { ...fallback }; + if (Array.isArray(parsed)) { + for (const entry of parsed) { + if (!entry?.from || !entry?.to) continue; + map[entry.from.trim()] = entry.to.trim(); + } + return map; + } + for (const [key, value] of Object.entries(parsed)) { + if (!key || !value) continue; + map[key.trim()] = value.trim(); + } + return map; + } catch { + return fallback; + } +} + +function extractJson(text: string): string | null { + const start = text.indexOf("{"); + const end = text.lastIndexOf("}"); + if (start === -1 || end === -1 || end <= start) return null; + return text.slice(start, end + 1); +} + +function normalizeTags(tags: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const tag of tags) { + const cleaned = tag.trim(); + if (!cleaned) continue; + const key = cleaned.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + result.push(cleaned); + } + return result; +} + +function createRunId(): string { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const random = Math.random().toString(36).slice(2, 8); + return `run-${stamp}-${random}`; +} diff --git a/src/app/core/llmPromptResolver.ts b/src/app/core/llmPromptResolver.ts new file mode 100644 index 0000000..541ab4f --- /dev/null +++ b/src/app/core/llmPromptResolver.ts @@ -0,0 +1,160 @@ +import type { LLMConfig } from "../services/llmClient"; + +export type PromptLanguage = "zh" | "en"; + +export type PromptFieldKey = + | "pass1Prompt" + | "pass1StrictPrompt" + | "pass2Prompt" + | "pass1PromptWithExisting" + | "pass1PromptNoNewWithExisting" + | "pass1StrictPromptWithExisting" + | "pass1StrictNoNewWithExisting" + | "pass2PromptWithExisting" + | "pass1PromptZh" + | "pass1StrictPromptZh" + | "pass2PromptZh" + | "pass1PromptWithExistingZh" + | "pass1PromptNoNewWithExistingZh" + | "pass1StrictPromptWithExistingZh" + | "pass1StrictNoNewWithExistingZh" + | "pass2PromptWithExistingZh"; + +type PromptFieldSet = { + pass1: PromptFieldKey; + pass1Strict: PromptFieldKey; + pass2: PromptFieldKey; +}; + +export type PromptMode = { + useExistingLists: boolean; + allowNewTagsWithExistingLists: boolean; + strictSingleTag: boolean; + language: PromptLanguage; +}; + +export type ResolvedPrompts = { + pass1: string; + pass1Strict: string; + pass2: string; + selectedPass1: string; + activeKeys: PromptFieldSet; + inactiveKeys: PromptFieldKey[]; +}; + +const DEFAULT_PASS1_PROMPT = + "You label GitHub repositories with 1-3 concise tags. Output only comma-separated tags."; +const DEFAULT_PASS1_STRICT_PROMPT = + "You label GitHub repositories with exactly 1 concise tag. Output only the tag text."; +const DEFAULT_PASS2_PROMPT = + "You consolidate tag lists by merging synonyms and near-duplicates. Output only JSON mapping each original tag to a compressed tag. Do not invent new tags beyond the provided list."; + +const EN_PROMPT_FIELDS: PromptFieldKey[] = [ + "pass1Prompt", + "pass1StrictPrompt", + "pass2Prompt", + "pass1PromptWithExisting", + "pass1PromptNoNewWithExisting", + "pass1StrictPromptWithExisting", + "pass1StrictNoNewWithExisting", + "pass2PromptWithExisting", +]; + +const ZH_PROMPT_FIELDS: PromptFieldKey[] = [ + "pass1PromptZh", + "pass1StrictPromptZh", + "pass2PromptZh", + "pass1PromptWithExistingZh", + "pass1PromptNoNewWithExistingZh", + "pass1StrictPromptWithExistingZh", + "pass1StrictNoNewWithExistingZh", + "pass2PromptWithExistingZh", +]; + +export function resolvePrompts(config: LLMConfig, mode: PromptMode): ResolvedPrompts { + const activeKeys = resolvePromptKeys(mode); + const pass1 = readPrompt(config, activeKeys.pass1, DEFAULT_PASS1_PROMPT); + const pass1Strict = readPrompt(config, activeKeys.pass1Strict, DEFAULT_PASS1_STRICT_PROMPT); + const pass2 = readPrompt(config, activeKeys.pass2, DEFAULT_PASS2_PROMPT); + const selectedPass1 = mode.strictSingleTag ? pass1Strict : pass1; + const inactiveKeys = getPromptFields(mode.language).filter( + (key) => + key !== activeKeys.pass1 && key !== activeKeys.pass1Strict && key !== activeKeys.pass2 + ); + + return { + pass1, + pass1Strict, + pass2, + selectedPass1, + activeKeys, + inactiveKeys, + }; +} + +function resolvePromptKeys(mode: PromptMode): PromptFieldSet { + if (mode.language === "zh") { + return resolveZhPromptKeys(mode); + } + return resolveEnPromptKeys(mode); +} + +function resolveEnPromptKeys(mode: PromptMode): PromptFieldSet { + if (!mode.useExistingLists) { + return { + pass1: "pass1Prompt", + pass1Strict: "pass1StrictPrompt", + pass2: "pass2Prompt", + }; + } + + if (mode.allowNewTagsWithExistingLists) { + return { + pass1: "pass1PromptWithExisting", + pass1Strict: "pass1StrictPromptWithExisting", + pass2: "pass2PromptWithExisting", + }; + } + + return { + pass1: "pass1PromptNoNewWithExisting", + pass1Strict: "pass1StrictNoNewWithExisting", + pass2: "pass2PromptWithExisting", + }; +} + +function resolveZhPromptKeys(mode: PromptMode): PromptFieldSet { + if (!mode.useExistingLists) { + return { + pass1: "pass1PromptZh", + pass1Strict: "pass1StrictPromptZh", + pass2: "pass2PromptZh", + }; + } + + if (mode.allowNewTagsWithExistingLists) { + return { + pass1: "pass1PromptWithExistingZh", + pass1Strict: "pass1StrictPromptWithExistingZh", + pass2: "pass2PromptWithExistingZh", + }; + } + + return { + pass1: "pass1PromptNoNewWithExistingZh", + pass1Strict: "pass1StrictNoNewWithExistingZh", + pass2: "pass2PromptWithExistingZh", + }; +} + +function getPromptFields(language: PromptLanguage): PromptFieldKey[] { + return language === "zh" ? ZH_PROMPT_FIELDS : EN_PROMPT_FIELDS; +} + +function readPrompt(config: LLMConfig, key: PromptFieldKey, fallback: string): string { + const value = config[key]; + if (typeof value === "string" && value.trim()) { + return value; + } + return fallback; +} diff --git a/src/app/core/queues.ts b/src/app/core/queues.ts new file mode 100644 index 0000000..655b5d6 --- /dev/null +++ b/src/app/core/queues.ts @@ -0,0 +1,26 @@ +export type QueueTask = () => Promise; + +export async function runWithConcurrency(tasks: QueueTask[], concurrency: number): Promise { + const results: T[] = []; + const executing = new Set>(); + let index = 0; + + const runNext = async () => { + const current = index; + index += 1; + if (current >= tasks.length) return; + const value = await tasks[current](); + results[current] = value; + await runNext(); + }; + + while (index < tasks.length && executing.size < concurrency) { + const runner = runNext().then(() => { + executing.delete(runner); + }); + executing.add(runner); + } + + await Promise.all(executing); + return results; +} diff --git a/src/app/core/repoListAssignments.ts b/src/app/core/repoListAssignments.ts new file mode 100644 index 0000000..482383a --- /dev/null +++ b/src/app/core/repoListAssignments.ts @@ -0,0 +1,11 @@ +import { db } from "../data/db"; + +export async function setRepoListMembership(repoId: string, listIds: string[]): Promise { + const uniqueListIds = Array.from(new Set(listIds.map((id) => id.trim()).filter(Boolean))); + await db.transaction("rw", db.repoLists, async () => { + await db.repoLists.put({ + repoId, + listIds: uniqueListIds, + }); + }); +} diff --git a/src/app/core/types.ts b/src/app/core/types.ts new file mode 100644 index 0000000..a0438fd --- /dev/null +++ b/src/app/core/types.ts @@ -0,0 +1,23 @@ +export type RepoInfo = { + id: string; + fullName: string; + description: string; + url: string; + topics: string[]; + language: string | null; + updatedAt: string; + readmeExcerpt: string; +}; + +export type StarList = { + id: string; + name: string; + description: string; + isPrivate: boolean; +}; + +export type ClassificationResult = { + repoId: string; + listName: string; + confidence: number; +}; diff --git a/src/app/data/db.ts b/src/app/data/db.ts new file mode 100644 index 0000000..bac5672 --- /dev/null +++ b/src/app/data/db.ts @@ -0,0 +1,109 @@ +import Dexie, { type Table } from "dexie"; + +export type RepoRecord = { + id: string; + fullName: string; + description: string; + repoUrl: string; + topics: string[]; + language: string | null; + updatedAt: string; + readmeExcerpt: string; + stargazerCount: number; +}; + +export type ClassificationRecord = { + repoId: string; + tags: string[]; + lastRunAt: string; +}; + +export type ClassificationRunRecord = { + id: string; + createdAt: string; + repoCount: number; + strictSingleTag: boolean; + testMode: boolean; + pass1Prompt: string; + pass1StrictPrompt: string; + pass2Prompt: string; +}; + +export type ClassificationTagRecord = { + id: string; + runId: string; + repoId: string; + tags: string[]; +}; + +export type TagCompressionRecord = { + tag: string; + compressedTag: string; +}; + +export type ListRecord = { + id: string; + name: string; + description: string; + isPrivate: boolean; +}; + +export type RepoListRecord = { + repoId: string; + listIds: string[]; +}; + +export type JobRecord = { + id: string; + type: string; + status: "idle" | "running" | "failed" | "completed"; + progress: number; + total: number; + message: string; + updatedAt: string; +}; + +export type CacheRecord = { + key: string; + etag: string | null; + lastFetchedAt: string; + hash: string; +}; + +export class StarManagerDB extends Dexie { + repos!: Table; + lists!: Table; + repoLists!: Table; + classifications!: Table; + classificationRuns!: Table; + classificationTags!: Table; + tagCompression!: Table; + jobs!: Table; + cache!: Table; + + constructor() { + super("star-manager"); + this.version(1).stores({ + repos: "id, fullName", + lists: "id, name", + repoLists: "repoId", + classifications: "repoId", + tagCompression: "tag", + jobs: "id, type, status", + cache: "key", + }); + this.version(2).stores({ + repos: "id, fullName", + lists: "id, name", + repoLists: "repoId", + classifications: "repoId", + classificationRuns: "id, createdAt", + classificationTags: "id, runId, repoId", + tagCompression: "tag", + jobs: "id, type, status", + cache: "key", + }); + } +} + +export const db = new StarManagerDB(); diff --git a/src/app/data/useLiveQuery.ts b/src/app/data/useLiveQuery.ts new file mode 100644 index 0000000..f965913 --- /dev/null +++ b/src/app/data/useLiveQuery.ts @@ -0,0 +1,25 @@ +import { liveQuery } from "dexie"; +import { useEffect, useState } from "react"; + +export function useLiveQuery( + querier: () => Promise | T, + deps: unknown[], + defaultValue: T +): T { + const [value, setValue] = useState(defaultValue); + + useEffect(() => { + const subscription = liveQuery(querier).subscribe({ + next: (result) => setValue(result), + error: (error) => { + console.error("Dexie liveQuery error", error); + }, + }); + + return () => { + subscription.unsubscribe(); + }; + }, deps); + + return value; +} diff --git a/src/app/i18n/index.ts b/src/app/i18n/index.ts new file mode 100644 index 0000000..14935bf --- /dev/null +++ b/src/app/i18n/index.ts @@ -0,0 +1,18 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import { detectBrowserLanguage } from "./language"; +import { resources } from "./resources"; + +if (!i18n.isInitialized) { + void i18n.use(initReactI18next).init({ + resources, + lng: detectBrowserLanguage(), + fallbackLng: "en", + supportedLngs: ["en", "zh-CN"], + interpolation: { + escapeValue: false, + }, + }); +} + +export { i18n }; diff --git a/src/app/i18n/language.ts b/src/app/i18n/language.ts new file mode 100644 index 0000000..a6afea6 --- /dev/null +++ b/src/app/i18n/language.ts @@ -0,0 +1,24 @@ +export type AppLanguage = "en" | "zh-CN"; +export type UiLanguagePreference = "auto" | AppLanguage; +export type PromptLanguage = "en" | "zh"; + +export function normalizeAppLanguage(language?: string | null): AppLanguage { + if (!language) return "en"; + return language.toLowerCase().startsWith("zh") ? "zh-CN" : "en"; +} + +export function detectBrowserLanguage(): AppLanguage { + if (typeof navigator === "undefined") return "en"; + return normalizeAppLanguage(navigator.language); +} + +export function resolveEffectiveLanguage( + preference: UiLanguagePreference, + browserLanguage: AppLanguage +): AppLanguage { + return preference === "auto" ? browserLanguage : preference; +} + +export function toPromptLanguage(language: AppLanguage): PromptLanguage { + return language === "zh-CN" ? "zh" : "en"; +} diff --git a/src/app/i18n/resources.ts b/src/app/i18n/resources.ts new file mode 100644 index 0000000..49198f6 --- /dev/null +++ b/src/app/i18n/resources.ts @@ -0,0 +1,701 @@ +export const resources = { + en: { + translation: { + common: { + appName: "Star Manager", + actions: { + cancel: "Cancel", + clear: "Clear", + close: "Close", + save: "Save", + settings: "Settings", + }, + labels: { + error: "Error", + failed: "Failed", + language: "Language", + lastSync: "Last sync", + progress: "Progress", + search: "Search", + stage: "Stage", + status: "Status", + unknown: "Unknown", + }, + values: { + all: "All", + allStarred: "All Starred", + none: "(none)", + unclassified: "Unclassified", + }, + }, + app: { + subtitle: "Organize GitHub Star Lists with confidence.", + actions: { + applyUpdates: "Apply Updates", + assignList: "Assign List", + configureLlm: "Configure LLM", + connectPat: "Connect PAT", + retryFailedLists: "Retry failed lists ({{count}})", + runClassification: "Run Classification", + syncStarLists: "Sync Star Lists", + }, + sections: { + batchActions: "Batch Actions", + llmClassification: "LLM Classification", + repositories: "Repositories", + starLists: "Star Lists", + syncStatus: "Sync Status", + }, + sync: { + detail: { + connectPat: "Connect PAT to start importing your starred repos.", + preparing: "Preparing sync...", + progress: "{{stage}} ({{current}}/{{total}})", + loaded: "Loaded {{repos}} repos across {{lists}} lists.", + retryRecovered: "All failed lists recovered.", + retryFinished: "Retry finished. {{count}} lists still failed.", + tokenOk: "Token OK. Logged in as {{login}}.", + syncFailed: "Sync failed", + retryFailed: "Retry failed", + tokenValidationFailed: "Token validation failed", + }, + status: { + completed: "Completed", + failed: "Failed", + idle: "Idle", + ready: "Ready", + retrying: "Retrying", + running: "Running", + }, + }, + filters: { + noList: "No list", + searchPlaceholder: "Search name or description", + updatedLastSixMonths: "Updated last 6 months", + }, + empty: { + noListsTitle: "No Star Lists loaded yet.", + noListsHint: "Run a sync to import your existing lists.", + noDataTitle: "No data yet.", + noDataHint: "Connect your PAT and run a sync to load your starred repos.", + noFilteredTitle: "No repositories match the current filters.", + noFilteredHint: "Try clearing filters or sync your Star Lists first.", + }, + sidebar: { + llmDesc: "Configure your OpenAI-compatible endpoint to auto-label repos.", + batchDesc: "Apply optimized Star Lists back to GitHub with one click.", + }, + values: { + noDescription: "No description", + }, + }, + onboarding: { + title: "README Fetching", + description1: + "We can fetch README snippets to improve classification quality. This may increase GitHub API usage and takes longer to sync.", + description2: + "Would you like to enable README fetching now? You can change this later in Settings.", + skip: "Skip for now", + enable: "Enable README fetch", + }, + patModal: { + title: "Connect GitHub PAT", + description: + "We store your token locally to access your Star Lists. Recommended scopes: repo, read:user, read:star.", + tokenLabel: "Personal access token", + savePat: "Save PAT", + }, + settings: { + closeAria: "Close settings", + title: "Settings", + description: "Manage tokens, LLM configuration, README fetching, and local cache.", + sections: { + language: "Language", + localCache: "Local Cache", + llmConfiguration: "LLM Configuration", + patManagement: "PAT Management", + readmeFetching: "README Fetching", + writeback: "Writeback", + }, + language: { + appLanguage: "App language", + auto: "Auto (Browser)", + english: "English", + chineseSimplified: "Chinese (Simplified)", + promptLanguage: "Prompt language: {{language}}. Prompts follow the app language.", + promptLanguageEnglish: "English", + promptLanguageChinese: "中文", + }, + pat: { + githubPat: "GitHub PAT", + githubLoginOptional: "GitHub Login (optional)", + savePat: "Save PAT", + clearPat: "Clear PAT", + required: "PAT is required.", + validating: "Validating PAT...", + validated: "PAT validated. Logged in as {{login}}.", + validationFailed: "PAT validation failed ({{name}}): {{message}}", + cleared: "PAT cleared.", + }, + llm: { + useExistingLists: "Use existing Star Lists as context", + allowNewTags: "Allow new tags when using existing lists", + currentMode: "Current mode", + modeBase: "Base mode: classify without existing Star Lists context.", + modeExisting: + "Existing lists mode: prefer existing lists and allow new tags.", + modeExistingOnly: + "Existing-only mode: classify with existing lists only, no new tags.", + baseUrl: "Base URL", + apiKey: "API Key", + model: "Model", + temperature: "Temperature", + maxTokens: "Max Tokens", + activePass1: "Pass 1 System Prompt (active mode)", + activePass1Strict: "Pass 1 Strict System Prompt (active mode)", + activePass2: "Pass 2 System Prompt (active mode)", + advancedSettings: "Advanced prompt settings ({{count}})", + advancedHelp: + "Edit prompts for non-active modes. These values are kept but hidden from the main view.", + testLlm: "Test LLM", + testing: "Testing...", + resetLlm: "Reset LLM", + missingBaseUrl: "Missing base URL.", + missingApiKey: "Missing API key.", + testOk: "OK in {{elapsed}}ms · {{response}}", + testError: "Error ({{name}}) in {{elapsed}}ms: {{message}}", + cleared: "LLM config cleared.", + promptLabels: { + pass1Prompt: "Pass 1 Base Prompt", + pass1StrictPrompt: "Pass 1 Strict Base Prompt", + pass2Prompt: "Pass 2 Base Prompt", + pass1PromptWithExisting: "Pass 1 Existing Context Prompt", + pass1PromptNoNewWithExisting: "Pass 1 Existing-Only Prompt", + pass1StrictPromptWithExisting: + "Pass 1 Strict Existing Context Prompt", + pass1StrictNoNewWithExisting: "Pass 1 Strict Existing-Only Prompt", + pass2PromptWithExisting: "Pass 2 Existing Context Prompt", + pass1PromptZh: "Pass 1 Base Prompt (ZH)", + pass1StrictPromptZh: "Pass 1 Strict Base Prompt (ZH)", + pass2PromptZh: "Pass 2 Base Prompt (ZH)", + pass1PromptWithExistingZh: "Pass 1 Existing Context Prompt (ZH)", + pass1PromptNoNewWithExistingZh: + "Pass 1 Existing-Only Prompt (ZH)", + pass1StrictPromptWithExistingZh: + "Pass 1 Strict Existing Context Prompt (ZH)", + pass1StrictNoNewWithExistingZh: + "Pass 1 Strict Existing-Only Prompt (ZH)", + pass2PromptWithExistingZh: "Pass 2 Existing Context Prompt (ZH)", + }, + }, + readme: { + enable: "Enable README snippets during sync", + }, + writeback: { + refreshBeforeApply: "Refresh GitHub data before apply", + refreshHelp: + "When enabled, Apply Updates recalculates the writeback plan using latest GitHub state.", + }, + cache: { + help: "Clear IndexedDB data to reset your local cache.", + clearButton: "Clear Local Cache", + cleared: "Local cache cleared. Reloading...", + }, + }, + classification: { + closeAria: "Close classification", + title: "LLM Classification", + description: + "Run a two-stage classification. Stage 2 only compresses tags to save tokens.", + sections: { + diffView: "Diff View", + preview: "Preview", + runStatus: "Run Status", + }, + actions: { + run: "Run Classification", + running: "Running...", + }, + labels: { + compareAgainst: "Compare against", + current: "Current", + existing: "Existing", + previous: "Previous", + selectRun: "Select run", + }, + options: { + existingStarLists: "Existing Star Lists", + previousRun: "Previous run", + selectRunPlaceholder: "Select a run", + reposSuffix: "repos", + testSuffix: "test", + singleSuffix: "single", + }, + toggles: { + strictSingleTag: "Strict single tag (exactly 1 per repo)", + testMode: "Test mode (first 5 repos)", + }, + status: { + noResults: "No classification results yet.", + noSavedRuns: "No saved runs yet.", + noTagDiff: "No tag differences found.", + noDiffFromExisting: "No differences from existing lists.", + }, + errors: { + missingConfig: "LLM config is missing.", + noRepos: "No repos available. Sync your Star Lists first.", + classificationFailed: "Classification failed", + }, + stage: { + idle: "Idle", + preparing: "Preparing", + classifying_repos: "Classifying repos", + compressing_tags: "Compressing tags", + completed: "Completed", + failed: "Failed", + }, + }, + assignList: { + closeAria: "Close assign list", + title: "Assign List", + description: "Choose local Star Lists for {{repoName}}.", + sections: { + lists: "Lists", + }, + status: { + noLists: "No lists available yet. Run sync first.", + saved: "Saved.", + saveFailed: "Save failed.", + saving: "Saving...", + }, + }, + apply: { + closeAria: "Close apply updates", + title: "Apply Updates", + description: "Write current classification results back to GitHub Star Lists.", + sections: { + applyResult: "Apply Result", + issues: "Issues", + planSummary: "Plan Summary", + plannedRepoChanges: "Planned Repo Changes", + progress: "Progress", + runControls: "Run Controls", + }, + controls: { + refreshBeforeApply: "Refresh GitHub data before apply", + refreshOn: + "Apply will re-plan against latest GitHub state.", + refreshOff: + "Apply will use the current preview plan without re-planning.", + applyToGithub: "Apply to GitHub", + applying: "Applying...", + }, + labels: { + actionable: "Actionable", + after: "After", + applied: "Applied", + createdLists: "Created Lists", + current: "Current", + failed: "Failed", + locallyUpdated: "Local updated", + missingLists: "Missing Lists", + skipped: "Skipped", + unchanged: "Unchanged", + unstarred: "Unstarred", + validTagged: "Valid Tagged", + }, + status: { + noChangesDetected: "No repo list changes detected.", + scanFailures: + "Membership scan partial failures: {{count}}. Preview may be partially incomplete.", + }, + errors: { + missingPat: "GitHub PAT is missing. Please connect PAT first.", + previewNotReady: + "Preview is not ready yet. Please wait for plan generation to finish.", + previewFailed: "Preview failed.", + applyFailed: "Apply failed.", + }, + confirmUnstarred: + "Found {{count}} unstarred repos that require list updates.\n\nClick OK to auto-star and continue.\nClick Cancel to skip those repos and continue.", + stage: { + idle: "Idle", + preparing_preview: "Preparing preview", + preparing_writeback: "Preparing writeback", + loading_local_classifications: "Loading local classifications", + fetching_star_lists: "Fetching Star Lists", + creating_missing_lists: "Creating missing lists", + resolving_repositories: "Resolving repositories", + scanning_existing_memberships: "Scanning existing memberships", + applying_updates: "Applying updates", + }, + issueReasons: { + empty_tags: "Empty tags", + repo_not_found: "Repo not found", + invalid_repo_name: "Invalid repo name", + repo_meta_missing: "Repo metadata missing", + repo_meta_fetch_failed: "Repo metadata fetch failed", + missing_list: "Missing list", + create_list_failed: "Create list failed", + membership_scan_failed: "Membership scan failed", + no_changes: "No changes", + unstarred_requires_confirmation: "Unstarred requires confirmation", + add_star_failed: "Add star failed", + update_failed: "Update failed", + }, + }, + progress: { + sync: { + idle: "Idle", + fetching_star_lists: "Fetching Star Lists", + fetching_starred_repos: "Fetching Starred Repos", + scanning_list_membership: "Scanning List Membership", + retrying_list_membership: "Retrying List Membership", + }, + }, + }, + }, + "zh-CN": { + translation: { + common: { + appName: "Star Manager", + actions: { + cancel: "取消", + clear: "清空", + close: "关闭", + save: "保存", + settings: "设置", + }, + labels: { + error: "错误", + failed: "失败", + language: "语言", + lastSync: "上次同步", + progress: "进度", + search: "搜索", + stage: "阶段", + status: "状态", + unknown: "未知", + }, + values: { + all: "全部", + allStarred: "全部收藏", + none: "(无)", + unclassified: "未分类", + }, + }, + app: { + subtitle: "更安心地管理 GitHub Star Lists。", + actions: { + applyUpdates: "应用更新", + assignList: "分配列表", + configureLlm: "配置 LLM", + connectPat: "连接 PAT", + retryFailedLists: "重试失败列表({{count}})", + runClassification: "运行分类", + syncStarLists: "同步 Star Lists", + }, + sections: { + batchActions: "批量操作", + llmClassification: "LLM 分类", + repositories: "仓库", + starLists: "Star 列表", + syncStatus: "同步状态", + }, + sync: { + detail: { + connectPat: "请先连接 PAT,再导入你收藏的仓库。", + preparing: "正在准备同步...", + progress: "{{stage}}({{current}}/{{total}})", + loaded: "已加载 {{repos}} 个仓库,覆盖 {{lists}} 个列表。", + retryRecovered: "所有失败列表均已恢复。", + retryFinished: "重试完成,仍有 {{count}} 个列表失败。", + tokenOk: "Token 校验通过,当前登录为 {{login}}。", + syncFailed: "同步失败", + retryFailed: "重试失败", + tokenValidationFailed: "Token 校验失败", + }, + status: { + completed: "已完成", + failed: "失败", + idle: "空闲", + ready: "就绪", + retrying: "重试中", + running: "运行中", + }, + }, + filters: { + noList: "无列表", + searchPlaceholder: "搜索名称或描述", + updatedLastSixMonths: "近 6 个月有更新", + }, + empty: { + noListsTitle: "尚未加载 Star 列表。", + noListsHint: "执行一次同步即可导入你已有的列表。", + noDataTitle: "暂无数据。", + noDataHint: "连接 PAT 并执行同步后即可加载已收藏仓库。", + noFilteredTitle: "当前筛选条件下没有匹配仓库。", + noFilteredHint: "尝试清空筛选,或先同步 Star 列表。", + }, + sidebar: { + llmDesc: "配置兼容 OpenAI 的接口,自动为仓库打标签。", + batchDesc: "一键将优化后的 Star Lists 写回 GitHub。", + }, + values: { + noDescription: "暂无描述", + }, + }, + onboarding: { + title: "README 抓取", + description1: + "我们可以抓取 README 片段来提升分类质量,但会增加 GitHub API 调用并延长同步时间。", + description2: "是否现在启用 README 抓取?你也可以稍后在设置中修改。", + skip: "暂不启用", + enable: "启用 README 抓取", + }, + patModal: { + title: "连接 GitHub PAT", + description: + "Token 仅保存在本地,用于访问你的 Star Lists。建议权限:repo、read:user、read:star。", + tokenLabel: "个人访问令牌", + savePat: "保存 PAT", + }, + settings: { + closeAria: "关闭设置", + title: "设置", + description: "管理 Token、LLM 配置、README 抓取和本地缓存。", + sections: { + language: "语言", + localCache: "本地缓存", + llmConfiguration: "LLM 配置", + patManagement: "PAT 管理", + readmeFetching: "README 抓取", + writeback: "写回", + }, + language: { + appLanguage: "界面语言", + auto: "自动(浏览器)", + english: "English", + chineseSimplified: "简体中文", + promptLanguage: "Prompt 语言:{{language}}。Prompt 会跟随界面语言。", + promptLanguageEnglish: "English", + promptLanguageChinese: "中文", + }, + pat: { + githubPat: "GitHub PAT", + githubLoginOptional: "GitHub 登录名(可选)", + savePat: "保存 PAT", + clearPat: "清除 PAT", + required: "PAT 不能为空。", + validating: "正在校验 PAT...", + validated: "PAT 校验通过,当前登录为 {{login}}。", + validationFailed: "PAT 校验失败({{name}}):{{message}}", + cleared: "PAT 已清除。", + }, + llm: { + useExistingLists: "使用已有 Star Lists 作为上下文", + allowNewTags: "使用已有列表时允许新增标签", + currentMode: "当前模式", + modeBase: "基础模式:不参考已有 Star Lists 进行分类。", + modeExisting: "已有列表模式:优先使用已有列表,并允许新增标签。", + modeExistingOnly: "仅已有列表模式:仅使用已有列表,不新增标签。", + baseUrl: "Base URL", + apiKey: "API Key", + model: "模型", + temperature: "温度", + maxTokens: "最大 Tokens", + activePass1: "Pass 1 系统 Prompt(当前模式)", + activePass1Strict: "Pass 1 严格系统 Prompt(当前模式)", + activePass2: "Pass 2 系统 Prompt(当前模式)", + advancedSettings: "高级 Prompt 设置({{count}})", + advancedHelp: "可编辑非当前模式的 Prompt,这些值会保留但不在主视图展示。", + testLlm: "测试 LLM", + testing: "测试中...", + resetLlm: "重置 LLM", + missingBaseUrl: "缺少 Base URL。", + missingApiKey: "缺少 API Key。", + testOk: "{{elapsed}}ms 内成功 · {{response}}", + testError: "错误({{name}}),耗时 {{elapsed}}ms:{{message}}", + cleared: "LLM 配置已清除。", + promptLabels: { + pass1Prompt: "Pass 1 基础 Prompt", + pass1StrictPrompt: "Pass 1 严格基础 Prompt", + pass2Prompt: "Pass 2 基础 Prompt", + pass1PromptWithExisting: "Pass 1 含已有上下文 Prompt", + pass1PromptNoNewWithExisting: "Pass 1 仅已有列表 Prompt", + pass1StrictPromptWithExisting: "Pass 1 严格含已有上下文 Prompt", + pass1StrictNoNewWithExisting: "Pass 1 严格仅已有列表 Prompt", + pass2PromptWithExisting: "Pass 2 含已有上下文 Prompt", + pass1PromptZh: "Pass 1 基础 Prompt(中文)", + pass1StrictPromptZh: "Pass 1 严格基础 Prompt(中文)", + pass2PromptZh: "Pass 2 基础 Prompt(中文)", + pass1PromptWithExistingZh: "Pass 1 含已有上下文 Prompt(中文)", + pass1PromptNoNewWithExistingZh: "Pass 1 仅已有列表 Prompt(中文)", + pass1StrictPromptWithExistingZh: + "Pass 1 严格含已有上下文 Prompt(中文)", + pass1StrictNoNewWithExistingZh: "Pass 1 严格仅已有列表 Prompt(中文)", + pass2PromptWithExistingZh: "Pass 2 含已有上下文 Prompt(中文)", + }, + }, + readme: { + enable: "同步时启用 README 片段", + }, + writeback: { + refreshBeforeApply: "应用前刷新 GitHub 数据", + refreshHelp: "启用后,Apply Updates 会基于最新 GitHub 状态重新生成写回计划。", + }, + cache: { + help: "清空 IndexedDB 数据以重置本地缓存。", + clearButton: "清空本地缓存", + cleared: "本地缓存已清空,正在刷新...", + }, + }, + classification: { + closeAria: "关闭分类", + title: "LLM 分类", + description: "执行两阶段分类。第 2 阶段仅压缩标签以节省 token。", + sections: { + diffView: "差异视图", + preview: "预览", + runStatus: "运行状态", + }, + actions: { + run: "运行分类", + running: "运行中...", + }, + labels: { + compareAgainst: "对比基准", + current: "当前", + existing: "已有", + previous: "上次", + selectRun: "选择运行记录", + }, + options: { + existingStarLists: "已有 Star Lists", + previousRun: "历史运行", + selectRunPlaceholder: "选择一条运行记录", + reposSuffix: "个仓库", + testSuffix: "测试", + singleSuffix: "单标签", + }, + toggles: { + strictSingleTag: "严格单标签(每仓库仅 1 个)", + testMode: "测试模式(仅前 5 个仓库)", + }, + status: { + noResults: "暂无分类结果。", + noSavedRuns: "暂无保存的运行记录。", + noTagDiff: "未发现标签差异。", + noDiffFromExisting: "与已有列表无差异。", + }, + errors: { + missingConfig: "LLM 配置不完整。", + noRepos: "没有可分类仓库,请先同步 Star Lists。", + classificationFailed: "分类失败", + }, + stage: { + idle: "空闲", + preparing: "准备中", + classifying_repos: "正在分类仓库", + compressing_tags: "正在压缩标签", + completed: "已完成", + failed: "失败", + }, + }, + assignList: { + closeAria: "关闭分配列表", + title: "分配列表", + description: "为 {{repoName}} 选择本地 Star 列表。", + sections: { + lists: "列表", + }, + status: { + noLists: "暂无列表,请先同步。", + saved: "已保存。", + saveFailed: "保存失败。", + saving: "保存中...", + }, + }, + apply: { + closeAria: "关闭应用更新", + title: "应用更新", + description: "将当前分类结果写回到 GitHub Star Lists。", + sections: { + applyResult: "应用结果", + issues: "问题", + planSummary: "计划摘要", + plannedRepoChanges: "计划中的仓库变更", + progress: "进度", + runControls: "运行控制", + }, + controls: { + refreshBeforeApply: "应用前刷新 GitHub 数据", + refreshOn: "应用时会基于最新 GitHub 状态重新规划。", + refreshOff: "应用时会直接使用当前预览计划,不再重新规划。", + applyToGithub: "应用到 GitHub", + applying: "应用中...", + }, + labels: { + actionable: "可执行", + after: "变更后", + applied: "已应用", + createdLists: "新建列表", + current: "当前", + failed: "失败", + locallyUpdated: "本地已更新", + missingLists: "缺失列表", + skipped: "跳过", + unchanged: "未变化", + unstarred: "未收藏", + validTagged: "有效标签仓库", + }, + status: { + noChangesDetected: "未检测到仓库列表变更。", + scanFailures: "列表成员扫描部分失败:{{count}}。预览可能不完整。", + }, + errors: { + missingPat: "缺少 GitHub PAT,请先连接 PAT。", + previewNotReady: "预览尚未就绪,请等待计划生成完成。", + previewFailed: "预览失败。", + applyFailed: "应用失败。", + }, + confirmUnstarred: + "发现 {{count}} 个未收藏仓库需要更新列表。\n\n点击“确定”将自动收藏并继续。\n点击“取消”将跳过这些仓库并继续。", + stage: { + idle: "空闲", + preparing_preview: "正在准备预览", + preparing_writeback: "正在准备写回", + loading_local_classifications: "正在加载本地分类", + fetching_star_lists: "正在拉取 Star 列表", + creating_missing_lists: "正在创建缺失列表", + resolving_repositories: "正在解析仓库", + scanning_existing_memberships: "正在扫描已有成员关系", + applying_updates: "正在应用更新", + }, + issueReasons: { + empty_tags: "标签为空", + repo_not_found: "仓库不存在", + invalid_repo_name: "仓库名格式无效", + repo_meta_missing: "仓库元数据缺失", + repo_meta_fetch_failed: "获取仓库元数据失败", + missing_list: "目标列表缺失", + create_list_failed: "创建列表失败", + membership_scan_failed: "成员扫描失败", + no_changes: "无变更", + unstarred_requires_confirmation: "未收藏需确认", + add_star_failed: "自动收藏失败", + update_failed: "更新失败", + }, + }, + progress: { + sync: { + idle: "空闲", + fetching_star_lists: "正在拉取 Star 列表", + fetching_starred_repos: "正在拉取已收藏仓库", + scanning_list_membership: "正在扫描列表成员关系", + retrying_list_membership: "正在重试列表成员关系", + }, + }, + }, + }, +} as const; diff --git a/src/app/services/githubAuth.ts b/src/app/services/githubAuth.ts new file mode 100644 index 0000000..ca8b610 --- /dev/null +++ b/src/app/services/githubAuth.ts @@ -0,0 +1,22 @@ +import { ghGraphql } from "./githubClient"; + +export type ViewerProfile = { + login: string; + name: string | null; + avatarUrl: string; +}; + +export async function validatePat(token: string): Promise { + const query = ` + query { + viewer { + login + name + avatarUrl + } + } + `; + + const data = await ghGraphql<{ viewer: ViewerProfile }>({ token }, query); + return data.viewer; +} diff --git a/src/app/services/githubClient.ts b/src/app/services/githubClient.ts new file mode 100644 index 0000000..4d8e36a --- /dev/null +++ b/src/app/services/githubClient.ts @@ -0,0 +1,85 @@ +export type GitHubConfig = { + token: string; +}; + +export type GitHubRequestOptions = { + signal?: AbortSignal; + retries?: number; +}; + +const API_URL = "https://api.github.com"; +const GRAPHQL_URL = "https://api.github.com/graphql"; + +async function requestJson( + url: string, + init: RequestInit, + options: GitHubRequestOptions +): Promise { + const retries = options.retries ?? 2; + let attempt = 0; + + while (true) { + try { + const response = await fetch(url, init); + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Request failed: ${response.status}`); + } + return (await response.json()) as T; + } catch (error) { + if (attempt >= retries) { + throw error; + } + attempt += 1; + await new Promise((resolve) => setTimeout(resolve, 800 * attempt)); + } + } +} + +export async function ghGraphql( + config: GitHubConfig, + query: string, + variables: Record = {}, + options: GitHubRequestOptions = {} +): Promise { + const body = JSON.stringify({ query, variables }); + const response = await requestJson<{ data?: T; errors?: { message: string }[] }>( + GRAPHQL_URL, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/vnd.github+json", + Authorization: `bearer ${config.token}`, + }, + body, + signal: options.signal, + }, + options + ); + if (response.errors && response.errors.length > 0) { + throw new Error(response.errors[0].message || "GitHub GraphQL error"); + } + if (!response.data) { + throw new Error("GitHub GraphQL response missing data"); + } + return response.data; +} + +export async function ghRest( + config: GitHubConfig, + path: string, + options: GitHubRequestOptions = {} +): Promise { + return requestJson( + `${API_URL}${path}`, + { + headers: { + Accept: "application/vnd.github+json", + Authorization: `bearer ${config.token}`, + }, + signal: options.signal, + }, + options + ); +} diff --git a/src/app/services/githubStarLists.ts b/src/app/services/githubStarLists.ts new file mode 100644 index 0000000..600c1ae --- /dev/null +++ b/src/app/services/githubStarLists.ts @@ -0,0 +1,391 @@ +import { ghGraphql, ghRest, type GitHubConfig } from "./githubClient"; + +export type StarList = { + id: string; + name: string; + description: string; +}; + +export type StarredRepo = { + id: string; + name: string; + fullName: string; + description: string | null; + url: string; + topics: string[]; + language: string | null; + updatedAt: string; + stargazerCount: number; +}; + +export type StarListMembership = { + listId: string; + repoIds: string[]; +}; + +export type UserListRef = { + id: string; + name: string; +}; + +export type RepositoryMeta = { + id: string; + viewerHasStarred: boolean; +}; + +export type RepoMembershipIndexProgress = { + current: number; + total: number; +}; + +export type RepoMembershipIndexResult = { + index: Map>; + failedListIds: string[]; +}; + +type ListApiField = "lists" | "userLists"; + +type GraphqlPageInfo = { + hasNextPage: boolean; + endCursor: string | null; +}; + +type StarListConnection = { + nodes: StarList[]; + pageInfo: GraphqlPageInfo; +}; + +type FetchStarListsResponse = { + viewer: Record; +}; + +type StarredRepoNode = { + id: string; + name: string; + nameWithOwner: string; + description: string | null; + url: string; + repositoryTopics: { nodes: { topic: { name: string } }[] }; + primaryLanguage: { name: string } | null; + updatedAt: string; + stargazerCount: number; +}; + +type FetchStarredReposResponse = { + viewer: { + starredRepositories: { + nodes: StarredRepoNode[]; + pageInfo: GraphqlPageInfo; + }; + }; +}; + +type FetchListMembershipResponse = { + node: { + items: { + nodes: { id: string }[]; + pageInfo: GraphqlPageInfo; + }; + } | null; +}; + +type CreateUserListResponse = { + createUserList: { + list: UserListRef | null; + } | null; +}; + +type RepositoryMetaResponse = { + repository: RepositoryMeta | null; +}; + +export async function detectStarListApi(config: GitHubConfig): Promise { + const candidates: ListApiField[] = ["lists", "userLists"]; + for (const field of candidates) { + try { + const query = `query { viewer { ${field}(first: 1) { totalCount } } }`; + await ghGraphql(config, query); + return field; + } catch { + continue; + } + } + throw new Error("Star Lists API not available for this token/account"); +} + +export async function fetchStarLists( + config: GitHubConfig, + field: ListApiField +): Promise { + const lists: StarList[] = []; + let after: string | null = null; + + while (true) { + const query = ` + query ($first: Int!, $after: String) { + viewer { + ${field}(first: $first, after: $after) { + nodes { + id + name + description + } + pageInfo { hasNextPage endCursor } + } + } + } + `; + + const data: FetchStarListsResponse = await ghGraphql( + config, + query, + { first: 100, after } + ); + + const connection: StarListConnection = data.viewer[field]; + lists.push(...(connection.nodes ?? [])); + if (!connection.pageInfo.hasNextPage) break; + after = connection.pageInfo.endCursor; + } + + return lists; +} + +export async function fetchStarredRepos(config: GitHubConfig): Promise { + const repos: StarredRepo[] = []; + let after: string | null = null; + + while (true) { + const query = ` + query ($first: Int!, $after: String) { + viewer { + starredRepositories(first: $first, after: $after, orderBy: { field: STARRED_AT, direction: DESC }) { + nodes { + id + name + nameWithOwner + description + url + repositoryTopics(first: 10) { + nodes { topic { name } } + } + primaryLanguage { name } + updatedAt + stargazerCount + } + pageInfo { hasNextPage endCursor } + } + } + } + `; + + const data: FetchStarredReposResponse = await ghGraphql(config, query, { first: 100, after }); + + const connection: FetchStarredReposResponse["viewer"]["starredRepositories"] = + data.viewer.starredRepositories; + repos.push( + ...connection.nodes.map((node: StarredRepoNode) => ({ + id: node.id, + name: node.name, + fullName: node.nameWithOwner, + description: node.description, + url: node.url, + topics: node.repositoryTopics.nodes.map( + (topicNode: { topic: { name: string } }) => topicNode.topic.name + ), + language: node.primaryLanguage?.name ?? null, + updatedAt: node.updatedAt, + stargazerCount: node.stargazerCount, + })) + ); + + if (!connection.pageInfo.hasNextPage) break; + after = connection.pageInfo.endCursor; + } + + return repos; +} + +export async function fetchReadmeExcerpt( + config: GitHubConfig, + owner: string, + repo: string +): Promise { + const data = await ghRest<{ content?: string; encoding?: string }>( + config, + `/repos/${owner}/${repo}/readme` + ); + const content = data.content ?? ""; + const encoding = data.encoding ?? "base64"; + if (encoding !== "base64") { + return ""; + } + const decoded = atob(content.replace(/\n/g, "")); + return decoded.slice(0, 3000); +} + +export async function fetchListMembership( + config: GitHubConfig, + listId: string +): Promise { + const repoIds: string[] = []; + let after: string | null = null; + + while (true) { + const query = ` + query ($id: ID!, $first: Int!, $after: String) { + node(id: $id) { + ... on UserList { + items(first: $first, after: $after) { + nodes { ... on Repository { id } } + pageInfo { hasNextPage endCursor } + } + } + } + } + `; + const data: FetchListMembershipResponse = await ghGraphql(config, query, { + id: listId, + first: 100, + after, + }); + + if (!data.node?.items) break; + repoIds.push(...data.node.items.nodes.map((node: { id: string }) => node.id)); + if (!data.node.items.pageInfo.hasNextPage) break; + after = data.node.items.pageInfo.endCursor; + } + + return { listId, repoIds }; +} + +export async function createUserList( + config: GitHubConfig, + name: string, + isPrivate: boolean, + description = "" +): Promise { + const query = ` + mutation($input: CreateUserListInput!) { + createUserList(input: $input) { + list { id name } + } + } + `; + const input: { name: string; isPrivate: boolean; description?: string } = { name, isPrivate }; + if (description.trim()) { + input.description = description.trim(); + } + + const data: CreateUserListResponse = await ghGraphql(config, query, { input }); + const created = data.createUserList?.list; + if (!created) { + throw new Error(`Failed to create list: ${name}`); + } + return created; +} + +export async function getRepositoryMeta( + config: GitHubConfig, + owner: string, + name: string +): Promise { + const query = ` + query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + id + viewerHasStarred + } + } + `; + try { + const data: RepositoryMetaResponse = await ghGraphql(config, query, { owner, name }); + return data.repository; + } catch (error) { + const message = (error as Error).message || ""; + if (message.includes("Could not resolve to a Repository")) { + return null; + } + throw error; + } +} + +export async function addStar(config: GitHubConfig, starrableId: string): Promise { + const query = ` + mutation($input: AddStarInput!) { + addStar(input: $input) { + starrable { id } + } + } + `; + await ghGraphql(config, query, { input: { starrableId } }); +} + +export async function updateUserListsForItem( + config: GitHubConfig, + itemId: string, + listIds: string[] +): Promise { + const query = ` + mutation($input: UpdateUserListsForItemInput!) { + updateUserListsForItem(input: $input) { clientMutationId } + } + `; + await ghGraphql(config, query, { input: { itemId, listIds } }); +} + +export async function buildRepoMembershipIndex( + config: GitHubConfig, + listIds: string[], + targetRepoIds: Set, + onProgress?: (progress: RepoMembershipIndexProgress) => void +): Promise { + const index = new Map>(); + const failedListIds: string[] = []; + const total = listIds.length; + let current = 0; + + for (const listId of listIds) { + try { + let after: string | null = null; + while (true) { + const query = ` + query($id: ID!, $first: Int!, $after: String) { + node(id: $id) { + ... on UserList { + items(first: $first, after: $after) { + nodes { ... on Repository { id } } + pageInfo { hasNextPage endCursor } + } + } + } + } + `; + const data: FetchListMembershipResponse = await ghGraphql(config, query, { + id: listId, + first: 100, + after, + }); + const items = data.node?.items; + if (!items) break; + + for (const node of items.nodes) { + if (!targetRepoIds.has(node.id)) continue; + const existing = index.get(node.id) ?? new Set(); + existing.add(listId); + index.set(node.id, existing); + } + + if (!items.pageInfo.hasNextPage) break; + after = items.pageInfo.endCursor; + } + } catch { + failedListIds.push(listId); + } + + current += 1; + onProgress?.({ current, total }); + } + + return { index, failedListIds }; +} diff --git a/src/app/services/llmClient.ts b/src/app/services/llmClient.ts new file mode 100644 index 0000000..6d05ef4 --- /dev/null +++ b/src/app/services/llmClient.ts @@ -0,0 +1,65 @@ +export type LLMConfig = { + baseUrl: string; + apiKey: string; + model: string; + temperature: number; + maxTokens: number; + pass1Prompt?: string; + pass1StrictPrompt?: string; + pass2Prompt?: string; + pass1PromptZh?: string; + pass1StrictPromptZh?: string; + pass2PromptZh?: string; + pass1PromptWithExisting?: string; + pass1PromptNoNewWithExisting?: string; + pass1StrictPromptWithExisting?: string; + pass1StrictNoNewWithExisting?: string; + pass1PromptWithExistingZh?: string; + pass1PromptNoNewWithExistingZh?: string; + pass1StrictPromptWithExistingZh?: string; + pass1StrictNoNewWithExistingZh?: string; + pass2PromptWithExisting?: string; + pass2PromptWithExistingZh?: string; +}; + +export type LLMMessage = { + role: "system" | "user" | "assistant"; + content: string; +}; + +export async function requestCompletion( + config: LLMConfig, + messages: LLMMessage[], + signal?: AbortSignal +): Promise { + const response = await fetch(`${config.baseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.apiKey}`, + }, + body: JSON.stringify({ + model: config.model, + temperature: config.temperature, + max_tokens: config.maxTokens, + messages, + }), + signal, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `LLM request failed: ${response.status}`); + } + + const data = (await response.json()) as { + choices?: { message?: { content?: string } }[]; + }; + + const content = data.choices?.[0]?.message?.content?.trim(); + if (!content) { + throw new Error("LLM response missing content"); + } + + return content; +} diff --git a/src/app/store/llmConfig.ts b/src/app/store/llmConfig.ts new file mode 100644 index 0000000..b2c0b7f --- /dev/null +++ b/src/app/store/llmConfig.ts @@ -0,0 +1,111 @@ +import { useCallback, useSyncExternalStore } from "react"; +import type { LLMConfig } from "../services/llmClient"; + +type LlmConfigState = { + config: LLMConfig; +}; + +const STORAGE_KEY = "star-manager.llm-config"; + +const defaultConfig: LLMConfig = { + baseUrl: "https://api.openai.com/v1", + apiKey: "", + model: "gpt-4o-mini", + temperature: 0.3, + maxTokens: 120, + pass1Prompt: "You label GitHub repositories with 1-3 concise tags. Output only comma-separated tags.", + pass1StrictPrompt: "You label GitHub repositories with exactly 1 concise tag. Output only the tag text.", + pass2Prompt: + "You consolidate tag lists by merging synonyms and near-duplicates. Output only JSON mapping each original tag to a compressed tag. Do not invent new tags beyond the provided list.", + pass1PromptZh: + "你将 GitHub 仓库标注为 1-3 个简洁标签,仅输出逗号分隔的标签。", + pass1StrictPromptZh: "你将 GitHub 仓库标注为且仅标注 1 个简洁标签,仅输出标签文本。", + pass2PromptZh: + "你需要合并标签中的同义词或近似词,输出仅包含原始标签到压缩标签的 JSON 映射。不要创造列表外的新标签。", + pass1PromptWithExisting: + "You label GitHub repositories with 1-3 concise tags. Prefer existing Star Lists when they fit, but you may add a new tag if needed. Output only comma-separated tags.", + pass1PromptNoNewWithExisting: + "You label GitHub repositories with 1-3 concise tags. You must select from the existing Star Lists only. If none fit, output nothing. Output only comma-separated tags.", + pass1StrictPromptWithExisting: + "You label GitHub repositories with exactly 1 concise tag. Prefer existing Star Lists when they fit. Output only the tag text.", + pass1StrictNoNewWithExisting: + "You must select exactly 1 tag from the existing Star Lists. If none fit, output nothing. Output only the tag text.", + pass1PromptWithExistingZh: + "你将 GitHub 仓库标注为 1-3 个简洁标签,优先使用已有 Star Lists,如有必要可新增标签,仅输出逗号分隔的标签。", + pass1PromptNoNewWithExistingZh: + "你将 GitHub 仓库标注为 1-3 个简洁标签,只能从已有 Star Lists 中选择。如果没有合适标签,请输出空内容,仅输出逗号分隔的标签。", + pass1StrictPromptWithExistingZh: + "你将 GitHub 仓库标注为且仅标注 1 个简洁标签,优先使用已有 Star Lists,仅输出标签文本。", + pass1StrictNoNewWithExistingZh: + "你必须从已有 Star Lists 中选择且仅选择 1 个标签。如果没有合适的标签,请输出空内容,仅输出标签文本。", + pass2PromptWithExisting: + "You consolidate tag lists by merging synonyms and near-duplicates. Output only JSON mapping each original tag to a compressed tag. Do not invent new tags beyond the provided list.", + pass2PromptWithExistingZh: + "你需要合并标签中的同义词或近似词,输出仅包含原始标签到压缩标签的 JSON 映射。不要创造列表外的新标签。", +}; + +let cached = readFromStorage(); +const listeners = new Set<() => void>(); + +function emitChange() { + for (const listener of listeners) { + listener(); + } +} + +function readFromStorage(): LlmConfigState { + if (typeof window === "undefined") { + return { config: { ...defaultConfig } }; + } + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return { config: { ...defaultConfig } }; + const parsed = JSON.parse(raw) as Partial; + return { + config: { + ...defaultConfig, + ...parsed, + }, + }; + } catch { + return { config: { ...defaultConfig } }; + } +} + +function persist(next: LlmConfigState) { + cached = next; + if (typeof window !== "undefined") { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next.config)); + } + emitChange(); +} + +export function useLlmConfigStore() { + const state = useSyncExternalStore( + (onStoreChange) => { + listeners.add(onStoreChange); + return () => listeners.delete(onStoreChange); + }, + () => cached, + () => cached + ); + + const setConfig = useCallback((config: LLMConfig) => { + persist({ config }); + }, []); + + const updateConfig = useCallback((partial: Partial) => { + persist({ config: { ...cached.config, ...partial } }); + }, []); + + const clearConfig = useCallback(() => { + persist({ config: { ...defaultConfig, apiKey: "" } }); + }, []); + + return { + config: state.config, + setConfig, + updateConfig, + clearConfig, + }; +} diff --git a/src/app/store/preferences.ts b/src/app/store/preferences.ts new file mode 100644 index 0000000..722f976 --- /dev/null +++ b/src/app/store/preferences.ts @@ -0,0 +1,121 @@ +import { useCallback, useSyncExternalStore } from "react"; +import type { UiLanguagePreference } from "../i18n/language"; + +type Preferences = { + hasCompletedOnboarding: boolean; + readmeOptIn: boolean; + patToken: string; + viewerLogin: string; + lastSyncedAt: string; + useExistingListsForClassification: boolean; + allowNewTagsWithExistingLists: boolean; + refreshBeforeApply: boolean; + uiLanguage: UiLanguagePreference; +}; + +const STORAGE_KEY = "star-manager.preferences"; + +const defaultPreferences: Preferences = { + hasCompletedOnboarding: false, + readmeOptIn: false, + patToken: "", + viewerLogin: "", + lastSyncedAt: "", + useExistingListsForClassification: false, + allowNewTagsWithExistingLists: true, + refreshBeforeApply: true, + uiLanguage: "auto", +}; + +let cached = readFromStorage(); +const listeners = new Set<() => void>(); + +function emitChange() { + for (const listener of listeners) { + listener(); + } +} + +function readFromStorage(): Preferences { + if (typeof window === "undefined") { + return { ...defaultPreferences }; + } + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return { ...defaultPreferences }; + const parsed = JSON.parse(raw) as Partial; + return { + ...defaultPreferences, + ...parsed, + }; + } catch { + return { ...defaultPreferences }; + } +} + +function persist(next: Preferences) { + cached = next; + if (typeof window !== "undefined") { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } + emitChange(); +} + +export function usePreferenceStore() { + const state = useSyncExternalStore( + (onStoreChange) => { + listeners.add(onStoreChange); + return () => listeners.delete(onStoreChange); + }, + () => cached, + () => cached + ); + + const setReadmeOptIn = useCallback((value: boolean) => { + persist({ ...cached, readmeOptIn: value }); + }, []); + + const markOnboarded = useCallback(() => { + persist({ ...cached, hasCompletedOnboarding: true }); + }, []); + + const setPatToken = useCallback((token: string, viewerLogin: string) => { + persist({ ...cached, patToken: token, viewerLogin }); + }, []); + + const setLastSyncedAt = useCallback((timestamp: string) => { + persist({ ...cached, lastSyncedAt: timestamp }); + }, []); + + const setUseExistingListsForClassification = useCallback((value: boolean) => { + persist({ ...cached, useExistingListsForClassification: value }); + }, []); + + const setAllowNewTagsWithExistingLists = useCallback((value: boolean) => { + persist({ ...cached, allowNewTagsWithExistingLists: value }); + }, []); + + const setRefreshBeforeApply = useCallback((value: boolean) => { + persist({ ...cached, refreshBeforeApply: value }); + }, []); + + const setUiLanguage = useCallback((value: UiLanguagePreference) => { + persist({ ...cached, uiLanguage: value }); + }, []); + + return { + preferences: state, + setReadmeOptIn, + markOnboarded, + setPatToken, + setLastSyncedAt, + setUseExistingListsForClassification, + setAllowNewTagsWithExistingLists, + setRefreshBeforeApply, + setUiLanguage, + }; +} + +export function getPreferenceSnapshot(): Preferences { + return cached; +} diff --git a/src/app/styles/app.css b/src/app/styles/app.css new file mode 100644 index 0000000..ede6864 --- /dev/null +++ b/src/app/styles/app.css @@ -0,0 +1,631 @@ +:root { + color-scheme: light; + font-family: "Space Grotesk", system-ui, -apple-system, sans-serif; + line-height: 1.4; + font-weight: 400; + background-color: #f5f1e8; + color: #1e1b16; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: radial-gradient(circle at 20% 10%, #f8efe0 0%, #f5f1e8 45%, #efe7db 100%); + font-family: "Space Grotesk", system-ui, -apple-system, sans-serif; +} + +#root { + min-height: 100vh; +} + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 28px 40px; + border-bottom: 1px solid rgba(30, 27, 22, 0.08); +} + +.brand { + display: flex; + align-items: center; + gap: 16px; +} + +.brand-icon { + width: 44px; + height: 44px; + border-radius: 12px; + background: #f0b429; + display: grid; + place-items: center; + font-weight: 600; + color: #1e1b16; + box-shadow: 0 12px 24px rgba(240, 180, 41, 0.2); +} + +.brand-title { + font-size: 22px; + letter-spacing: 0.4px; + margin: 0; +} + +.brand-subtitle { + margin: 4px 0 0; + font-size: 13px; + color: rgba(30, 27, 22, 0.6); +} + +.header-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.button { + border: 1px solid rgba(30, 27, 22, 0.2); + background: #ffffff; + padding: 10px 16px; + border-radius: 999px; + font-size: 13px; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.button.primary { + background: #1e1b16; + color: #f5f1e8; + border-color: transparent; + box-shadow: 0 8px 20px rgba(30, 27, 22, 0.25); +} + +.button:active { + transform: scale(0.98); +} + +.app-main { + display: grid; + grid-template-columns: 280px 1fr 320px; + gap: 24px; + padding: 32px 40px 48px; + flex: 1; +} + +.panel { + background: rgba(255, 255, 255, 0.8); + border-radius: 24px; + padding: 20px; + box-shadow: 0 12px 40px rgba(30, 27, 22, 0.08); + backdrop-filter: blur(12px); +} + +.panel h2 { + font-size: 15px; + margin: 0 0 12px; + color: rgba(30, 27, 22, 0.7); + text-transform: uppercase; + letter-spacing: 1.2px; +} + +.list-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-radius: 12px; + margin-bottom: 8px; + background: #fffdf8; + border: 1px solid rgba(30, 27, 22, 0.08); + font-size: 14px; +} + +.list-item.active { + border-color: #f0b429; + box-shadow: 0 6px 16px rgba(240, 180, 41, 0.2); +} + +.repo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 16px; +} + +.filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.filter-group { + display: flex; + align-items: center; + gap: 8px; +} + +.filter-group label { + font-size: 12px; + color: rgba(30, 27, 22, 0.7); +} + +.filter-group select { + padding: 8px 10px; + border-radius: 10px; + border: 1px solid rgba(30, 27, 22, 0.2); + background: #fffdf8; + font-size: 12px; +} + +.filter-group.search { + margin-left: auto; +} + +.filter-group.search input { + padding: 8px 10px; + border-radius: 10px; + border: 1px solid rgba(30, 27, 22, 0.2); + background: #fffdf8; + font-size: 12px; + min-width: 220px; +} + +.checkbox { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: rgba(30, 27, 22, 0.7); +} + +.repo-card { + background: #fffdf8; + border-radius: 16px; + padding: 16px; + border: 1px solid rgba(30, 27, 22, 0.08); + display: flex; + flex-direction: column; + gap: 10px; + min-height: 180px; +} + +.repo-meta { + display: flex; + justify-content: space-between; + font-size: 12px; + color: rgba(30, 27, 22, 0.65); +} + +.repo-title { + font-weight: 600; + font-size: 15px; + margin: 0; +} + +.repo-desc { + font-size: 13px; + color: rgba(30, 27, 22, 0.65); + margin: 0; +} + +.tag { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + background: rgba(240, 180, 41, 0.18); + color: #1e1b16; + margin-right: 6px; +} + +.sidebar-meta { + display: flex; + flex-direction: column; + gap: 12px; +} + +.meta-card { + background: #fffdf8; + border-radius: 16px; + padding: 16px; + border: 1px solid rgba(30, 27, 22, 0.08); +} + +.meta-card .button { + margin-top: 12px; +} + +.meta-title { + margin: 0 0 8px; + font-size: 14px; +} + +.meta-desc { + margin: 0; + font-size: 12px; + color: rgba(30, 27, 22, 0.6); +} + +.drawer { + position: fixed; + inset: 0; + background: rgba(30, 27, 22, 0.4); + display: grid; + place-items: center; + z-index: 40; +} + +.drawer-panel { + width: min(560px, 92vw); + background: #fffdf8; + border-radius: 20px; + padding: 24px; + box-shadow: 0 18px 50px rgba(30, 27, 22, 0.2); + position: relative; +} + +.drawer-panel.settings { + max-height: 85vh; + overflow: visible; + display: flex; + flex-direction: column; +} + +.settings-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding-right: 8px; + padding-bottom: 16px; + border-radius: 16px; +} + +.drawer-panel h3 { + margin: 0 0 8px; +} + +.drawer-panel p { + margin: 0 0 16px; + font-size: 14px; + color: rgba(30, 27, 22, 0.7); +} + +.drawer-panel p + p { + margin-top: -6px; +} + +.settings-section { + margin-top: 20px; + padding-top: 14px; + border-top: 1px solid rgba(30, 27, 22, 0.08); +} + +.settings-section h4 { + margin: 0 0 10px; + font-size: 14px; +} + +.settings-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 10px; +} + +.settings-actions .button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.input-row.inline { + display: flex; + gap: 12px; +} + +.input-row.inline > div { + flex: 1; +} + +.helper-text { + font-size: 12px; + color: rgba(30, 27, 22, 0.6); + margin-top: 8px; +} + +.prompt-mode-card { + border: 1px solid rgba(240, 180, 41, 0.35); + background: rgba(240, 180, 41, 0.12); + border-radius: 12px; + padding: 10px 12px; + margin: 10px 0 12px; +} + +.prompt-mode-title { + margin: 0; + font-size: 12px; + color: rgba(30, 27, 22, 0.7); + font-weight: 600; +} + +.prompt-mode-value { + margin: 4px 0 0; + font-size: 12px; + color: rgba(30, 27, 22, 0.72); +} + +.advanced-prompts { + margin-top: 10px; + padding: 10px 12px; + border: 1px dashed rgba(30, 27, 22, 0.2); + border-radius: 12px; + background: rgba(255, 255, 255, 0.6); +} + +.advanced-prompts summary { + cursor: pointer; + font-size: 12px; + color: rgba(30, 27, 22, 0.75); + font-weight: 600; + list-style: none; +} + +.advanced-prompts[open] summary { + margin-bottom: 8px; +} + +.writeback-summary-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.writeback-summary-item { + border: 1px solid rgba(30, 27, 22, 0.12); + border-radius: 10px; + background: #fffdf8; + padding: 10px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.writeback-summary-label { + font-size: 11px; + color: rgba(30, 27, 22, 0.62); + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.writeback-summary-value { + font-size: 16px; + font-weight: 600; + color: #1e1b16; +} + +.writeback-issues { + display: grid; + gap: 8px; + max-height: 220px; + overflow-y: auto; + padding-right: 4px; +} + +.writeback-issue { + border: 1px solid rgba(30, 27, 22, 0.12); + border-radius: 10px; + background: #fffdf8; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.writeback-issue-reason { + font-size: 11px; + color: rgba(30, 27, 22, 0.6); + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.writeback-issue-msg { + font-size: 12px; + color: rgba(30, 27, 22, 0.76); +} + +.assign-list-grid { + display: grid; + gap: 8px; + max-height: 320px; + overflow-y: auto; +} + +.assign-list-item { + display: flex; + align-items: center; + gap: 8px; + border: 1px solid rgba(30, 27, 22, 0.12); + border-radius: 10px; + background: #fffdf8; + padding: 8px 10px; + font-size: 13px; + color: rgba(30, 27, 22, 0.78); +} + +.writeback-preview-list { + display: grid; + gap: 10px; + max-height: 320px; + overflow-y: auto; + padding-right: 4px; +} + +.writeback-preview-item { + border: 1px solid rgba(30, 27, 22, 0.12); + border-radius: 10px; + background: #fffdf8; + padding: 10px; +} + +.writeback-preview-title { + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; +} + +.writeback-preview-row { + display: flex; + gap: 8px; + font-size: 12px; + color: rgba(30, 27, 22, 0.72); + margin-top: 4px; +} + +.writeback-preview-label { + min-width: 52px; + font-weight: 600; +} + +.preview-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} + +.preview-card { + border: 1px solid rgba(30, 27, 22, 0.1); + border-radius: 12px; + padding: 12px; + background: #fffdf8; +} + +.preview-title { + font-size: 13px; + font-weight: 600; + margin-bottom: 8px; +} + +.preview-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + font-size: 12px; + color: rgba(30, 27, 22, 0.7); +} + +.diff-grid { + display: grid; + gap: 12px; + margin-top: 12px; +} + +.diff-card { + border: 1px solid rgba(30, 27, 22, 0.1); + border-radius: 12px; + padding: 12px; + background: #fffdf8; +} + +.diff-row { + display: flex; + gap: 8px; + font-size: 12px; + color: rgba(30, 27, 22, 0.7); + margin-top: 6px; +} + +.diff-label { + font-weight: 600; + min-width: 70px; +} + +.empty-state { + padding: 18px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.7); + border: 1px dashed rgba(30, 27, 22, 0.2); + font-size: 13px; + color: rgba(30, 27, 22, 0.7); +} + +.drawer-actions { + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.input-row { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} + +.input-row input { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid rgba(30, 27, 22, 0.2); + font-size: 13px; +} + +.input-row textarea { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid rgba(30, 27, 22, 0.2); + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + resize: vertical; +} + +.input-row select { + padding: 8px 10px; + border-radius: 10px; + border: 1px solid rgba(30, 27, 22, 0.2); + background: #fffdf8; + font-size: 12px; +} + +.input-row label { + font-size: 12px; + color: rgba(30, 27, 22, 0.7); +} + +@media (max-width: 1100px) { + .app-main { + grid-template-columns: 1fr; + } + + .writeback-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +.panel-close { + position: absolute; + top: -22px; + right: -22px; + width: 36px; + height: 36px; + border-radius: 999px; + border: 1px solid rgba(30, 27, 22, 0.2); + background: #fffdf8; + cursor: pointer; + font-size: 16px; + display: grid; + place-items: center; + box-shadow: 0 10px 24px rgba(30, 27, 22, 0.12); + z-index: 5; +} + +.panel-close:hover { + transform: translateY(-1px); +} diff --git a/src/app/styles/fonts.css b/src/app/styles/fonts.css new file mode 100644 index 0000000..b0273be --- /dev/null +++ b/src/app/styles/fonts.css @@ -0,0 +1 @@ +@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&display=swap"); diff --git a/src/app/ui/ApplyUpdatesModal.tsx b/src/app/ui/ApplyUpdatesModal.tsx new file mode 100644 index 0000000..f167340 --- /dev/null +++ b/src/app/ui/ApplyUpdatesModal.tsx @@ -0,0 +1,313 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + applyWriteback, + planWriteback, + type WritebackPlan, + type WritebackProgress, + type WritebackResult, +} from "../core/githubWriteback"; +import { usePreferenceStore } from "../store/preferences"; + +type ApplyUpdatesModalProps = { + isOpen: boolean; + onClose: () => void; + token: string; + onRequestToken: () => void; +}; + +const EMPTY_PROGRESS: WritebackProgress = { + stage: "idle", + current: 0, + total: 0, + failed: 0, + skipped: 0, +}; + +export function ApplyUpdatesModal({ + isOpen, + onClose, + token, + onRequestToken, +}: ApplyUpdatesModalProps) { + const { t } = useTranslation(); + const { preferences, setRefreshBeforeApply } = usePreferenceStore(); + const [plan, setPlan] = useState(null); + const [result, setResult] = useState(null); + const [progress, setProgress] = useState(EMPTY_PROGRESS); + const [isPlanning, setIsPlanning] = useState(false); + const [isApplying, setIsApplying] = useState(false); + const [error, setError] = useState(""); + const requestSeqRef = useRef(0); + + const summary = useMemo(() => { + const activePlan = result?.plan ?? plan; + if (!activePlan) return null; + return { + actionableRepos: activePlan.actionableRepos, + unchangedRepos: activePlan.unchangedRepos, + validTaggedRepos: activePlan.validTaggedRepos, + unstarredRepos: activePlan.unstarredActionableRepos, + missingLists: activePlan.missingLists.length, + createdLists: activePlan.createdLists.length, + }; + }, [plan, result]); + + useEffect(() => { + if (!isOpen) { + requestSeqRef.current += 1; + setPlan(null); + setResult(null); + setProgress(EMPTY_PROGRESS); + setIsPlanning(false); + setIsApplying(false); + setError(""); + } + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + const trimmedToken = token.trim(); + if (!trimmedToken) { + setPlan(null); + setResult(null); + setProgress(EMPTY_PROGRESS); + setIsPlanning(false); + setError(t("apply.errors.missingPat")); + return; + } + + const requestId = ++requestSeqRef.current; + setIsPlanning(true); + setError(""); + setResult(null); + setProgress({ + stage: "preparing_preview", + current: 0, + total: 1, + failed: 0, + skipped: 0, + }); + + planWriteback({ token: trimmedToken }, {}, setProgress) + .then((nextPlan) => { + if (requestId !== requestSeqRef.current) return; + setPlan(nextPlan); + }) + .catch((err) => { + if (requestId !== requestSeqRef.current) return; + setError((err as Error).message || t("apply.errors.previewFailed")); + }) + .finally(() => { + if (requestId !== requestSeqRef.current) return; + setIsPlanning(false); + }); + }, [isOpen, token, t]); + + if (!isOpen) return null; + + const ensureToken = (): boolean => { + if (token.trim()) return true; + setError(t("apply.errors.missingPat")); + onRequestToken(); + return false; + }; + + const handleApply = async () => { + if (!ensureToken()) return; + if (!preferences.refreshBeforeApply && !plan) { + setError(t("apply.errors.previewNotReady")); + return; + } + const requestId = ++requestSeqRef.current; + setIsApplying(true); + setError(""); + setResult(null); + setProgress({ + stage: "preparing_writeback", + current: 0, + total: 1, + failed: 0, + skipped: 0, + }); + + try { + const nextResult = await applyWriteback( + { token: token.trim() }, + { + createMissingListsAsPrivate: false, + reusePlan: preferences.refreshBeforeApply ? null : plan, + resolveAutoStarForUnstarred: (unstarredCount) => + window.confirm(t("apply.confirmUnstarred", { count: unstarredCount })), + }, + setProgress + ); + if (requestId !== requestSeqRef.current) return; + setResult(nextResult); + setPlan(nextResult.plan); + } catch (err) { + if (requestId !== requestSeqRef.current) return; + setError((err as Error).message || t("apply.errors.applyFailed")); + } finally { + if (requestId !== requestSeqRef.current) return; + setIsApplying(false); + } + }; + + const disabled = isPlanning || isApplying; + const issues = result ? [...result.failed, ...result.skipped] : (plan?.skipped ?? []); + const changePreviewRows = (result?.plan.candidates ?? plan?.candidates ?? []).slice(0, 80); + + return ( +
+
+ +
+

{t("apply.title")}

+

{t("apply.description")}

+ +
+

{t("apply.sections.runControls")}

+ +

+ {preferences.refreshBeforeApply + ? t("apply.controls.refreshOn") + : t("apply.controls.refreshOff")} +

+
+ +
+
+ +
+

{t("apply.sections.progress")}

+

+ {t("common.labels.stage")}: {t(`apply.stage.${progress.stage}`, { defaultValue: progress.stage })} +

+

+ {t("common.labels.progress")}: {progress.current}/{progress.total} · {t("apply.labels.failed")}: {progress.failed} · {t("apply.labels.skipped")}: {progress.skipped} +

+
+ + {summary ? ( +
+

{t("apply.sections.planSummary")}

+
+
+ {t("apply.labels.actionable")} + {summary.actionableRepos} +
+
+ {t("apply.labels.unchanged")} + {summary.unchangedRepos} +
+
+ {t("apply.labels.validTagged")} + {summary.validTaggedRepos} +
+
+ {t("apply.labels.unstarred")} + {summary.unstarredRepos} +
+
+ {t("apply.labels.missingLists")} + {summary.missingLists} +
+
+ {t("apply.labels.createdLists")} + {summary.createdLists} +
+
+ {plan?.scanFailures ? ( +

+ {t("apply.status.scanFailures", { count: plan.scanFailures })} +

+ ) : null} +
+ ) : null} + + {changePreviewRows.length > 0 ? ( +
+

{t("apply.sections.plannedRepoChanges")}

+
+ {changePreviewRows.map((row) => ( +
+
{row.repoFullName}
+
+ {t("apply.labels.current")} + {row.currentListNames.join(", ") || t("common.values.none")} +
+
+ {t("apply.labels.after")} + {row.finalListNames.join(", ") || t("common.values.none")} +
+
+ ))} +
+
+ ) : plan ? ( +
+

{t("apply.sections.plannedRepoChanges")}

+

{t("apply.status.noChangesDetected")}

+
+ ) : null} + + {result ? ( +
+

{t("apply.sections.applyResult")}

+

+ {t("apply.labels.applied")}: {result.applied} +

+

+ {t("apply.labels.locallyUpdated")}: {result.locallyUpdated} +

+

+ {t("apply.labels.failed")}: {result.failed.length} +

+

+ {t("apply.labels.skipped")}: {result.skipped.length} +

+
+ ) : null} + + {issues.length > 0 ? ( +
+

{t("apply.sections.issues")}

+
+ {issues.slice(0, 60).map((issue, index) => ( +
+ + {t(`apply.issueReasons.${issue.reason}`, { defaultValue: issue.reason })} + + + {issue.repoFullName ? `${issue.repoFullName}: ` : ""} + {issue.message} + +
+ ))} +
+
+ ) : null} + + {error ? ( +

+ {t("common.labels.error")}: {error} +

+ ) : null} +
+
+
+ ); +} diff --git a/src/app/ui/AssignListModal.tsx b/src/app/ui/AssignListModal.tsx new file mode 100644 index 0000000..f1357af --- /dev/null +++ b/src/app/ui/AssignListModal.tsx @@ -0,0 +1,104 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { setRepoListMembership } from "../core/repoListAssignments"; +import { db } from "../data/db"; +import { useLiveQuery } from "../data/useLiveQuery"; + +type AssignListModalProps = { + isOpen: boolean; + repoId: string; + repoName: string; + onClose: () => void; +}; + +export function AssignListModal({ isOpen, repoId, repoName, onClose }: AssignListModalProps) { + const { t } = useTranslation(); + const [selectedListIds, setSelectedListIds] = useState([]); + const [isSaving, setIsSaving] = useState(false); + const [status, setStatus] = useState(""); + + const lists = useLiveQuery( + async () => db.lists.orderBy("name").toArray(), + [], + [] + ); + + const repoList = useLiveQuery( + async () => db.repoLists.get(repoId), + [repoId], + undefined + ); + + useEffect(() => { + if (!isOpen) return; + setSelectedListIds(repoList?.listIds ?? []); + setStatus(""); + }, [isOpen, repoId, repoList?.listIds]); + + const selectedSet = useMemo(() => new Set(selectedListIds), [selectedListIds]); + + if (!isOpen) return null; + + const toggleList = (listId: string) => { + setSelectedListIds((prev) => { + const has = prev.includes(listId); + if (has) return prev.filter((id) => id !== listId); + return [...prev, listId]; + }); + }; + + const handleSave = async () => { + setIsSaving(true); + setStatus(""); + try { + await setRepoListMembership(repoId, selectedListIds); + setStatus(t("assignList.status.saved")); + } catch (error) { + setStatus((error as Error).message || t("assignList.status.saveFailed")); + } finally { + setIsSaving(false); + } + }; + + return ( +
+
+ +
+

{t("assignList.title")}

+

{t("assignList.description", { repoName })}

+
+

{t("assignList.sections.lists")}

+ {lists.length === 0 ? ( +

{t("assignList.status.noLists")}

+ ) : ( +
+ {lists.map((list) => ( + + ))} +
+ )} +
+ + +
+ {status ?

{status}

: null} +
+
+
+
+ ); +} diff --git a/src/app/ui/FirstRunPrompt.tsx b/src/app/ui/FirstRunPrompt.tsx new file mode 100644 index 0000000..1240e47 --- /dev/null +++ b/src/app/ui/FirstRunPrompt.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from "react-i18next"; + +type FirstRunPromptProps = { + onConfirm: (enableReadme: boolean) => void; + onSkip: () => void; +}; + +export function FirstRunPrompt({ onConfirm, onSkip }: FirstRunPromptProps) { + const { t } = useTranslation(); + + return ( +
+
+

{t("onboarding.title")}

+

{t("onboarding.description1")}

+

{t("onboarding.description2")}

+
+ + +
+
+
+ ); +} diff --git a/src/app/ui/LlmClassificationModal.tsx b/src/app/ui/LlmClassificationModal.tsx new file mode 100644 index 0000000..ca908c7 --- /dev/null +++ b/src/app/ui/LlmClassificationModal.tsx @@ -0,0 +1,331 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { runTwoStageClassification } from "../core/llmClassification"; +import { db } from "../data/db"; +import { useLiveQuery } from "../data/useLiveQuery"; +import { normalizeAppLanguage, toPromptLanguage } from "../i18n/language"; +import { useLlmConfigStore } from "../store/llmConfig"; +import { usePreferenceStore } from "../store/preferences"; + +type LlmClassificationModalProps = { + isOpen: boolean; + onClose: () => void; +}; + +export function LlmClassificationModal({ isOpen, onClose }: LlmClassificationModalProps) { + const { t, i18n } = useTranslation(); + const { config } = useLlmConfigStore(); + const { preferences } = usePreferenceStore(); + const [stage, setStage] = useState("idle"); + const [progress, setProgress] = useState({ current: 0, total: 0 }); + const [isRunning, setIsRunning] = useState(false); + const [error, setError] = useState(""); + const [isTestMode, setIsTestMode] = useState(false); + const [strictSingleTag, setStrictSingleTag] = useState(false); + const [selectedRunId, setSelectedRunId] = useState(null); + const [compareMode, setCompareMode] = useState<"existing" | "run">("existing"); + + const repos = useLiveQuery( + async () => db.repos.toArray(), + [], + [] + ); + + const classifications = useLiveQuery(async () => db.classifications.toArray(), [], []); + const lists = useLiveQuery(async () => db.lists.toArray(), [], []); + const repoLists = useLiveQuery(async () => db.repoLists.toArray(), [], []); + + const runs = useLiveQuery( + async () => db.classificationRuns.orderBy("createdAt").reverse().toArray(), + [], + [] + ); + + const selectedRunTags = useLiveQuery( + async () => { + if (!selectedRunId) return []; + return db.classificationTags.where("runId").equals(selectedRunId).toArray(); + }, + [selectedRunId], + [] + ); + + const previewRows = useMemo(() => { + const map = new Map(classifications.map((item) => [item.repoId, item.tags])); + return repos.slice(0, 12).map((repo) => ({ + id: repo.id, + name: repo.fullName, + tags: map.get(repo.id) ?? [], + })); + }, [repos, classifications]); + + const existingListMap = useMemo(() => { + const listMap = new Map(lists.map((list) => [list.id, list.name])); + const out = new Map(); + for (const row of repoLists) { + const names = row.listIds.map((listId) => listMap.get(listId)).filter(Boolean) as string[]; + out.set(row.repoId, names); + } + return out; + }, [lists, repoLists]); + + const diffRows = useMemo(() => { + if (compareMode === "run" && !selectedRunId) return []; + const runMap = new Map(selectedRunTags.map((item) => [item.repoId, item.tags])); + const currentMap = new Map(classifications.map((item) => [item.repoId, item.tags])); + const rows = repos.map((repo) => { + const current = currentMap.get(repo.id) ?? []; + const existing = existingListMap.get(repo.id) ?? []; + const previous = compareMode === "run" ? runMap.get(repo.id) ?? [] : existing; + const changed = previous.join("|") !== current.join("|"); + return { + id: repo.id, + name: repo.fullName, + previous, + current, + existing, + changed, + }; + }); + return rows.filter((row) => row.changed).slice(0, 30); + }, [repos, classifications, selectedRunTags, existingListMap, compareMode, selectedRunId]); + + if (!isOpen) return null; + + const handleRun = async () => { + if (!config.apiKey || !config.baseUrl) { + setError(t("classification.errors.missingConfig")); + return; + } + if (repos.length === 0) { + setError(t("classification.errors.noRepos")); + return; + } + const repoBatch = isTestMode ? repos.slice(0, 5) : repos; + const promptLanguage = toPromptLanguage( + normalizeAppLanguage(i18n.resolvedLanguage || i18n.language) + ); + setError(""); + setIsRunning(true); + setStage("preparing"); + setProgress({ current: 0, total: repoBatch.length }); + try { + const result = await runTwoStageClassification( + config, + repoBatch.map((repo) => ({ + id: repo.id, + fullName: repo.fullName, + description: repo.description, + topics: repo.topics, + language: repo.language, + readmeExcerpt: repo.readmeExcerpt, + existingLists: existingListMap.get(repo.id) ?? [], + })), + { + strictSingleTag, + pass1Prompt: config.pass1Prompt, + pass1StrictPrompt: config.pass1StrictPrompt, + pass2Prompt: config.pass2Prompt, + useExistingLists: preferences.useExistingListsForClassification, + allowNewTagsWithExistingLists: preferences.allowNewTagsWithExistingLists, + language: promptLanguage, + }, + (p) => { + setStage(p.stage); + setProgress({ current: p.current, total: p.total }); + } + ); + + await db.transaction( + "rw", + db.classifications, + db.tagCompression, + db.classificationRuns, + db.classificationTags, + async () => { + await db.classifications.clear(); + await db.tagCompression.clear(); + const now = new Date().toISOString(); + const rows = Object.entries(result.repoTags).map(([repoId, tags]) => ({ + repoId, + tags, + lastRunAt: now, + })); + await db.classifications.bulkPut(rows); + await db.tagCompression.bulkPut( + Object.entries(result.tagMap).map(([tag, compressedTag]) => ({ + tag, + compressedTag, + })) + ); + await db.classificationRuns.add({ + id: result.runId, + createdAt: now, + repoCount: repoBatch.length, + strictSingleTag, + testMode: isTestMode, + pass1Prompt: config.pass1Prompt || "", + pass1StrictPrompt: config.pass1StrictPrompt || "", + pass2Prompt: config.pass2Prompt || "", + }); + await db.classificationTags.bulkPut( + Object.entries(result.repoTags).map(([repoId, tags]) => ({ + id: `${result.runId}:${repoId}`, + runId: result.runId, + repoId, + tags, + })) + ); + } + ); + setStage("completed"); + setSelectedRunId(result.runId); + } catch (err) { + setError((err as Error).message || t("classification.errors.classificationFailed")); + setStage("failed"); + } finally { + setIsRunning(false); + } + }; + + return ( +
+
+ +
+

{t("classification.title")}

+

{t("classification.description")}

+ +
+

{t("classification.sections.runStatus")}

+

+ {t("common.labels.stage")}: {t(`classification.stage.${stage}`, { defaultValue: stage })} +

+

+ {t("common.labels.progress")}: {progress.current}/{progress.total} +

+ + + {error ? ( +

+ {t("common.labels.error")}: {error} +

+ ) : null} +
+ +
+
+ +
+

{t("classification.sections.preview")}

+ {previewRows.length === 0 ? ( +

{t("classification.status.noResults")}

+ ) : ( +
+ {previewRows.map((row) => ( +
+
{row.name}
+
+ {row.tags.length === 0 + ? t("common.values.unclassified") + : row.tags.map((tag) => ( + + {tag} + + ))} +
+
+ ))} +
+ )} +
+ +
+

{t("classification.sections.diffView")}

+
+ + +
+ {compareMode === "run" ? ( + runs.length === 0 ? ( +

{t("classification.status.noSavedRuns")}

+ ) : ( +
+ + +
+ ) + ) : null} + {compareMode === "run" && selectedRunId && diffRows.length === 0 ? ( +

{t("classification.status.noTagDiff")}

+ ) : null} + {compareMode === "existing" && diffRows.length === 0 ? ( +

{t("classification.status.noDiffFromExisting")}

+ ) : null} + {diffRows.length > 0 ? ( +
+ {diffRows.map((row) => ( +
+
{row.name}
+
+ {t("classification.labels.existing")} + {row.existing.join(", ") || t("common.values.none")} +
+ {compareMode === "run" ? ( +
+ {t("classification.labels.previous")} + {row.previous.join(", ") || t("common.values.unclassified")} +
+ ) : null} +
+ {t("classification.labels.current")} + {row.current.join(", ") || t("common.values.unclassified")} +
+
+ ))} +
+ ) : null} +
+
+
+
+ ); +} diff --git a/src/app/ui/PatModal.tsx b/src/app/ui/PatModal.tsx new file mode 100644 index 0000000..f024091 --- /dev/null +++ b/src/app/ui/PatModal.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +type PatModalProps = { + isOpen: boolean; + onClose: () => void; + onSave: (token: string) => void; +}; + +export function PatModal({ isOpen, onClose, onSave }: PatModalProps) { + const { t } = useTranslation(); + const [token, setToken] = useState(""); + + if (!isOpen) return null; + + return ( +
+
+

{t("patModal.title")}

+

{t("patModal.description")}

+
+ + setToken(event.target.value)} + placeholder="ghp_..." + /> +
+
+ + +
+
+
+ ); +} diff --git a/src/app/ui/SettingsModal.tsx b/src/app/ui/SettingsModal.tsx new file mode 100644 index 0000000..9333122 --- /dev/null +++ b/src/app/ui/SettingsModal.tsx @@ -0,0 +1,478 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { resolvePrompts, type PromptFieldKey } from "../core/llmPromptResolver"; +import { normalizeAppLanguage, toPromptLanguage, type UiLanguagePreference } from "../i18n/language"; +import { requestCompletion } from "../services/llmClient"; +import type { LLMConfig } from "../services/llmClient"; +import { validatePat } from "../services/githubAuth"; +import { useLlmConfigStore } from "../store/llmConfig"; +import { usePreferenceStore } from "../store/preferences"; + +type SettingsModalProps = { + isOpen: boolean; + onClose: () => void; +}; + +function getModeDescriptionKey(useExisting: boolean, allowNewTags: boolean): string { + if (!useExisting) { + return "settings.llm.modeBase"; + } + if (allowNewTags) { + return "settings.llm.modeExisting"; + } + return "settings.llm.modeExistingOnly"; +} + +export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { + const { t, i18n } = useTranslation(); + const { + preferences, + setReadmeOptIn, + setPatToken, + setUseExistingListsForClassification, + setAllowNewTagsWithExistingLists, + setRefreshBeforeApply, + setUiLanguage, + } = usePreferenceStore(); + const { config, updateConfig, clearConfig } = useLlmConfigStore(); + const [patTokenInput, setPatTokenInput] = useState(preferences.patToken); + const [patLoginInput, setPatLoginInput] = useState(preferences.viewerLogin); + const [status, setStatus] = useState(""); + const [testStatus, setTestStatus] = useState(""); + const [isSavingPat, setIsSavingPat] = useState(false); + const [isTesting, setIsTesting] = useState(false); + + const promptLanguage = toPromptLanguage(normalizeAppLanguage(i18n.resolvedLanguage || i18n.language)); + + const promptFieldMeta: Record = useMemo( + () => ({ + pass1Prompt: { label: t("settings.llm.promptLabels.pass1Prompt"), rows: 3 }, + pass1StrictPrompt: { label: t("settings.llm.promptLabels.pass1StrictPrompt"), rows: 2 }, + pass2Prompt: { label: t("settings.llm.promptLabels.pass2Prompt"), rows: 3 }, + pass1PromptWithExisting: { + label: t("settings.llm.promptLabels.pass1PromptWithExisting"), + rows: 3, + }, + pass1PromptNoNewWithExisting: { + label: t("settings.llm.promptLabels.pass1PromptNoNewWithExisting"), + rows: 3, + }, + pass1StrictPromptWithExisting: { + label: t("settings.llm.promptLabels.pass1StrictPromptWithExisting"), + rows: 2, + }, + pass1StrictNoNewWithExisting: { + label: t("settings.llm.promptLabels.pass1StrictNoNewWithExisting"), + rows: 2, + }, + pass2PromptWithExisting: { + label: t("settings.llm.promptLabels.pass2PromptWithExisting"), + rows: 3, + }, + pass1PromptZh: { label: t("settings.llm.promptLabels.pass1PromptZh"), rows: 3 }, + pass1StrictPromptZh: { + label: t("settings.llm.promptLabels.pass1StrictPromptZh"), + rows: 2, + }, + pass2PromptZh: { label: t("settings.llm.promptLabels.pass2PromptZh"), rows: 3 }, + pass1PromptWithExistingZh: { + label: t("settings.llm.promptLabels.pass1PromptWithExistingZh"), + rows: 3, + }, + pass1PromptNoNewWithExistingZh: { + label: t("settings.llm.promptLabels.pass1PromptNoNewWithExistingZh"), + rows: 3, + }, + pass1StrictPromptWithExistingZh: { + label: t("settings.llm.promptLabels.pass1StrictPromptWithExistingZh"), + rows: 2, + }, + pass1StrictNoNewWithExistingZh: { + label: t("settings.llm.promptLabels.pass1StrictNoNewWithExistingZh"), + rows: 2, + }, + pass2PromptWithExistingZh: { + label: t("settings.llm.promptLabels.pass2PromptWithExistingZh"), + rows: 3, + }, + }), + [t] + ); + + const resolvedPrompts = useMemo( + () => + resolvePrompts(config, { + strictSingleTag: false, + useExistingLists: preferences.useExistingListsForClassification, + allowNewTagsWithExistingLists: preferences.allowNewTagsWithExistingLists, + language: promptLanguage, + }), + [ + config, + preferences.useExistingListsForClassification, + preferences.allowNewTagsWithExistingLists, + promptLanguage, + ] + ); + + const activePromptFields = useMemo( + () => [ + { + id: "llm-pass1-active", + key: resolvedPrompts.activeKeys.pass1, + label: t("settings.llm.activePass1"), + rows: promptFieldMeta[resolvedPrompts.activeKeys.pass1].rows, + value: resolvedPrompts.pass1, + }, + { + id: "llm-pass1-strict-active", + key: resolvedPrompts.activeKeys.pass1Strict, + label: t("settings.llm.activePass1Strict"), + rows: promptFieldMeta[resolvedPrompts.activeKeys.pass1Strict].rows, + value: resolvedPrompts.pass1Strict, + }, + { + id: "llm-pass2-active", + key: resolvedPrompts.activeKeys.pass2, + label: t("settings.llm.activePass2"), + rows: promptFieldMeta[resolvedPrompts.activeKeys.pass2].rows, + value: resolvedPrompts.pass2, + }, + ], + [resolvedPrompts, promptFieldMeta, t] + ); + + const inactivePromptFields = useMemo( + () => + resolvedPrompts.inactiveKeys.map((key) => ({ + key, + id: `llm-${key}`, + label: promptFieldMeta[key].label, + rows: promptFieldMeta[key].rows, + value: config[key] || "", + })), + [config, promptFieldMeta, resolvedPrompts] + ); + + const modeDescription = useMemo( + () => + t( + getModeDescriptionKey( + preferences.useExistingListsForClassification, + preferences.allowNewTagsWithExistingLists + ) + ), + [ + preferences.useExistingListsForClassification, + preferences.allowNewTagsWithExistingLists, + t, + ] + ); + + useEffect(() => { + if (!isOpen) return; + setPatTokenInput(preferences.patToken); + setPatLoginInput(preferences.viewerLogin); + }, [isOpen, preferences.patToken, preferences.viewerLogin]); + + if (!isOpen) return null; + + const handleTest = async () => { + if (!config.baseUrl.trim()) { + setTestStatus(t("settings.llm.missingBaseUrl")); + return; + } + if (!config.apiKey.trim()) { + setTestStatus(t("settings.llm.missingApiKey")); + return; + } + setIsTesting(true); + setTestStatus(t("settings.llm.testing")); + const startedAt = performance.now(); + try { + const response = await requestCompletion( + config, + [ + { role: "system", content: "You are a helpful assistant." }, + { role: "user", content: "Reply with OK" }, + ] + ); + const elapsed = Math.round(performance.now() - startedAt); + setTestStatus(t("settings.llm.testOk", { elapsed, response })); + } catch (error) { + const elapsed = Math.round(performance.now() - startedAt); + const err = error as Error; + const name = err?.name || "Error"; + const message = err?.message || "Test failed"; + setTestStatus(t("settings.llm.testError", { elapsed, name, message })); + } finally { + setIsTesting(false); + } + }; + + const handleSavePat = async () => { + const token = patTokenInput.trim(); + if (!token) { + setStatus(t("settings.pat.required")); + return; + } + setIsSavingPat(true); + setStatus(t("settings.pat.validating")); + try { + const viewer = await validatePat(token); + setPatToken(token, viewer.login); + setPatLoginInput(viewer.login); + setStatus(t("settings.pat.validated", { login: viewer.login })); + } catch (error) { + const err = error as Error; + const name = err?.name || "Error"; + const message = err?.message || "Validation failed"; + setStatus(t("settings.pat.validationFailed", { name, message })); + } finally { + setIsSavingPat(false); + } + }; + + const updatePromptField = (key: PromptFieldKey, value: string) => { + updateConfig({ [key]: value } as Partial); + }; + + const promptLanguageLabel = + promptLanguage === "zh" + ? t("settings.language.promptLanguageChinese") + : t("settings.language.promptLanguageEnglish"); + + return ( +
+
+ +
+

{t("settings.title")}

+

{t("settings.description")}

+ +
+

{t("settings.sections.language")}

+
+ + +
+
+ +
+

{t("settings.sections.patManagement")}

+
+ + setPatTokenInput(event.target.value)} + placeholder="ghp_..." + /> +
+
+ + setPatLoginInput(event.target.value)} + placeholder="octocat" + /> +
+
+ + +
+
+ +
+

{t("settings.sections.llmConfiguration")}

+ + +

+ {t("settings.language.promptLanguage", { language: promptLanguageLabel })} +

+
+

{t("settings.llm.currentMode")}

+

{modeDescription}

+
+
+ + updateConfig({ baseUrl: event.target.value })} + /> +
+
+ + updateConfig({ apiKey: event.target.value })} + /> +
+
+ + updateConfig({ model: event.target.value })} + /> +
+
+
+ + updateConfig({ temperature: Number(event.target.value) })} + /> +
+
+ + updateConfig({ maxTokens: Number(event.target.value) })} + /> +
+
+ {activePromptFields.map((field) => ( +
+ +