feat(dictionary): add Hachidori backend support
- Add backend selection, setup gating, Anki integration, and external host support - Add launcher flags, documentation, packaging, and focused tests - Open on-demand overlay modals on the first attempt
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Hachidori in SubMiner
|
||||
|
||||
This is a source snapshot of Hachidori at the revision in `SOURCE.json`.
|
||||
`UPSTREAM-README.md` is the original project introduction. The extension,
|
||||
WebAssembly bindings, engine source, build scripts and licenses are kept here
|
||||
so host changes stay local to SubMiner.
|
||||
|
||||
`bun run build:hachidori` verifies the committed engine artifacts and
|
||||
copies the extension to `build/hachidori`. The full app build includes this step,
|
||||
and Electron packaging puts the result in `resources/hachidori`.
|
||||
|
||||
## Local changes
|
||||
|
||||
- `extension/overlay-mode.js` enables embedded host behavior. Custom
|
||||
JavaScript is disabled because Electron does not provide `userScripts`.
|
||||
- `extension/subminer-host.js` implements the existing SubMiner popup event and
|
||||
command contract and prioritizes character-name results. `content.js` supplies
|
||||
popup lifecycle and reader actions.
|
||||
- `extension/anki-mining.js` marks initial add/overwrite requests for SubMiner's
|
||||
AnkiConnect proxy. Later Hachidori pronunciation updates remain unmarked so
|
||||
they do not repeat SubMiner media enrichment.
|
||||
- `extension/anki.js` sends those private markers only to the exact configured
|
||||
SubMiner proxy URL. Direct AnkiConnect requests use standard parameters.
|
||||
- `extension/manifest.json` loads the host bridge and drops `userScripts`.
|
||||
- The native `hdw_frequencies` binding and `hd_frequencies` engine message query
|
||||
frequency dictionaries without requiring a term dictionary. Frequency values
|
||||
retain their source reading, including whether a frequency was untagged.
|
||||
|
||||
- External dictionary links retain local Anki templates, audio sources, and custom buttons. Dictionary requests use the host; mining and media rendering use SubMiner. Setup uses Hachidori's native link/unlink messages and verifies the live host inventory.
|
||||
|
||||
## Rebuilding the engine
|
||||
|
||||
Ordinary app builds use the locally rebuilt WASM and JavaScript files with
|
||||
SHA-256 checksums in `SOURCE.json`. The current engine uses Emscripten 6.0.9.
|
||||
To rebuild them, install Emscripten and the
|
||||
CMake prerequisites described in `docs/source-build.md`. Use a temporary checkout
|
||||
of `https://github.com/bee-san/hachidori` at the exact `revision` in `SOURCE.json`,
|
||||
then run `git submodule update --init --recursive`. Copy this snapshot's
|
||||
`wasm/` and `third_party/hoshidicts/` sources into that temporary checkout,
|
||||
preserving the initialized external submodules. Run `sh wasm/build.sh` and,
|
||||
if changing the unused overlay capture encoder, `sh wasm/avif/build.sh`.
|
||||
|
||||
The snapshot's `third_party/hoshidicts` has its engine sources and `.gitmodules`;
|
||||
its external submodules are retrieved by that recursive checkout. The
|
||||
hoshidicts revision is pinned separately in `SOURCE.json`. The AVIF CMake file
|
||||
pins libavif. Copy rebuilt files from that temporary checkout's
|
||||
`extension/vendor/` only as an intentional source/artifact update and update
|
||||
the corresponding checksums. Do not overwrite the locally adapted extension.
|
||||
|
||||
Hachidori and its modifications are GPL-3.0-or-later, see `LICENSE`.
|
||||
The engine and bundled libraries retain their own license files.
|
||||
The packaged `extension/vendor/hoshidicts-licenses/` includes dependency and
|
||||
toolchain notices for the rebuilt engine.
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"repository": "https://github.com/bee-san/hachidori",
|
||||
"revision": "4be36f5e87ab946574d6c0279c6e7dd6fee36c45",
|
||||
"hoshidicts": {
|
||||
"repository": "https://github.com/bee-san/hoshidicts",
|
||||
"revision": "3066e606a75e2d79c2d2c1bc89d6c5bd2587f284"
|
||||
},
|
||||
"license": "GPL-3.0-or-later",
|
||||
"artifacts": {
|
||||
"extension/vendor/avif-encoder.mjs": "442a13571f3e8ee8117bc886e06ab52b4c5ebde7cb1659071fb498bfc0bea434",
|
||||
"extension/vendor/avif-encoder.wasm": "384b6299418d989cde5fd7660caf167aa8d4cfcedca53b2e73ddd086fc047797",
|
||||
"extension/vendor/hoshidicts-threaded-idbfs.mjs": "977cd9367539b922fc697522931d6943d6621b28897b16e754b413f15e64b8f7",
|
||||
"extension/vendor/hoshidicts-threaded-idbfs.wasm": "1e784283a8cd8c07d5eda64d7a10d6fb9cc526ac5a31c29805543d9efafe5059",
|
||||
"extension/vendor/hoshidicts-threaded.mjs": "688b3aad5ba3248609c8b3e7e3e1eee441454e51c3e691121eed254428abc6c5",
|
||||
"extension/vendor/hoshidicts-threaded.wasm": "3c78e56899d0f8f6fb60fa6af5ef85081d782014f15119516713e35ace9c97c6",
|
||||
"extension/vendor/hoshidicts.mjs": "e0116880d5fb7a3211c23aeec42d1d330326435260bf22a7e97053ba287b76f4",
|
||||
"extension/vendor/hoshidicts.wasm": "2d8b8fc5b5293f9e75d699416577f1ecb145156990fdcc6537c11793b95f231e"
|
||||
},
|
||||
"localChanges": [
|
||||
"Enable overlay mode and disable unsupported userScripts customization.",
|
||||
"Bridge popup state, successful lookups, mouse events and host commands to SubMiner.",
|
||||
"Mark initial Anki adds and overwrites for SubMiner media enrichment; background pronunciation enrichment stays unmarked.",
|
||||
"Expose native direct term/reading frequency queries, retaining reading-specific versus untagged frequency provenance in all three WASM runtimes.",
|
||||
"Prioritize SubMiner character glossaries and character-name lookup results in the host popup.",
|
||||
"Send private mining metadata only to the exact SubMiner-managed Anki proxy URL; direct AnkiConnect uses standard parameters."
|
||||
],
|
||||
"nativeBuild": {
|
||||
"emscripten": "6.0.9",
|
||||
"runtimes": [
|
||||
"threaded-opfs",
|
||||
"threaded-idbfs",
|
||||
"single-thread-idbfs"
|
||||
],
|
||||
"dependencies": {
|
||||
"zstd": "82d322c4973d9e2968d94047a40892bc6d9a9bdf",
|
||||
"xxHash": "c0b5ea995d66691734b1a79ad89e73a0d2fd5a53",
|
||||
"utfcpp": "c5585ef88b169a11891e03b5ca268d2925629399",
|
||||
"utf8proc": "0075ed7d0adba45682ee6bf7a83b10f8fd110163",
|
||||
"unordered_dense": "e5b9441ecf193f3e1e8f954527fc76edee20d7eb",
|
||||
"libdeflate": "b122c8be1d78b19f6d0a6efc5bb79bfcbb30dd51",
|
||||
"kanji-processor": "452cc2db9f3626a70bfb07c575c1165ae67cbdee",
|
||||
"glaze": "dda11044fb0cf4ec6b3bced58fcd8136a987e06b"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/hachidori.png" width="180" alt="Hachidori pink and lilac hummingbird logo">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Hachidori</h1>
|
||||
|
||||
<p align="center"><strong>The fastest, most feature rich Japanese dictionary app in the world</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-GPL--3.0--or--later-7c3aed" alt="GPL-3.0-or-later license"></a>
|
||||
<a href="#install-in-15-seconds"><img src="https://img.shields.io/badge/Chrome-128%2B-4285F4?logo=googlechrome&logoColor=white" alt="Chrome 128 or newer"></a>
|
||||
<a href="docs/privacy.md"><img src="https://img.shields.io/badge/dictionary_engine-local-0f766e" alt="Dictionary engine runs locally"></a>
|
||||
<a href="https://sonarcloud.io/summary/new_code?id=bee-san_hachidori"><img src="https://sonarcloud.io/api/project_badges/measure?project=bee-san_hachidori&metric=alert_status" alt="SonarQube Cloud quality gate"></a>
|
||||
<a href="https://github.com/bee-san/hachidori"><img src="https://img.shields.io/github/stars/bee-san/hachidori?style=flat&logo=github&color=f59e0b" alt="GitHub stars"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#install-in-15-seconds">Install</a> ·
|
||||
<a href="benchmark/README.md">Benchmarks</a> ·
|
||||
<a href="docs/architecture.md">Architecture</a> ·
|
||||
<a href="extension/README.md">Extension</a> ·
|
||||
<a href="docs/sharing.md">Sharing</a> ·
|
||||
<a href="docs/chrome-web-store.md">Chrome Web Store guide</a> ·
|
||||
<a href="docs/privacy.md">Privacy</a> ·
|
||||
<a href="CONTRIBUTING.md">Contributing</a>
|
||||
</p>
|
||||
|
||||
Hachidori is a blazing fast Japanese Dictionary Chrome Extension that is feature rich and optionated.
|
||||
|
||||
## Install in 15 seconds
|
||||
|
||||
Click <a href="https://chromewebstore.google.com/detail/hachidori/mikpaebfdmidjnopgffchicnmoahhcbe">here to install from Chrome store.</a> (note: this will always lag behind the repo and may have bugs fixed in the repo)
|
||||
|
||||
Every GitHub release also ships an unsigned Firefox 153+ desktop package
|
||||
(`*-firefox-unsigned.xpi`) for temporary installation; it is not on
|
||||
addons.mozilla.org yet. See [the Firefox guide](docs/firefox.md); media
|
||||
recording is not included in that edition.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/install-in-60-seconds.gif" alt="Animated walkthrough of Hachidori's first-run setup, dictionary installation, Anki detection, and Japanese lookup" width="720">
|
||||
</p>
|
||||
|
||||
# Blazing Fast
|
||||
|
||||
Hachidori is 83 times faster than the worlds most popular Japanese dictionary app at importing dictionaries.
|
||||
|
||||
<img width="1672" height="941" alt="ChatGPT Image Sep 9, 2026, 09_18_59 AM" src="https://github.com/user-attachments/assets/c507c940-f61e-4063-8d2c-9e43184cd7d3" />
|
||||
|
||||
See [the measured results](docs/browser-performance.md).
|
||||
|
||||
|
||||
# Media mining
|
||||
|
||||
<img width="917" height="362" alt="Screenshot 2026-09-09 at 11 26 31" src="https://github.com/user-attachments/assets/c0f6d1b5-187d-45b7-9bda-aadf32879586" />
|
||||
|
||||
Hachidori can record your screen and capture sentence audio + a gif. Not just in Chrome but in all windows on your desktop.
|
||||
|
||||
This feature is **experimental** and may not work very well. I may remove it or reduce it also.
|
||||
|
||||
See [Media mining setup, limits, and verification](docs/media-capture.md).
|
||||
|
||||
# Custom Dictionary
|
||||
|
||||
Do you keep on seeing a name pop up over & over again in a book, but it's not in the dictionary?
|
||||
|
||||
With Hachidori, you can highlight the word and add it as a custom definition.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/custom-dictionary.gif" alt="Animated demonstration of adding and viewing a custom dictionary definition in Hachidori" width="720">
|
||||
</p>
|
||||
|
||||
# Lookup blur
|
||||
|
||||
Sometimes we fall into a trap of looking up a word over & over again, but never learning it.
|
||||
|
||||
Hachidori records how many times you have looked up a word and can blur it for you for a few seconds to force you to remember it.
|
||||
|
||||
It can even use anki.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/lookup-blur.gif" alt="Animated demonstration of Hachidori blurring repeated lookups before revealing their definitions" width="720">
|
||||
</p>
|
||||
|
||||
# Sharing
|
||||
|
||||
Set up Hachidori once and use that setup from every other Hachidori, in GameSentenceMiner, another browser or another computer. Same dictionaries and settings used across multiple Hachidoris.
|
||||
|
||||
Install [Hachidori Relay for Anki](https://github.com/bee-san/hachidori-anki)
|
||||
from **Settings → Sharing → Download the Anki add-on**, then follow the
|
||||
[sharing guide](docs/sharing.md) to link your other browsers.
|
||||
|
||||
<img width="775" height="471" alt="Screenshot 2026-09-14 at 14 19 41" src="https://github.com/user-attachments/assets/991d570a-599e-4277-8736-c72b2f371ed0" />
|
||||
|
||||
|
||||
# Experimental features
|
||||
|
||||
**Settings → Advanced → Experimental features** switches on work that is still
|
||||
changing and may be removed:
|
||||
|
||||
- **Media mining** — the screen and audio capture above.
|
||||
- **Long dictionary entries** — find entries longer than the scan length
|
||||
(proverbs, titles) without scanning further on every hover.
|
||||
- **MDX dictionaries** — import MDict `.mdx` dictionaries with their `.mdd`
|
||||
resource files from **Add dictionaries**, next to Yomitan ZIPs. Choose the
|
||||
`.mdx` and its `.mdd` files together.
|
||||
|
||||
|
||||
# Opinionated
|
||||
|
||||
Hachidori is an opinionated program. If it does not benefit me, the creator, personally than I will not add that feature.
|
||||
|
||||
I do this because I am a pretty average learner, and if I make this tool great for myself than I am making it great for the average Japanese learner.
|
||||
|
||||
# AI Usage
|
||||
|
||||
This program was created with the assistance of AI. I used GPT 5.6 Ultra, and then GPT 6.0 Astra Ultra exclusively. When Codex goes down, I use Fable 5.1 with ultrathink and ultracode.
|
||||
|
||||
I have reviewed all plans, I set the direction of how this program works. Large parts of the program such as the actual dictionary core are hand-written.
|
||||
|
||||
I also have personally been using this for months, and as I am the main user of this program I find bugs pretty often which I fix.
|
||||
|
||||
The assets used are AI generated. If you are an artist and want to contribute to open source, please feel free to make a real logo or a visual novel style background.
|
||||
|
||||
## Credits
|
||||
|
||||
The logo pack and six visual novel backgrounds were supplied by bee-san. See the
|
||||
[asset ownership and publishing record](docs/asset-rights.md) for the original
|
||||
assets and their copyright declaration.
|
||||
|
||||
Hachidori is powered by [hoshidicts](https://github.com/Manhhao/hoshidicts) by Manhhao. Its popup renderer, structured-content renderer, furigana segmentation, and CSS are ported from [GameSentenceMiner PR #549](https://github.com/bpwhelan/GameSentenceMiner/pull/549), which adapts [Hoshi Reader](https://github.com/Manhhao/Hoshi-Reader) and [Yomitan](https://github.com/yomidevs/yomitan). See the full [renderer attribution](extension/render/ATTRIBUTION.md).
|
||||
|
||||
## License
|
||||
|
||||
Hachidori is available under [GPL-3.0-or-later](LICENSE), matching hoshidicts and the ported GameSentenceMiner code.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Asset ownership and publishing record
|
||||
|
||||
This record identifies the artwork supplied by Hachidori's repository owner,
|
||||
**bee-san**, for the extension, documentation and Chrome Web Store materials.
|
||||
The source files below were supplied on **September 8, 2026**.
|
||||
|
||||
## Visual novel backgrounds
|
||||
|
||||
**Copyright © 2026 bee-san.** The owner explicitly stated “i own the copyright”
|
||||
and directed Hachidori to use the image made at 6:29am, with that ownership
|
||||
recorded for Chrome publishing. This is the owner's declaration and
|
||||
authorization to include the artwork in Hachidori and its publishing materials.
|
||||
|
||||
- Original filename: `ChatGPT Image Sep 8, 2026, 06_29_20 AM.png`.
|
||||
- Selected image time: **September 8, 2026, 06:29:20am**, Europe/London.
|
||||
- Repository file: [`extension/assets/preview-background.webp`](../extension/assets/preview-background.webp).
|
||||
- Supplied format and dimensions: PNG, **1672 × 941**, **2,154,041 bytes**.
|
||||
- Supplied SHA-256: `84c3fd0ff5803596a5991b52a8ce2f3d2da5083393197112425f5f340bdca361`.
|
||||
- Usage: first-run practice and the Design preview, plus screenshots of those
|
||||
surfaces in documentation and store materials.
|
||||
|
||||
The supplied artwork bakes its own illustrated dialogue panel into the bottom of
|
||||
the frame; Hachidori never shows that panel, and places selectable Japanese text
|
||||
over the live scene above it. This replaces the earlier GSM preview background in
|
||||
the packaged extension.
|
||||
|
||||
The owner subsequently supplied the five additional scenes below and requested
|
||||
their use alongside the original in first-run practice and the Design preview,
|
||||
with a random starting scene and an arrow to cycle through them. This extends
|
||||
the record to those owner-supplied files and their authorized use in Hachidori
|
||||
and its publishing screenshots. Every supplied image is a **1672 × 941 PNG**.
|
||||
|
||||
| Repository file | Original filename | Supplied bytes |
|
||||
| --- | --- | ---: |
|
||||
| [`preview-background-2.webp`](../extension/assets/preview-background-2.webp) | `ChatGPT Image Sep 8, 2026, 07_16_27 AM.png` | 2,306,454 |
|
||||
| [`preview-background-3.webp`](../extension/assets/preview-background-3.webp) | `ChatGPT Image Sep 8, 2026, 07_16_31 AM.png` | 2,172,556 |
|
||||
| [`preview-background-4.webp`](../extension/assets/preview-background-4.webp) | `ChatGPT Image Sep 8, 2026, 07_16_38 AM.png` | 2,508,746 |
|
||||
| [`preview-background-5.webp`](../extension/assets/preview-background-5.webp) | `ChatGPT Image Sep 8, 2026, 07_16_43 AM.png` | 2,306,899 |
|
||||
| [`preview-background-6.webp`](../extension/assets/preview-background-6.webp) | `ChatGPT Image Sep 8, 2026, 07_18_10 AM.png` | 2,314,792 |
|
||||
|
||||
SHA-256 checksums of the supplied PNG files:
|
||||
|
||||
```text
|
||||
ac8c161853b335b7c28f5e3d68cf7bb11862072974dae805642af6258e6a608e preview-background-2.png
|
||||
95f23c4cd334a93f3c560dccf40ef7b40c7ea3d1188af5eb97a371b951e3d97f preview-background-3.png
|
||||
579182b5dd57fa306ab569238f8812ee5b7b214a1042fb1836596d9528c793ec preview-background-4.png
|
||||
35b57369c49ec315ac1c9b0d4aa9c732e86ab928f40ac68a1097f6dd8af6f692 preview-background-5.png
|
||||
6556de21bdd8fc3f8faced963b8936be85aaa443fdf296e746b259c3c233ea14 preview-background-6.png
|
||||
```
|
||||
|
||||
## Packaged form of the backgrounds
|
||||
|
||||
The extension only ever displays the top **1672 × 672** of each frame, so that
|
||||
crop is what Hachidori packages, encoded as WebP at quality 82: 970,930 bytes for
|
||||
all six, where the PNGs cost 13,413,193.
|
||||
|
||||
All six supplied files stay retrievable from this repository's history at commit
|
||||
`4abbcd3`, where their sizes and checksums are the ones recorded above. The PNGs
|
||||
the WebP files replace were those same images after ImgBot's lossless
|
||||
re-compression in `23f138c`, which is why the replaced bytes and checksums differ
|
||||
from the supplied ones while every pixel still matched.
|
||||
|
||||
| Packaged file | Bytes |
|
||||
| --- | ---: |
|
||||
| `preview-background.webp` | 147,244 |
|
||||
| `preview-background-2.webp` | 153,232 |
|
||||
| `preview-background-3.webp` | 145,796 |
|
||||
| `preview-background-4.webp` | 191,904 |
|
||||
| `preview-background-5.webp` | 178,482 |
|
||||
| `preview-background-6.webp` | 154,272 |
|
||||
|
||||
```text
|
||||
976b9f69104c66cbde4bc11e554ba3a1c9d1688493593621bba121d64d0066ba preview-background.webp
|
||||
d787d3b3a0025e025eeb31b5158b28787465293176cce41456adbdc5ab2f24f8 preview-background-2.webp
|
||||
52c66542fca697f226607cc4419c6ea333cb959663a327a3c1d73643f08eb449 preview-background-3.webp
|
||||
0f996a30dd3ae0657d14d4021e4a3f028cfd57875b37cc16b19baf97f2690527 preview-background-4.webp
|
||||
894588bd6b898c983d4d60abcaa2b08862a53df081d8f4ba2c0f898a08556f49 preview-background-5.webp
|
||||
2b715d36fb5ad2be6810ffaf322a8700083e8a8173702bffb9ba477c350caf17 preview-background-6.webp
|
||||
```
|
||||
|
||||
## Hachidori logo pack
|
||||
|
||||
The owner supplied `hachidori-logo-pack.zip` and requested its use for the README
|
||||
and other Hachidori branding. Archive SHA-256:
|
||||
`b34398f7eb27eb136247445b20a48d69606366c0f8e1f929280f670519228e21`.
|
||||
|
||||
The following files are copied unchanged from that pack:
|
||||
|
||||
| Project asset | Purpose |
|
||||
| --- | --- |
|
||||
| [`docs/assets/hachidori.png`](assets/hachidori.png) | Transparent 1024 × 1024 hummingbird mark in the README |
|
||||
| [`docs/assets/hachidori-icon.svg`](assets/hachidori-icon.svg) | Original vector mark |
|
||||
| [`docs/assets/hachidori-logo.svg`](assets/hachidori-logo.svg) | Stacked logo with outlined lettering |
|
||||
| [`docs/assets/hachidori-wordmark.svg`](assets/hachidori-wordmark.svg) | Horizontal logo with outlined lettering |
|
||||
| [`docs/assets/hachidori-app-icon.svg`](assets/hachidori-app-icon.svg) | App icon source |
|
||||
| [`extension/icons/hachidori-16.png`](../extension/icons/hachidori-16.png), [`32`](../extension/icons/hachidori-32.png), [`48`](../extension/icons/hachidori-48.png), [`128`](../extension/icons/hachidori-128.png) | Supplied icon sizes used by Chrome and the Settings/startup headers |
|
||||
|
||||
The SVG lettering is outlined and needs no external font. The pack's README
|
||||
describes these variants; it contains no separate license terms. This record
|
||||
documents the owner's instruction to use the supplied branding for Hachidori.
|
||||
|
||||
## Publishing reference
|
||||
|
||||
Use this record and the linked repository assets when identifying the artwork
|
||||
included in Hachidori's Chrome Web Store package, screenshots and branding.
|
||||
The packaged backgrounds also carry a short
|
||||
[ownership notice](../extension/assets/ATTRIBUTION.md).
|
||||
|
||||
Hachidori's software license remains [GPL-3.0-or-later](../LICENSE). Attribution
|
||||
for the imported dictionary renderer and other upstream code is maintained
|
||||
separately in [renderer attribution](../extension/render/ATTRIBUTION.md).
|
||||
@@ -0,0 +1,120 @@
|
||||
# Building a distributed source archive
|
||||
|
||||
Each release's `hachidori-<version>-<commit>-source.zip` includes Hachidori's
|
||||
tracked files, the complete recursive Hoshidicts submodules, and the pinned
|
||||
libavif, libaom and unminified zip.js sources. It contains no Git metadata and
|
||||
does not require access to a private repository. `SOURCE_REVISIONS.json`
|
||||
records the repository commits and downloaded dependency checksums.
|
||||
|
||||
The Chrome upload ZIP contains `LICENSE`, the dependency notices,
|
||||
`SOURCE.txt` and `SOURCE.json`. The source reference names the accompanying
|
||||
archive and its SHA-256. Publish that exact source archive at a public download
|
||||
location and put the location in the store listing before distributing the
|
||||
extension. A private GitHub repository link is not a substitute for that
|
||||
download.
|
||||
|
||||
## Load the JavaScript source
|
||||
|
||||
Extract the archive. Open `chrome://extensions`, enable **Developer mode**,
|
||||
choose **Load unpacked**, and select its `extension/` directory. Committed
|
||||
Wasm files are included, so JavaScript development needs no compiler.
|
||||
|
||||
## Rebuild the dictionary engine
|
||||
|
||||
Install [Emscripten](https://emscripten.org/docs/getting_started/downloads.html)
|
||||
with C++23 support, CMake 3.31 or newer, and a native build tool. Activate the
|
||||
SDK environment so `emcmake`, `emcc`, and `em++` are on `PATH`. Run these commands
|
||||
from the extracted archive's top-level directory:
|
||||
|
||||
```sh
|
||||
emcmake cmake -S wasm -B wasm/build -DCMAKE_BUILD_TYPE=Release -DHACHIDORI_PTHREADS=ON -DHACHIDORI_WASMFS=ON
|
||||
cmake --build wasm/build --parallel
|
||||
emcmake cmake -S wasm -B wasm/build-idbfs -DCMAKE_BUILD_TYPE=Release -DHACHIDORI_PTHREADS=ON -DHACHIDORI_WASMFS=OFF
|
||||
cmake --build wasm/build-idbfs --parallel
|
||||
emcmake cmake -S wasm -B wasm/build-fallback -DCMAKE_BUILD_TYPE=Release -DHACHIDORI_PTHREADS=OFF
|
||||
cmake --build wasm/build-fallback --parallel
|
||||
cp wasm/build/hoshidicts-threaded.mjs wasm/build/hoshidicts-threaded.wasm extension/vendor/
|
||||
cp wasm/build-idbfs/hoshidicts-threaded-idbfs.mjs wasm/build-idbfs/hoshidicts-threaded-idbfs.wasm extension/vendor/
|
||||
cp wasm/build-fallback/hoshidicts.mjs wasm/build-fallback/hoshidicts.wasm extension/vendor/
|
||||
```
|
||||
|
||||
The source archive already includes each CMake dependency under
|
||||
`third_party/hoshidicts/external/`; no submodule checkout is needed.
|
||||
|
||||
## Rebuild the AVIF encoder
|
||||
|
||||
Use the source archive's bundled dependencies to avoid fetching them during
|
||||
configuration:
|
||||
|
||||
```sh
|
||||
emcmake cmake -S wasm/avif -B wasm/avif/build -DCMAKE_BUILD_TYPE=MinSizeRel \
|
||||
-DFETCHCONTENT_SOURCE_DIR_LIBAVIF="$PWD/third_party/store-sources/libavif" \
|
||||
-DFETCHCONTENT_SOURCE_DIR_LIBAOM="$PWD/third_party/store-sources/libaom"
|
||||
cmake --build wasm/avif/build --parallel
|
||||
cp wasm/avif/build/avif-encoder.mjs wasm/avif/build/avif-encoder.wasm extension/vendor/
|
||||
```
|
||||
|
||||
The sources are libavif 1.3.0 at
|
||||
`1aadfad932c98c069a1204261b1856f81f3bc199` and libaom 3.12.1 at
|
||||
`10aece4157eb79315da205f39e19bf6ab3ee30d0`. Both libavif's small libyuv subset
|
||||
and libaom's internal dependencies are present in those archives. Other AVIF
|
||||
codecs and external libyuv are disabled by `wasm/avif/CMakeLists.txt`.
|
||||
|
||||
Packaging pins libaom's official release archive. Its source files and executable
|
||||
modes match that commit, while the Gitiles archive endpoint rewrites timestamps
|
||||
on each request and cannot provide a stable download checksum.
|
||||
|
||||
## zip.js and validation
|
||||
|
||||
`third_party/store-sources/zipjs/lib/` contains zip.js 2.11.2's editable source;
|
||||
its upstream `README.md` and `package.json` describe the library. The release
|
||||
uses its existing `dist/zip-core-external.min.js`, copied unchanged to
|
||||
`extension/vendor/zip.js`. No npm install or JavaScript build is needed to
|
||||
restore that shipped file:
|
||||
|
||||
```sh
|
||||
cp third_party/store-sources/zipjs/dist/zip-core-external.min.js extension/vendor/zip.js
|
||||
node test/make-fixture.mjs
|
||||
node test/node-smoke.mjs
|
||||
node test/extension-smoke.mjs
|
||||
```
|
||||
|
||||
See [the test guide](../test/README.md) for the browser suite and its external
|
||||
test dependencies. The packaging command verifies archive checksums and file
|
||||
integrity; it does not compile or run these runtime suites.
|
||||
|
||||
These instructions preserve the source revisions and build settings. The
|
||||
historical committed Wasm files do not record the exact compiler version, so
|
||||
they do not establish byte-for-byte reproduction of those binaries. Record
|
||||
`emcc --version`, `cmake --version`, build commands and test results whenever
|
||||
publishing newly built Wasm files.
|
||||
|
||||
## Produce a release pair from a Git checkout
|
||||
|
||||
Commit the intended release changes and initialize recursive submodules. With
|
||||
Python 3.9 or newer and Git installed:
|
||||
|
||||
```sh
|
||||
git submodule update --init --recursive
|
||||
python3 scripts/package-store.py --output-dir /tmp/hachidori-store
|
||||
```
|
||||
|
||||
The command refuses a dirty checkout, reads only committed Git objects, and
|
||||
writes the upload ZIP, source ZIP and `SHA256SUMS.txt` outside the repository.
|
||||
Its first run downloads only checksum-pinned dependency sources. Pass
|
||||
`--cache-dir /path/to/cache` to reuse them across machines or offline runs.
|
||||
ZIP paths, timestamps, order and permissions are normalized, giving identical
|
||||
bytes when repeated with the same commit and Python/zlib version. ZIP integrity
|
||||
is checked before checksums are written. Load unpacked from an extracted Chrome
|
||||
ZIP to check the exact staged runtime before uploading it.
|
||||
|
||||
CI runs this packaging command and verifies the checksums for every release
|
||||
candidate. The **Release** workflow supports package-only manual runs by default.
|
||||
Enabling **Publish** for a manual run requires an existing bare
|
||||
`<manifest.version>` release tag at the selected commit; the workflow uploads
|
||||
its assets and submits the Chrome package. Pushing `<manifest.version>` keeps
|
||||
the automatic release path. Both paths publish only after the version,
|
||||
minimum/current Chrome pins, archive integrity, and checksums pass. Chrome Web
|
||||
Store automation requires the service-account secret and the publisher and
|
||||
extension repository variables documented in
|
||||
[the publishing guide](chrome-web-store.md#publish-step-by-step).
|
||||
@@ -0,0 +1,186 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
|
||||
# The Hachidori extension
|
||||
|
||||
This folder is the shared extension source and the Manifest V3 package exactly
|
||||
as Chrome 128 or newer loads it, with no build step. The JavaScript is plain ES
|
||||
modules and classic scripts, the dictionary engine is committed WebAssembly
|
||||
under `vendor/`, and everything runs inside the browser. To run it in Chrome
|
||||
from a checkout, open `chrome://extensions`, turn on **Developer mode**, choose
|
||||
**Load unpacked** and select this folder. `scripts/package-store.py` zips this
|
||||
same folder, with the licence files, for the Chrome Web Store.
|
||||
|
||||
`manifest.firefox.json` is the reviewed Firefox MV2 manifest. The same
|
||||
packager writes the Firefox XPI from these sources minus the Chrome-only files
|
||||
listed in `scripts/firefox-package.json`, with that manifest in place of
|
||||
`manifest.json`; `scripts/prepare-firefox.mjs` stages the same layout in an
|
||||
ignored directory for lint and the Firefox smoke test. See the
|
||||
[Firefox guide](../docs/firefox.md) to build and temporarily install the
|
||||
unsigned XPI.
|
||||
|
||||
[The architecture guide](../docs/architecture.md) explains how the pieces
|
||||
work together and lists every runtime message and stored key. This page says
|
||||
where things are.
|
||||
|
||||
## Entry points
|
||||
|
||||
Drag a lookup popup's bottom-right corner to resize it. The size is shared by
|
||||
subsequent and nested lookups in that page, including after closing and reopening
|
||||
the popup. Reloading or navigating the page (or restarting the browser) starts a
|
||||
new reading session with the saved Design dimensions. Dragging does not change
|
||||
those saved settings or other tabs.
|
||||
|
||||
The popup action row is one non-wrapping keyboard and visual group: a nested
|
||||
Close or Back control first, then Anki, pronunciation, personal-dictionary
|
||||
edit, and custom buttons in saved order. A custom button opens a URL template
|
||||
or mines with a chosen Anki Template. Actions share a 36-pixel height and a
|
||||
5-pixel gap. At narrow popup widths the whole action row scrolls horizontally
|
||||
instead of wrapping, clipping, or overlapping controls. Browser mode opens link
|
||||
buttons in a Chrome tab; overlay mode asks its embedding host to open the same
|
||||
validated URL in the system browser.
|
||||
|
||||
`manifest.json` names them.
|
||||
|
||||
| File | Runs as | Role |
|
||||
| --- | --- | --- |
|
||||
| `background.js` | the service worker | Routes every runtime message and owns everything in `chrome.storage.local`: dictionary metadata, options, the personal dictionary, update schedules, lookup counts, automatic-backup metadata, first-run and sharing state. It also owns the alarms, the Anki gateway and the sharing host and client. It holds no engine state, so Chrome may stop it whenever it is idle. |
|
||||
| `firefox-background.html`, `firefox-background.js` | Firefox’s persistent MV2 background page | Loads the shared background module and hosts `offscreen.html` in one authenticated hidden iframe so the engine remains warm. |
|
||||
| `content.js`, with the classic scripts listed under `content_scripts` | every web page | Scans the Japanese text near the pointer, renders the popup in a closed shadow root through `render/popup.js` and `render/glossary.js`, and adds the popup's Anki and pronunciation controls (`anki-content.js`, `audio-content.js`). Chrome also injects `capture-content.js`; Firefox does not. `content.css` is the only style the page itself receives: the source highlight. |
|
||||
| `offscreen.html`, `offscreen.js` | Chrome’s offscreen document or Firefox’s hidden background iframe | Owns the dictionary engine. `engine-worker.js` runs the pthread build with direct OPFS once `opfs-capability-worker.js` has proved the browser can, `engine-worker-idbfs.js` runs the pthread build on IDBFS when the browser has shared memory but no OPFS access handles (Electron), both through `engine-worker-runtime.js`; `engine-service.js` is also the single-thread IDBFS fallback. Pronunciation, Anki and the first-run installer load here on demand. Chrome also hosts media capture here. |
|
||||
| `settings.html`, `settings.js` | the options page | Dictionaries, groups, updates, the personal dictionary, Reading, Design, pronunciation, Anki, keybinds, backup and sharing, with media capture where supported and global search. The larger sections have their own `*-settings.js` controller; `design-preview.html` is the live preview inside Design. |
|
||||
| `startup.html`, `startup.js` | a tab opened once after install | First-run setup: recommended dictionaries, Anki detection, a practice lookup, and the offer to use a Hachidori that another browser on this computer already shares. Overlay mode skips it. |
|
||||
| `toolbar.html`, `toolbar.js` | the toolbar button's popup | Turns lookups on and off, shows the sharing state and opens Settings. Chrome also exposes the recording action here. |
|
||||
| `capture.html`, `capture.js` | a Chrome-only tab opened from the toolbar or Settings | Controls media capture. The recorder itself, `capture-host.js`, runs in the offscreen document and keeps going when this tab closes. Firefox does not expose this entry point. |
|
||||
|
||||
`overlay-mode.js`, its `browser-api.js` dependency, `render/reader.css` and
|
||||
`icons.css` are the only files web pages may fetch
|
||||
(`web_accessible_resources`). The popup and its Anki controls load the two
|
||||
stylesheets; overlay hosts use the shared mode contract.
|
||||
|
||||
## Modules by feature
|
||||
|
||||
Files share a prefix with the feature they belong to. A rule that more than
|
||||
one context needs lives in a module with no Chrome dependency, so Settings,
|
||||
the service worker and both engine runtimes run the same code.
|
||||
|
||||
- **Dictionaries and stored state.** `reader-options.js` is the one stored
|
||||
options view every context reads. `dictionary-group-state.js` and
|
||||
`dictionary-groups.js` hold the group rules and their Settings controls,
|
||||
`dictionary-name-drafts.js` the autosaved names, `dictionary-progress.js`
|
||||
the import progress. `managed-dictionary-source.js` and
|
||||
`recommended-dictionaries.js` define the trusted update sources and the
|
||||
starter set; `custom-dictionary.js` the personal dictionary's source format
|
||||
and archive; `setup-state.js` the first-run stages and initial selections;
|
||||
`setup-installer.js` the offscreen recommended installer, observed from startup
|
||||
and Settings by `recommended-install-client.js`. `json-value.js` and `response-limits.js`
|
||||
are the comparison and size rules the transaction boundaries share.
|
||||
- **Lookup statistics.** `lookup-stats-identity.js`, a classic script so the
|
||||
content script can use it, and `lookup-stats.js`.
|
||||
- **Anki.** `anki.js` is the AnkiConnect gateway and `anki-setup.js`
|
||||
recognises an existing mining setup. `anki-templates.js`, `anki-values.js`,
|
||||
`anki-glossary.js`, `anki-pitch.js`, `anki-resources.js` and `anki-audio.js` build the note
|
||||
fields and media. Stored Anki Templates group each destination, note type,
|
||||
field mapping and duplicate policy; the first powers the built-in action and
|
||||
custom Anki buttons select the others by stable ID. Settings edits every
|
||||
field mapping through an accessible marker combobox while retaining the
|
||||
mapping string exactly. `anki-duplicates.js` and
|
||||
`anki-enrichment.js` handle a
|
||||
note that already exists; `anki-digest.js` hashes media.
|
||||
`anki-client-media.js` validates final screenshot, capture and browser-speech
|
||||
media crossing a linked-browser boundary. `anki-mining.js` and
|
||||
`anki-worker.js` are the mining service in the
|
||||
service worker. `anki-index.js` and `anki-index-cache.js` provide the shared
|
||||
scoped duplicate and maturity index, including cache-only View readiness and
|
||||
click-time live ID repair. `anki-offscreen.js` launches
|
||||
`anki-index-worker.js` for complete refreshes without moving note fields
|
||||
through the service worker.
|
||||
- **Pronunciation.** `audio-sources.js`, `audio-repository.js`,
|
||||
`audio-cache.js` and `audio-player.js` fetch, keep and play audio in the
|
||||
offscreen document (`audio-offscreen.js`); `speech.js` wraps the browser's
|
||||
text-to-speech.
|
||||
- **Media capture.** `capture-host.js` is the offscreen recorder.
|
||||
`capture-session.js`, `capture-buffer.js`, `capture-timeline.js` and
|
||||
`capture-speech.js` are its bounded buffers, occurrence timeline and speech
|
||||
detection. `capture-audio-worklet.js`, `capture-frame-client.js` with
|
||||
`capture-frame-worker.js`, and `capture-encoder-client.js` with
|
||||
`capture-encoder-worker.js` move audio sampling, frame grabbing and animated
|
||||
AVIF encoding (`avif-sequence.js`) off the main thread.
|
||||
`texthooker-protocol.js` parses the text a texthooker sends.
|
||||
- **Backup.** `backup-archive.js` is the manual ZIP format, `backup-state.js`
|
||||
the shared snapshot rules, `backup-automatic.js` the two-record daily
|
||||
retention, cadence and age rules, `backup-downloads.js` the pending downloads,
|
||||
and `backup-settings.js` the manual and automatic restore controls.
|
||||
- **Sharing.** `sharing-protocol.js` is the wire contract both sides import;
|
||||
`sharing-host.js` and `sharing-client.js` are the two roles in the service
|
||||
worker; `sharing-settings.js` is the Settings section. `anki-addon.js` pins
|
||||
and downloads the compatible `.ankiaddon` release from
|
||||
[hachidori-anki](https://github.com/bee-san/hachidori-anki), which owns the
|
||||
Python relay, its tests, and packaging.
|
||||
- **Pages.** `settings-search.js` and `settings-dom.js` serve Settings;
|
||||
`experimental-settings.js` renders the Advanced → Experimental features
|
||||
switches from the registry in `reader-options.js`;
|
||||
`keybind-settings.js`, `custom-button-settings.js` and `external-links.js`
|
||||
the keybinds and custom buttons in the popup; `local-file-access.js` the
|
||||
notice about Chrome's *Allow access to file URLs* permission;
|
||||
`startup-practice.js` the practice step. `visual-novel.js` and
|
||||
`visual-novel.css` draw the background scenes behind the startup page and
|
||||
the Design preview from the images in `assets/` (see
|
||||
`assets/ATTRIBUTION.md`); `design-preview.js` renders the preview from
|
||||
`sample-meal.svg` and local sample data.
|
||||
- **Renderer.** `render/` is the popup renderer ported from GameSentenceMiner,
|
||||
which adapts Hoshi Reader and Yomitan; `render/ATTRIBUTION.md` records what
|
||||
came from where.
|
||||
- **Overlay mode.** `overlay-mode.js` is the one switch a host such as the
|
||||
GameSentenceMiner overlay flips in its copy. It also defines the shared
|
||||
host-capability policy used by Settings, the toolbar, the reader and the
|
||||
service worker; see
|
||||
[overlay mode](../docs/overlay-mode.md).
|
||||
- **Vendored code.** `vendor/hoshidicts-threaded.{mjs,wasm}`,
|
||||
`vendor/hoshidicts-threaded-idbfs.{mjs,wasm}` and
|
||||
`vendor/hoshidicts.{mjs,wasm}` are the three builds of the hoshidicts engine
|
||||
from `wasm/build.sh`, `vendor/avif-encoder.{mjs,wasm}` the AVIF encoder
|
||||
from `wasm/avif/`, and `vendor/zip.js` the pinned zip.js runtime. They are
|
||||
committed build output: update them with their source change and otherwise
|
||||
leave them alone.
|
||||
- `icons/` holds the extension's icons.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Every script, stylesheet and page starts with an
|
||||
`SPDX-License-Identifier: GPL-3.0-or-later` line; the files under `render/`
|
||||
also keep their upstream copyright lines.
|
||||
- A rule the content script needs as well as the module contexts lives in a
|
||||
classic script that publishes one `globalThis.HD…` object
|
||||
(`HDReaderOptions`, `HDLookupStats`, `HDDictionaryGroups`, …); modules
|
||||
import such a file for its side effect.
|
||||
- Runtime messages are objects with a `target` and an `hd_*` `type`, and
|
||||
they carry explicit ids, revisions or generations so a stale reply fails
|
||||
closed. Stored values are revisioned and written only by the service
|
||||
worker; a page edits them by compare-and-set. The offscreen document never
|
||||
touches `chrome.storage` itself.
|
||||
- Nothing here is generated except `vendor/`. There is no bundler,
|
||||
transpiler or minifier: what is committed is what ships.
|
||||
|
||||
## Checking a change
|
||||
|
||||
```sh
|
||||
node test/make-fixture.mjs # writes the dictionary fixtures once
|
||||
node test/extension-smoke.mjs # this folder's JavaScript against the real engine, in Node
|
||||
node test/chrome-e2e.mjs # this folder loaded unpacked into a real Chrome
|
||||
```
|
||||
|
||||
[The test guide](../test/README.md) says what each suite proves and how to
|
||||
install the browser and jsdom they need; the validation list in
|
||||
[AGENTS.md](../AGENTS.md) says which checks each kind of change requires.
|
||||
Sharing changes have their own suites, listed in [sharing](../docs/sharing.md).
|
||||
|
||||
## More
|
||||
|
||||
- [Privacy](../docs/privacy.md): what leaves the browser, and when.
|
||||
- [Sharing](../docs/sharing.md), [overlay mode](../docs/overlay-mode.md),
|
||||
[media capture](../docs/media-capture.md),
|
||||
[the backup format](../docs/backup-format.md),
|
||||
[update schedules](../docs/update-schedules.md) and
|
||||
[lookup statistics](../docs/lookup-statistics.md) describe those features.
|
||||
- [Building a source archive](../docs/source-build.md) covers `wasm/` and
|
||||
the `third_party/hoshidicts` submodule behind `vendor/`.
|
||||
@@ -0,0 +1,13 @@
|
||||
// Pin the add-on independently so older extensions and vendored copies keep
|
||||
// downloading the relay they were tested with.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
export const ANKI_ADDON_FILE_NAME = "hachidori-relay.ankiaddon";
|
||||
export const ANKI_ADDON_VERSION = "0.0.4";
|
||||
export const ANKI_ADDON_URL = `https://github.com/bee-san/hachidori-anki/releases/download/v${ANKI_ADDON_VERSION}/${ANKI_ADDON_FILE_NAME}`;
|
||||
|
||||
export async function fetchAnkiAddon(request = globalThis.fetch) {
|
||||
const response = await request(ANKI_ADDON_URL);
|
||||
if (!response.ok) throw new Error(`GitHub returned HTTP ${response.status}. Try again.`);
|
||||
return response.blob();
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { selectedAudioPlan } from "./audio-repository.js";
|
||||
import { ankiMediaFilename } from "./anki-resources.js";
|
||||
|
||||
const MIME_EXTENSIONS = { "audio/aac": "aac", "audio/flac": "flac", "audio/mp4": "m4a", "audio/mpeg": "mp3",
|
||||
"audio/ogg": "ogg", "audio/wav": "wav", "audio/webm": "webm", "audio/x-wav": "wav", "application/ogg": "ogg" };
|
||||
|
||||
async function base64(window, blob, signal) {
|
||||
signal.throwIfAborted();
|
||||
const reader = new window.FileReader();
|
||||
let abort;
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
reader.onload = () => resolve(reader.result.slice(reader.result.indexOf(",") + 1));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
abort = () => { reader.abort(); reject(signal.reason); };
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abort);
|
||||
reader.onload = reader.onerror = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function candidateFile(window, repository, candidate, signal) {
|
||||
const lease = await repository.acquire(candidate, signal);
|
||||
let audio, abort;
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
audio = new window.Audio();
|
||||
audio.preload = "auto";
|
||||
await new Promise((resolve, reject) => {
|
||||
audio.onloadeddata = resolve;
|
||||
audio.onerror = () => { lease.invalidate(); reject(new Error("The pronunciation could not be decoded.")); };
|
||||
abort = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
audio.src = lease.url;
|
||||
audio.load();
|
||||
});
|
||||
signal.throwIfAborted();
|
||||
const suffix = new URL(candidate.url).pathname.split(".").at(-1).toLowerCase();
|
||||
const mime = lease.blob.type.split(";")[0].toLowerCase();
|
||||
const fallbackExtension = /^[a-z0-9]+$/u.test(suffix) ? suffix : "bin";
|
||||
const extension = Object.hasOwn(MIME_EXTENSIONS, mime) ? MIME_EXTENSIONS[mime] : fallbackExtension;
|
||||
const bytes = await lease.blob.arrayBuffer();
|
||||
signal.throwIfAborted();
|
||||
const filename = await ankiMediaFilename(bytes, extension);
|
||||
const data = await base64(window, lease.blob, signal);
|
||||
signal.throwIfAborted();
|
||||
return { filename, data, candidate };
|
||||
} finally {
|
||||
if (audio) {
|
||||
signal.removeEventListener("abort", abort);
|
||||
audio.onloadeddata = audio.onerror = null;
|
||||
audio.pause();
|
||||
audio.removeAttribute("src");
|
||||
audio.load();
|
||||
}
|
||||
lease.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function speechFile(window, recording, signal) {
|
||||
signal.throwIfAborted();
|
||||
if (!(recording?.data instanceof Uint8Array) || recording.data.length === 0) {
|
||||
throw new Error("Browser text-to-speech produced no captured WAV data.");
|
||||
}
|
||||
const filename = await ankiMediaFilename(recording.data, "wav");
|
||||
if (recording.filename !== undefined && recording.filename !== filename) {
|
||||
throw new Error("The linked browser-speech filename does not match its WAV data.");
|
||||
}
|
||||
const data = await base64(window, new Blob([recording.data], { type: "audio/wav" }), signal);
|
||||
signal.throwIfAborted();
|
||||
return { filename, data, candidate: recording.candidate };
|
||||
}
|
||||
|
||||
// Read-only discovery/decoding, separate from the playback owner. The returned
|
||||
// exact bytes and digest stay paired through duplicate check and later upload.
|
||||
export async function exportAnkiAudio(window, repository, {
|
||||
sources,
|
||||
term,
|
||||
selection,
|
||||
recordSpeech = true,
|
||||
}, signal, { recordSpeechAudio } = {}) {
|
||||
const plan = selection ? await selectedAudioPlan(repository, sources, term, selection, signal) : { sources };
|
||||
let failure;
|
||||
for (const source of plan.sources) {
|
||||
if (source.type.startsWith("text-to-speech")) {
|
||||
try {
|
||||
if (typeof recordSpeechAudio !== "function") {
|
||||
throw new Error("Browser text-to-speech recording is unavailable.");
|
||||
}
|
||||
const recorded = await recordSpeechAudio(source, term, signal, { record: recordSpeech });
|
||||
if (recorded?.recordingRequired === true) return recorded;
|
||||
return { ...await speechFile(window, recorded, signal), sourceId: source.id };
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
failure = error;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const candidates = plan.candidate ? [plan.candidate] : await repository.candidates(source, term, signal);
|
||||
for (const [index, candidate] of candidates.entries()) {
|
||||
try {
|
||||
return { ...await candidateFile(window, repository, { ...candidate, index: candidate.index ?? index }, signal), sourceId: source.id };
|
||||
} catch (error) { signal.throwIfAborted(); failure = error; }
|
||||
}
|
||||
} catch (error) { signal.throwIfAborted(); failure = error; }
|
||||
}
|
||||
throw failure ?? new Error("No downloadable pronunciation is available for this result.");
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import {
|
||||
MAX_ANIMATED_AVIF_BYTES,
|
||||
MAX_WAV_BYTES,
|
||||
} from "./media-limits.js";
|
||||
|
||||
export const MAX_LINKED_SCREENSHOT_BYTES = 6 * 1024 * 1024;
|
||||
export const MAX_LINKED_SPEECH_BYTES = MAX_WAV_BYTES;
|
||||
export const CAPTURE_FILENAMES = Object.freeze({
|
||||
animation: /^hachidori-[a-z0-9]+\.avif$/u,
|
||||
audio: /^hachidori-[a-z0-9]+\.wav$/u,
|
||||
});
|
||||
export const CAPTURE_LIMITS = Object.freeze({
|
||||
animation: MAX_ANIMATED_AVIF_BYTES,
|
||||
audio: MAX_WAV_BYTES,
|
||||
});
|
||||
const SCREENSHOT_FILENAME = /^hachidori-screenshot-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.jpg$/u;
|
||||
const SPEECH_FILENAME = /^hachidori_[0-9a-f]{64}\.wav$/u;
|
||||
const CLIENT_MEDIA_FIELDS = new Set(["screenshot", "capture", "speech"]);
|
||||
const SCREENSHOT_FIELDS = new Set(["token", "filename", "data"]);
|
||||
const CAPTURE_FIELDS = new Set(["jobId", "warnings", "assets"]);
|
||||
const ASSET_FIELDS = new Set(["filename", "byteLength", "data"]);
|
||||
const SPEECH_PLAN_FIELDS = new Set(["sourceId", "sourceKey", "expression", "reading"]);
|
||||
const SPEECH_FIELDS = new Set([...SPEECH_PLAN_FIELDS, "filename", "byteLength", "data"]);
|
||||
const CAPTURE_KINDS = ["animation", "audio"];
|
||||
|
||||
function record(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactFields(value, allowed, label) {
|
||||
if (!record(value) || Object.keys(value).some(key => !allowed.has(key))) {
|
||||
throw new Error(`The linked ${label} payload is invalid.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function decodedBase64Length(value) {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length % 4 !== 0
|
||||
|| !/^[A-Za-z0-9+/]*={0,2}$/u.test(value)) return null;
|
||||
if (value.endsWith("==")) return value.length / 4 * 3 - 2;
|
||||
return value.length / 4 * 3 - Number(value.endsWith("="));
|
||||
}
|
||||
|
||||
function unavailable(request, kind) {
|
||||
return Array.isArray(request?.captureUnavailable) && request.captureUnavailable.includes(kind);
|
||||
}
|
||||
|
||||
function validateScreenshot(request, value) {
|
||||
exactFields(value, SCREENSHOT_FIELDS, "screenshot");
|
||||
const expected = request?.screenshot;
|
||||
const byteLength = decodedBase64Length(value.data);
|
||||
if (!record(expected) || unavailable(request, "screenshot")
|
||||
|| typeof value.token !== "string" || value.token === "" || value.token.length > 256
|
||||
|| value.token !== expected.token || value.filename !== expected.filename
|
||||
|| !SCREENSHOT_FILENAME.test(value.filename)
|
||||
|| byteLength === null || !value.data.startsWith("/9j/")
|
||||
|| byteLength > MAX_LINKED_SCREENSHOT_BYTES) {
|
||||
throw new Error("The linked screenshot payload is invalid, stale, or exceeds its size limit.");
|
||||
}
|
||||
return { token: value.token, filename: value.filename, data: value.data };
|
||||
}
|
||||
|
||||
function validateAsset(request, kind, value) {
|
||||
exactFields(value, ASSET_FIELDS, `captured ${kind}`);
|
||||
const expectedFilename = request?.capturePin?.[kind === "animation" ? "animationFilename" : "audioFilename"];
|
||||
const byteLength = decodedBase64Length(value.data);
|
||||
if (typeof expectedFilename !== "string" || value.filename !== expectedFilename
|
||||
|| !CAPTURE_FILENAMES[kind].test(value.filename)
|
||||
|| !Number.isSafeInteger(value.byteLength) || value.byteLength < 1
|
||||
|| value.byteLength > CAPTURE_LIMITS[kind]
|
||||
|| byteLength === null || byteLength !== value.byteLength) {
|
||||
throw new Error(`The linked captured ${kind} payload is invalid, stale, or exceeds its size limit.`);
|
||||
}
|
||||
return { filename: value.filename, byteLength: value.byteLength, data: value.data };
|
||||
}
|
||||
|
||||
function validateCapture(request, value) {
|
||||
exactFields(value, CAPTURE_FIELDS, "captured-media");
|
||||
if (typeof request?.captureJobId !== "string" || request.captureJobId === ""
|
||||
|| request.captureJobId.length > 256 || value.jobId !== request.captureJobId
|
||||
|| !Array.isArray(value.warnings) || value.warnings.length > 64
|
||||
|| value.warnings.some(warning => typeof warning !== "string" || warning.length > 500)
|
||||
|| !record(value.assets) || Object.keys(value.assets).some(kind => !CAPTURE_KINDS.includes(kind))) {
|
||||
throw new Error("The linked captured-media payload is invalid or stale.");
|
||||
}
|
||||
const assets = {};
|
||||
for (const kind of CAPTURE_KINDS) {
|
||||
if (value.assets[kind] !== undefined) assets[kind] = validateAsset(request, kind, value.assets[kind]);
|
||||
}
|
||||
return { jobId: value.jobId, warnings: [...value.warnings], assets };
|
||||
}
|
||||
|
||||
function validateSpeechPlan(value, label = "browser-speech plan") {
|
||||
exactFields(value, SPEECH_PLAN_FIELDS, label);
|
||||
if (typeof value.sourceId !== "string" || value.sourceId === "" || value.sourceId.length > 256
|
||||
|| typeof value.sourceKey !== "string" || value.sourceKey === "" || value.sourceKey.length > 16_384
|
||||
|| typeof value.expression !== "string" || value.expression === "" || value.expression.length > 4096
|
||||
|| typeof value.reading !== "string" || value.reading.length > 4096) {
|
||||
throw new Error(`The linked ${label} is invalid.`);
|
||||
}
|
||||
return {
|
||||
sourceId: value.sourceId,
|
||||
sourceKey: value.sourceKey,
|
||||
expression: value.expression,
|
||||
reading: value.reading,
|
||||
};
|
||||
}
|
||||
|
||||
function validateSpeech(request, value) {
|
||||
exactFields(value, SPEECH_FIELDS, "browser-speech payload");
|
||||
const expected = validateSpeechPlan(request?.clientSpeech);
|
||||
const actual = validateSpeechPlan(Object.fromEntries(
|
||||
[...SPEECH_PLAN_FIELDS].map(field => [field, value[field]]),
|
||||
), "browser-speech identity");
|
||||
const byteLength = decodedBase64Length(value.data);
|
||||
if (Object.keys(expected).some(field => actual[field] !== expected[field])
|
||||
|| !SPEECH_FILENAME.test(value.filename)
|
||||
|| !Number.isSafeInteger(value.byteLength) || value.byteLength < 1
|
||||
|| value.byteLength > MAX_LINKED_SPEECH_BYTES
|
||||
|| byteLength === null || byteLength !== value.byteLength
|
||||
|| !value.data.startsWith("UklG")) {
|
||||
throw new Error("The linked browser-speech payload is invalid, stale, or exceeds its size limit.");
|
||||
}
|
||||
return { ...actual, filename: value.filename, byteLength: value.byteLength, data: value.data };
|
||||
}
|
||||
|
||||
// Returns a new allowlisted envelope so the host never retains caller-owned
|
||||
// objects or unrecognised fields after validating the cross-browser boundary.
|
||||
export function validateLinkedAnkiClientMedia(request, value) {
|
||||
exactFields(value, CLIENT_MEDIA_FIELDS, "client-media envelope");
|
||||
const expectsScreenshot = record(request?.screenshot) && !unavailable(request, "screenshot");
|
||||
const expectsCapture = typeof request?.captureJobId === "string" && request.captureJobId !== "";
|
||||
const expectsSpeech = record(request?.clientSpeech);
|
||||
if (expectsScreenshot !== (value.screenshot !== undefined)
|
||||
|| expectsCapture !== (value.capture !== undefined)
|
||||
|| expectsSpeech !== (value.speech !== undefined)) {
|
||||
throw new Error("The linked client-media envelope is missing media for this mining request.");
|
||||
}
|
||||
return {
|
||||
...(value.screenshot === undefined ? {} : { screenshot: validateScreenshot(request, value.screenshot) }),
|
||||
...(value.capture === undefined ? {} : { capture: validateCapture(request, value.capture) }),
|
||||
...(value.speech === undefined ? {} : { speech: validateSpeech(request, value.speech) }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
(function () {
|
||||
"use strict";
|
||||
const text = (node, value) => { if (node.textContent !== value) node.textContent = value; };
|
||||
|
||||
const FEEDBACK_PRIORITY = { info: 0, success: 1, warning: 2, error: 3 };
|
||||
function syncFeedbackSurface(feedback) {
|
||||
const visible = [...feedback.querySelectorAll(".gsm-hoshidicts-anki-control")]
|
||||
.filter(control => !control.hidden);
|
||||
feedback.hidden = visible.length === 0;
|
||||
if (visible.length === 0) {
|
||||
delete feedback.dataset.kind;
|
||||
return;
|
||||
}
|
||||
feedback.dataset.kind = visible.reduce((kind, control) => {
|
||||
const next = control.dataset.kind || "info";
|
||||
return FEEDBACK_PRIORITY[next] > FEEDBACK_PRIORITY[kind] ? next : kind;
|
||||
}, "info");
|
||||
}
|
||||
function syncFeedback(record) {
|
||||
if (!record.control) return;
|
||||
record.control.hidden = record.hidden || (record.badge.hidden && record.output.textContent === "");
|
||||
syncFeedbackSurface(record.feedback);
|
||||
}
|
||||
function setStatus(record, value, kind = "info") {
|
||||
text(record.output, value);
|
||||
record.control.dataset.kind = kind;
|
||||
syncFeedback(record);
|
||||
}
|
||||
function setMiningButtonState(record, state, message = "") {
|
||||
const button = record.add;
|
||||
button.dataset.state = state;
|
||||
const actionTitle = message || {
|
||||
checking: "Checking Anki card status",
|
||||
ready: "Mine to Anki",
|
||||
"add-duplicate": "Add duplicate to Anki",
|
||||
overwrite: "Overwrite note in Anki",
|
||||
"view-existing": "View existing notes in Anki",
|
||||
mining: "Adding note",
|
||||
success: "Note added",
|
||||
error: "Could not add note",
|
||||
duplicate: "Note already exists",
|
||||
unavailable: "Anki mining is unavailable",
|
||||
}[state] || "Mine to Anki";
|
||||
const title = record.custom && !message ? `${record.label}: ${actionTitle}` : actionTitle;
|
||||
button.title = title;
|
||||
button.setAttribute("aria-label", title);
|
||||
button.setAttribute("aria-busy", String(state === "checking" || state === "mining"));
|
||||
button.dataset.action = views(record) ? "view" : "add";
|
||||
if (record.custom) {
|
||||
let label = button.querySelector(".gsm-hoshidicts-text-action-label");
|
||||
if (!label) {
|
||||
label = button.ownerDocument.createElement("span");
|
||||
label.className = "gsm-hoshidicts-text-action-label";
|
||||
button.replaceChildren(label);
|
||||
}
|
||||
text(label, record.label);
|
||||
return;
|
||||
}
|
||||
const iconName = {
|
||||
ready: "add",
|
||||
"add-duplicate": "document-add",
|
||||
overwrite: "document-edit",
|
||||
"view-existing": "book-search",
|
||||
checking: "arrow-clockwise",
|
||||
mining: "arrow-sync",
|
||||
success: "book-search",
|
||||
error: "error-circle",
|
||||
unavailable: "subtract",
|
||||
}[state] || "subtract";
|
||||
const icon = button.ownerDocument.createElement("span");
|
||||
icon.className = "gsm-hoshidicts-mine-icon hd-icon";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.dataset.icon = iconName;
|
||||
button.replaceChildren(icon);
|
||||
}
|
||||
// The one Anki button opens Anki instead of adding once there is a note to
|
||||
// show: the note it wrote, the write it could not confirm, or the duplicates
|
||||
// that block adding.
|
||||
function views(record) {
|
||||
return record.terminal || (record.decision?.state === "duplicate" && record.decision.canAdd === false);
|
||||
}
|
||||
function disabled(record) {
|
||||
if (!record.add) return;
|
||||
record.add.disabled = record.busy
|
||||
|| (!record.terminal && (record.needsCheck || (!record.decision?.canAdd && !views(record))));
|
||||
}
|
||||
function payload(record) {
|
||||
return {
|
||||
...record.group.getRequest(record.result),
|
||||
configKey: record.configKey,
|
||||
templateId: record.templateId,
|
||||
};
|
||||
}
|
||||
function decisionState(value) {
|
||||
if (value.action === "overwrite" && value.canAdd) return "overwrite";
|
||||
if (value.state === "duplicate") return value.canAdd ? "add-duplicate" : "view-existing";
|
||||
if (value.state === "invalid" || value.state === "error") return "error";
|
||||
return "ready";
|
||||
}
|
||||
function showControls(record, value) {
|
||||
record.hidden = !value;
|
||||
if (record.add) record.add.hidden = !value;
|
||||
syncFeedback(record);
|
||||
}
|
||||
function removeControls(record) {
|
||||
if (record?.add && !record.custom) record.add.remove();
|
||||
if (record?.add && record.custom) {
|
||||
record.add.removeEventListener("mousedown", record.onMouseDown);
|
||||
record.add.removeEventListener("click", record.onClick);
|
||||
record.add.disabled = true;
|
||||
delete record.add.dataset.state;
|
||||
delete record.add.dataset.action;
|
||||
record.add.removeAttribute("aria-busy");
|
||||
}
|
||||
record?.control?.remove();
|
||||
if (record?.feedback) syncFeedbackSurface(record.feedback);
|
||||
}
|
||||
function reusableRecord(record, group, spec) {
|
||||
return record?.group === group && record.result === spec.item.result
|
||||
&& record.custom === spec.custom && record.templateId === spec.templateId;
|
||||
}
|
||||
function createRecord(group, spec) {
|
||||
const { item, binding, custom, templateId, label, add } = spec;
|
||||
return {
|
||||
...item,
|
||||
binding,
|
||||
group,
|
||||
custom,
|
||||
templateId,
|
||||
label,
|
||||
...(add ? { add } : {}),
|
||||
configKey: null,
|
||||
busy: false,
|
||||
terminal: false,
|
||||
decision: null,
|
||||
viewChecked: false,
|
||||
needsCheck: true,
|
||||
captureJobId: null,
|
||||
};
|
||||
}
|
||||
function updateRecord(record, spec) {
|
||||
Object.assign(record, {
|
||||
actions: spec.item.actions,
|
||||
feedback: spec.item.feedback,
|
||||
result: spec.item.result,
|
||||
label: spec.label,
|
||||
});
|
||||
if (spec.custom) setMiningButtonState(record, record.add.dataset.state || "checking");
|
||||
}
|
||||
function captureBadge(record, state = "") {
|
||||
if (!record.badge) return;
|
||||
const capture = record.decision?.capture;
|
||||
record.badge.hidden = !capture;
|
||||
if (!capture) {
|
||||
text(record.badge, "");
|
||||
syncFeedback(record);
|
||||
return;
|
||||
}
|
||||
const labels = [capture.sourceLabel, capture.partial ? "Partial" : "", state].filter(Boolean);
|
||||
text(record.badge, labels.join(" · "));
|
||||
syncFeedback(record);
|
||||
}
|
||||
function decision(record, value) {
|
||||
record.decision = value;
|
||||
if (!record.terminal && !record.busy) {
|
||||
const state = decisionState(value);
|
||||
setMiningButtonState(record, state, state === "view-existing" ? "" : value.error || "");
|
||||
setStatus(record, value.error || "", value.error ? "error" : "info");
|
||||
}
|
||||
captureBadge(record);
|
||||
disabled(record);
|
||||
}
|
||||
function uncertain(record, error) {
|
||||
record.terminal = true;
|
||||
setMiningButtonState(record, "error", "Check Anki before trying again");
|
||||
setStatus(record, error, "error");
|
||||
}
|
||||
function readyCaptureRequest(record, request, requirements, assets) {
|
||||
captureBadge(record);
|
||||
const unavailable = [...(request.captureUnavailable ?? [])];
|
||||
if (requirements.includeAnimation && !assets?.animation) unavailable.push("animation");
|
||||
if (requirements.includeAudio && !assets?.audio) unavailable.push("audio");
|
||||
return { ...request, captureJobId: record.captureJobId, captureUnavailable: unavailable };
|
||||
}
|
||||
function captureProgress(record, status) {
|
||||
if (status.state === "finishing") {
|
||||
captureBadge(record, "Finishing clip");
|
||||
setStatus(record, "Finishing clip…");
|
||||
} else {
|
||||
captureBadge(record);
|
||||
const progress = status.total > 0 ? ` ${status.progress}/${status.total}` : "";
|
||||
setStatus(record, `Encoding captured media${progress}…`);
|
||||
}
|
||||
}
|
||||
function restartChecks(records) {
|
||||
for (const record of records) {
|
||||
if (record.terminal) continue;
|
||||
record.viewChecked = false;
|
||||
record.needsCheck = true;
|
||||
record.decision = null;
|
||||
if (record.add) setMiningButtonState(record, "checking");
|
||||
}
|
||||
}
|
||||
function cachedViewRequest(record) {
|
||||
return { request: {
|
||||
term: {
|
||||
expression: record.result.term.expression,
|
||||
reading: record.result.term.reading,
|
||||
},
|
||||
templateId: record.templateId,
|
||||
} };
|
||||
}
|
||||
function cachedView(value) {
|
||||
return value?.cached === true && value.state === "duplicate"
|
||||
&& value.canAdd === false && Array.isArray(value.noteIds) && value.noteIds.length > 0;
|
||||
}
|
||||
function checkedConfigKey(configKey, result) {
|
||||
if (typeof result?.configKey !== "string") return { configKey, changed: false };
|
||||
if (configKey !== null && result.configKey !== configKey) return { configKey, changed: true };
|
||||
return { configKey: result.configKey, changed: false };
|
||||
}
|
||||
function createAnkiController({
|
||||
send,
|
||||
capture = send,
|
||||
onChange,
|
||||
// The page's own overlays are hidden for a viewport screenshot and restored
|
||||
// afterwards; without a host to hide, the screenshot is just taken.
|
||||
conceal = during => during(),
|
||||
wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
|
||||
}) {
|
||||
const owners = new Map(), bound = new WeakMap();
|
||||
let enabled = false, settingsKey = "", checks = Promise.resolve();
|
||||
let primaryTemplateId = "default";
|
||||
let templateIds = new Set(["default"]);
|
||||
let customButtonTemplates = new Map();
|
||||
const live = group => enabled && owners.get(group.owner) === group && !group.popup.hidden && group.isCurrent();
|
||||
const boundHere = record => bound.get(record.binding) === record && record.binding.isConnected;
|
||||
const current = record => live(record.group) && boundHere(record);
|
||||
const needsCheck = record => boundHere(record) && record.needsCheck && !record.busy && !record.terminal;
|
||||
function available(records, value, error = "") {
|
||||
for (const record of records) {
|
||||
const show = value || record.custom || record.terminal || cachedView(record.decision);
|
||||
if (show && record.actions.isConnected) controls(record);
|
||||
if (record.control) showControls(record, show);
|
||||
if (!value && !show) record.needsCheck = false;
|
||||
if (!value && record.custom) {
|
||||
record.needsCheck = false;
|
||||
setMiningButtonState(record, "unavailable", error || "This Anki Template is unavailable.");
|
||||
setStatus(record, error || "This Anki Template is unavailable.", "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
async function requestCachedView(record) {
|
||||
record.viewChecked = true;
|
||||
try {
|
||||
return await send("hd_anki_view", cachedViewRequest(record));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function applyCachedView(group, record, result) {
|
||||
if (!cachedView(result)) return;
|
||||
record.needsCheck = false;
|
||||
controls(record);
|
||||
showControls(record, true);
|
||||
decision(record, result);
|
||||
onChange(group.owner);
|
||||
}
|
||||
async function checkCachedViews(group, records, owns) {
|
||||
const configKeys = new Map();
|
||||
for (const record of records) {
|
||||
if (!owns()) return { configKeys, changed: [] };
|
||||
if (!needsCheck(record) || record.viewChecked) continue;
|
||||
const result = await requestCachedView(record);
|
||||
if (result === null || !owns() || !boundHere(record)) continue;
|
||||
const configKey = configKeys.get(record.templateId) ?? null;
|
||||
const checked = checkedConfigKey(configKey, result);
|
||||
if (checked.changed) {
|
||||
return {
|
||||
configKeys,
|
||||
changed: records.filter(candidate => candidate.templateId === record.templateId),
|
||||
};
|
||||
}
|
||||
if (checked.configKey !== null) configKeys.set(record.templateId, checked.configKey);
|
||||
record.configKey = checked.configKey;
|
||||
applyCachedView(group, record, result);
|
||||
}
|
||||
return { configKeys, changed: [] };
|
||||
}
|
||||
async function checkRecords(records, owns) {
|
||||
for (const record of records) {
|
||||
if (!owns()) return;
|
||||
if (!needsCheck(record)) continue;
|
||||
record.needsCheck = false;
|
||||
try {
|
||||
const result = await send("hd_anki_preflight", { request: payload(record) });
|
||||
if (owns() && boundHere(record)) decision(record, result);
|
||||
} catch (error) {
|
||||
if (owns() && boundHere(record)) decision(record, { state: "error", canAdd: false, error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
function recordsByTemplate(records) {
|
||||
const byTemplate = new Map();
|
||||
for (const record of records) {
|
||||
if (!byTemplate.has(record.templateId)) byTemplate.set(record.templateId, []);
|
||||
byTemplate.get(record.templateId).push(record);
|
||||
}
|
||||
return byTemplate;
|
||||
}
|
||||
async function checkTemplateRecords(group, templateId, records, configKeys, owns) {
|
||||
const status = await send("hd_anki_status", { templateId });
|
||||
if (!owns()) return;
|
||||
const cachedKey = configKeys.get(templateId) ?? null;
|
||||
if (status.available && cachedKey !== null && status.configKey !== cachedKey) {
|
||||
restartChecks(records);
|
||||
return;
|
||||
}
|
||||
for (const record of records) record.configKey = status.configKey;
|
||||
available(records, status.available, status.error);
|
||||
if (!status.available) return;
|
||||
onChange(group.owner);
|
||||
await checkRecords(records, owns);
|
||||
}
|
||||
async function checkGroup(group) {
|
||||
const epoch = group.epoch;
|
||||
const owns = () => live(group) && epoch === group.epoch;
|
||||
try {
|
||||
if (!owns()) return;
|
||||
const missing = group.records.filter(record => needsCheck(record) && !templateIds.has(record.templateId));
|
||||
available(missing, false, "The selected Anki Template is no longer available.");
|
||||
const configured = group.records.filter(record => templateIds.has(record.templateId));
|
||||
const cached = await checkCachedViews(group, configured, owns);
|
||||
if (!owns()) return;
|
||||
if (cached.changed.length > 0) {
|
||||
restartChecks(cached.changed);
|
||||
return;
|
||||
}
|
||||
if (!group.records.some(needsCheck)) return;
|
||||
const byTemplate = recordsByTemplate(group.records.filter(needsCheck));
|
||||
for (const [templateId, records] of byTemplate) {
|
||||
await checkTemplateRecords(group, templateId, records, cached.configKeys, owns);
|
||||
if (!owns()) return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (owns()) available(group.records.filter(needsCheck), false, error.message);
|
||||
} finally {
|
||||
group.queued = false;
|
||||
if (live(group)) {
|
||||
group.records.forEach(disabled);
|
||||
onChange(group.owner);
|
||||
refresh(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
function refresh(group, all = false) {
|
||||
if (all) for (const record of group.records) {
|
||||
record.viewChecked = record.terminal;
|
||||
record.needsCheck = !record.terminal;
|
||||
}
|
||||
group.records.forEach(record => {
|
||||
if (needsCheck(record)) {
|
||||
// Readiness belongs to this result, not to the whole popup's queue.
|
||||
record.decision = null;
|
||||
if (enabled) {
|
||||
controls(record);
|
||||
showControls(record, true);
|
||||
setMiningButtonState(record, "checking");
|
||||
}
|
||||
}
|
||||
disabled(record);
|
||||
});
|
||||
if (!live(group) || group.queued || !group.records.some(needsCheck)) return;
|
||||
group.queued = true;
|
||||
const operation = () => checkGroup(group);
|
||||
checks = checks.then(operation, operation);
|
||||
}
|
||||
function refreshAll() {
|
||||
for (const group of owners.values()) refresh(group, true);
|
||||
}
|
||||
async function discardScreenshot(record) {
|
||||
if (!record.screenshot) return;
|
||||
const { token } = record.screenshot;
|
||||
record.screenshot = null;
|
||||
try { await send("hd_anki_screenshot_discard", { request: { token } }); } catch { /* A restarted worker holds nothing. */ }
|
||||
}
|
||||
async function cancelCapture(record) {
|
||||
if (!record.captureJobId) return;
|
||||
try { await capture("hd_capture_cancel", { jobId: record.captureJobId }); } catch { /* Stop/expiry already cleaned it up. */ }
|
||||
record.captureJobId = null;
|
||||
}
|
||||
async function handleSubmissionFailure(record, error, writeSent, owns) {
|
||||
if (!writeSent) {
|
||||
// Nothing was sent, so the picture this submission took is nobody's.
|
||||
await discardScreenshot(record);
|
||||
} else if (!error.responseReceived) {
|
||||
uncertain(record, `The write could not be confirmed. Check Anki before trying again. ${error.message}`);
|
||||
return;
|
||||
} else {
|
||||
// A worker reply confirms that no Anki mutation was sent. Release the
|
||||
// request-owned export even after its popup owner has retired.
|
||||
await cancelCapture(record);
|
||||
await discardScreenshot(record);
|
||||
}
|
||||
if (!owns()) return;
|
||||
setMiningButtonState(record, decisionState(record.decision));
|
||||
setStatus(record, `Could not add: ${error.message}`, "error");
|
||||
}
|
||||
// One viewport screenshot for this submission, taken with Hachidori's own
|
||||
// overlays hidden. A capture or upload that fails is a warning carried with
|
||||
// the note's outcome: the field renders empty and the note still goes in.
|
||||
async function prepareScreenshot(record, request, owns) {
|
||||
record.screenshotWarning = "";
|
||||
record.screenshot = null;
|
||||
if (record.decision?.screenshot !== true) return request;
|
||||
if (owns()) setStatus(record, "Taking the screenshot…");
|
||||
try {
|
||||
const taken = await conceal(() => send("hd_anki_screenshot", { templateId: record.templateId }));
|
||||
if (typeof taken?.filename !== "string" || !taken.filename) throw new Error("no screenshot was taken");
|
||||
record.screenshot = { token: taken.token, filename: taken.filename };
|
||||
return { ...request, screenshot: record.screenshot };
|
||||
} catch (error) {
|
||||
record.screenshotWarning = `Screenshot: ${error.message}`;
|
||||
return { ...request, captureUnavailable: [...(request.captureUnavailable ?? []), "screenshot"] };
|
||||
}
|
||||
}
|
||||
function submitted(record, result) {
|
||||
if (result.state === "uncertain") { uncertain(record, result.error); return true; }
|
||||
if (result.state !== "added" && result.state !== "updated") return false;
|
||||
// A configuration epoch can change while Anki commits. The submitted
|
||||
// record still owns its outcome; never turn a known write into a retry.
|
||||
record.terminal = true;
|
||||
record.noteIds = [result.noteId];
|
||||
const label = result.state === "added" ? "Added" : "Updated";
|
||||
const warnings = [record.screenshotWarning, ...(result.warnings ?? [])].filter(Boolean);
|
||||
setMiningButtonState(record, "success", `Find ${label.toLowerCase()} note in Anki`);
|
||||
setStatus(record, `${label} note ${result.noteId}. ${warnings.join(" ")}`.trim(),
|
||||
warnings.length > 0 ? "warning" : "success");
|
||||
refreshAll(); // Best-effort checks cannot turn a confirmed write into a retry.
|
||||
return true;
|
||||
}
|
||||
async function prepareCapture(record, request, owns) {
|
||||
const selected = record.decision?.capture;
|
||||
if (!selected) return request;
|
||||
if (!request.capturePin?.token) throw new Error("The capture pin expired. Look up the text again.");
|
||||
if (!record.captureJobId) {
|
||||
const started = await capture("hd_capture_export", {
|
||||
token: request.capturePin.token,
|
||||
requirements: selected.requirements,
|
||||
});
|
||||
record.captureJobId = started.jobId;
|
||||
}
|
||||
for (;;) {
|
||||
const status = await capture("hd_capture_job_status", { jobId: record.captureJobId });
|
||||
if (status.state === "ready") {
|
||||
return readyCaptureRequest(record, request, selected.requirements, status.assets);
|
||||
}
|
||||
if (status.state === "error") {
|
||||
const message = status.error || "Captured media could not be encoded.";
|
||||
await cancelCapture(record);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (owns()) captureProgress(record, status);
|
||||
await wait(100);
|
||||
}
|
||||
}
|
||||
async function submit(record, fromPointer) {
|
||||
if (!current(record) || record.add.disabled || record.busy || record.terminal) return;
|
||||
const group = record.group, epoch = group.epoch;
|
||||
const owns = () => current(record) && record.group === group && group.epoch === epoch;
|
||||
const baseRequest = (fromPointer && record.pointerRequest) || payload(record);
|
||||
const request = record.decision?.clientSpeech
|
||||
? { ...baseRequest, clientSpeech: record.decision.clientSpeech }
|
||||
: baseRequest;
|
||||
record.pointerRequest = null;
|
||||
record.busy = true;
|
||||
setMiningButtonState(record, "mining");
|
||||
disabled(record);
|
||||
setStatus(record, "Saving to Anki…");
|
||||
let writeSent = false;
|
||||
try {
|
||||
const prepared = await prepareCapture(record, await prepareScreenshot(record, request, owns), owns);
|
||||
if (owns()) setStatus(record, "Saving to Anki…");
|
||||
writeSent = true;
|
||||
const result = await send("hd_anki_submit", { request: prepared });
|
||||
// These replies confirm that no note was written. Release the export
|
||||
// even if its popup retired while Anki was checking the submission.
|
||||
if (["duplicate", "invalid"].includes(result.state)) await cancelCapture(record);
|
||||
if (!submitted(record, result) && owns()) { decision(record, { ...result, canAdd: false }); refreshAll(); }
|
||||
} catch (error) {
|
||||
await handleSubmissionFailure(record, error, writeSent, owns);
|
||||
} finally {
|
||||
record.busy = false;
|
||||
if (current(record)) { disabled(record); onChange(record.group.owner); refresh(record.group); }
|
||||
}
|
||||
}
|
||||
// An unconfirmed write has no note ID, so Anki searches for the expression.
|
||||
async function browse(record) {
|
||||
if (!current(record) || record.add.disabled) return;
|
||||
record.add.disabled = true;
|
||||
const noteIds = record.terminal ? record.noteIds : record.decision?.noteIds;
|
||||
try {
|
||||
const result = await send("hd_anki_browse", { request: {
|
||||
noteIds: Array.isArray(noteIds) ? noteIds : [],
|
||||
expression: record.result.term.expression,
|
||||
configKey: record.configKey,
|
||||
templateId: record.templateId,
|
||||
} });
|
||||
if (current(record) && Array.isArray(result?.noteIds)) {
|
||||
if (record.terminal) record.noteIds = result.noteIds;
|
||||
else if (record.decision) record.decision = { ...record.decision, noteIds: result.noteIds };
|
||||
}
|
||||
if (current(record) && result?.opened === false) {
|
||||
record.terminal = false;
|
||||
record.noteIds = [];
|
||||
record.decision = null;
|
||||
record.viewChecked = false;
|
||||
record.needsCheck = true;
|
||||
setMiningButtonState(record, "checking");
|
||||
setStatus(record, "");
|
||||
refresh(record.group);
|
||||
}
|
||||
}
|
||||
catch (error) { if (current(record)) setStatus(record, `Could not open Anki: ${error.message}`, "error"); }
|
||||
finally {
|
||||
if (current(record)) { disabled(record); onChange(record.group.owner); }
|
||||
}
|
||||
}
|
||||
function controls(record) {
|
||||
if (record.control) return;
|
||||
const document = record.actions.ownerDocument;
|
||||
const feedback = record.feedback || document.createElement("div");
|
||||
if (!record.feedback) {
|
||||
feedback.className = "gsm-hoshidicts-mining-feedback";
|
||||
feedback.setAttribute("role", "status");
|
||||
feedback.setAttribute("aria-live", "polite");
|
||||
feedback.hidden = true;
|
||||
record.actions.after(feedback);
|
||||
}
|
||||
const control = document.createElement("div");
|
||||
control.className = "gsm-hoshidicts-anki-control";
|
||||
const add = record.add || document.createElement("button");
|
||||
if (!record.custom) {
|
||||
add.type = "button";
|
||||
add.className = "gsm-hoshidicts-mine-button";
|
||||
}
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "gsm-hoshidicts-capture-badge";
|
||||
badge.hidden = true;
|
||||
const output = document.createElement("output");
|
||||
output.className = "gsm-hoshidicts-anki-status";
|
||||
output.setAttribute("aria-live", "polite");
|
||||
control.append(badge, output);
|
||||
control.hidden = true;
|
||||
feedback.append(control);
|
||||
if (!record.custom) {
|
||||
const leadingAction = record.actions.firstElementChild;
|
||||
if (leadingAction?.matches(".gsm-hoshidicts-popup-close, .gsm-hoshidicts-kanji-back")) {
|
||||
leadingAction.after(add);
|
||||
} else {
|
||||
record.actions.prepend(add);
|
||||
}
|
||||
}
|
||||
Object.assign(record, { feedback, control, add, badge, output, hidden: false });
|
||||
setMiningButtonState(record, "checking");
|
||||
record.onMouseDown = event => {
|
||||
if (event.button === 0 && current(record) && !views(record)) record.pointerRequest = payload(record);
|
||||
};
|
||||
record.onClick = event => {
|
||||
if (views(record)) void browse(record);
|
||||
else void submit(record, event.detail > 0);
|
||||
};
|
||||
add.addEventListener("mousedown", record.onMouseDown);
|
||||
add.addEventListener("click", record.onClick);
|
||||
disabled(record);
|
||||
}
|
||||
function recordSpecs(group) {
|
||||
const specs = [];
|
||||
for (const item of group.items) {
|
||||
specs.push({
|
||||
item,
|
||||
binding: item.actions,
|
||||
custom: false,
|
||||
templateId: primaryTemplateId,
|
||||
label: "Anki",
|
||||
});
|
||||
for (const add of item.actions.querySelectorAll(".gsm-hoshidicts-custom-anki-button")) {
|
||||
const descriptor = customButtonTemplates.get(add.dataset.customButtonId);
|
||||
if (!descriptor) continue;
|
||||
specs.push({
|
||||
item,
|
||||
binding: add,
|
||||
add,
|
||||
custom: true,
|
||||
templateId: descriptor.templateId,
|
||||
label: descriptor.label,
|
||||
});
|
||||
}
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
function recordForSpec(group, spec) {
|
||||
let record = bound.get(spec.binding);
|
||||
if (reusableRecord(record, group, spec)) {
|
||||
updateRecord(record, spec);
|
||||
return record;
|
||||
}
|
||||
if (record) {
|
||||
removeControls(record);
|
||||
bound.delete(spec.binding);
|
||||
}
|
||||
record = createRecord(group, spec);
|
||||
bound.set(spec.binding, record);
|
||||
return record;
|
||||
}
|
||||
function reconcile(group) {
|
||||
const next = recordSpecs(group).map(spec => recordForSpec(group, spec));
|
||||
const retained = new Set(next);
|
||||
const retainedBindings = new Set(next.map(record => record.binding));
|
||||
for (const record of group.records) {
|
||||
if (retained.has(record) || retainedBindings.has(record.binding)) continue;
|
||||
removeControls(record);
|
||||
if (bound.get(record.binding) === record) bound.delete(record.binding);
|
||||
}
|
||||
group.records = next;
|
||||
}
|
||||
function bind(items, context) {
|
||||
let group = owners.get(context.owner);
|
||||
if (group && group.request !== context.request) { retire(context.owner); group = null; }
|
||||
if (!group) {
|
||||
group = { ...context, items, records: [], epoch: 0, queued: false };
|
||||
owners.set(context.owner, group);
|
||||
}
|
||||
else Object.assign(group, context);
|
||||
group.items = items;
|
||||
reconcile(group);
|
||||
group.records.forEach(disabled);
|
||||
refresh(group);
|
||||
}
|
||||
function retire(owner) {
|
||||
for (const [key, group] of owners) {
|
||||
if (owner !== undefined && owner !== key) continue;
|
||||
owners.delete(key);
|
||||
for (const record of group.records) if (record.control) showControls(record, false);
|
||||
}
|
||||
}
|
||||
return { bind, retire,
|
||||
refresh(owner) { const group = owners.get(owner); if (group) refresh(group, true); },
|
||||
update(options, ready = true) {
|
||||
const anki = globalThis.HDReaderOptions.normaliseAnki(options.anki);
|
||||
const customButtons = globalThis.HDReaderOptions.normaliseCustomButtons(options.customButtons);
|
||||
const key = JSON.stringify([ready, anki, customButtons, options.audioSources, options.mediaCapture]);
|
||||
if (key === settingsKey) return;
|
||||
settingsKey = key;
|
||||
primaryTemplateId = anki.templates[0].id;
|
||||
templateIds = new Set(anki.templates.map(template => template.id));
|
||||
customButtonTemplates = new Map(customButtons.filter(button => button.type === "anki")
|
||||
.map(button => [button.id, { templateId: button.templateId, label: button.label }]));
|
||||
enabled = ready && (anki.templates.some(template => Boolean(template.model))
|
||||
|| customButtonTemplates.size > 0);
|
||||
for (const group of owners.values()) {
|
||||
group.epoch++;
|
||||
reconcile(group);
|
||||
for (const record of group.records) if (record.control) showControls(record, false);
|
||||
refresh(group, true);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
// Mining screenshots address this exact document before and after capture.
|
||||
// This script is present in every browser; the capture content script is not.
|
||||
globalThis.chrome?.runtime?.onMessage?.addListener((message, sender, sendResponse) => {
|
||||
if (message?.target === "hachidori-anki-content" && message.type === "hd_anki_document") sendResponse({ present: true });
|
||||
});
|
||||
globalThis.HDAnki = { createAnkiController };
|
||||
}());
|
||||
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
export async function ankiDigest(bytes) {
|
||||
const hash = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes));
|
||||
return [...hash].map(byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { isAnkiAudioOnlyTemplate } from "./anki-templates.js";
|
||||
|
||||
// GSM PR #549 hoshidicts_anki.py and hoshidicts_markers.py. These policies
|
||||
// receive the gateway's private invoker, never a page-selected API action.
|
||||
const escapeQuery = value => value.replace(/[\\"*_:]/gu, String.raw`\$&`);
|
||||
const positiveId = value => Number.isSafeInteger(value) && value > 0;
|
||||
export const isAnkiDuplicateError = error => /cannot create note because it is a duplicate/iu.test(error || "");
|
||||
|
||||
export function ankiBrowseQuery(expression) {
|
||||
return `"${escapeQuery(expression.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"))}"`;
|
||||
}
|
||||
|
||||
export function ankiNoteIdsQuery(noteIds) {
|
||||
if (!Array.isArray(noteIds) || noteIds.length === 0 || !noteIds.every(positiveId)) {
|
||||
throw new Error("Anki browse requires valid note IDs.");
|
||||
}
|
||||
return `nid:${[...new Set(noteIds)].join(",")}`;
|
||||
}
|
||||
|
||||
export function ankiNoteOptions(config) {
|
||||
const deck = config.duplicateScope === "deck";
|
||||
return {
|
||||
// The index applies the selected recognized-note-type scope. Native Anki
|
||||
// remains a final race guard for the configured destination note type only:
|
||||
// its all-model switch would also reject unrelated custom note types.
|
||||
allowDuplicate: config.duplicateBehavior === "new",
|
||||
duplicateScope: deck ? "deck" : "collection",
|
||||
duplicateScopeOptions: {
|
||||
deckName: deck ? config.deck : null,
|
||||
checkChildren: deck,
|
||||
checkAllModels: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function overwriteValue(existing, incoming, mode) {
|
||||
if (mode === "overwrite") return incoming;
|
||||
if (mode === "skip") return existing;
|
||||
if (mode === "append") return existing + incoming;
|
||||
if (mode === "prepend") return incoming + existing;
|
||||
if (mode === "coalesce-new") return incoming || existing;
|
||||
return existing || incoming;
|
||||
}
|
||||
|
||||
export function canonicalAnkiFields(fields, templates, existing) {
|
||||
const names = new Map(Object.keys(existing).map(name => [name.toLowerCase(), name]));
|
||||
const canonicalTemplates = [], incoming = [];
|
||||
for (const [field, template] of Object.entries(templates)) {
|
||||
const name = Object.hasOwn(existing, field) ? field : names.get(field.toLowerCase());
|
||||
if (name === undefined) {
|
||||
const existingNames = Object.keys(existing).map(field => `“${field}”`).join(", ");
|
||||
throw new Error(`Anki's note has no field “${field}” to overwrite; its fields are ${existingNames || "unknown"}. `
|
||||
+ "The note type's fields changed. Refresh fields in Anki Settings before overwriting this note.");
|
||||
}
|
||||
canonicalTemplates.push([name, template]);
|
||||
incoming.push([name, fields[field]]);
|
||||
}
|
||||
return { templates: Object.fromEntries(canonicalTemplates), fields: Object.fromEntries(incoming) };
|
||||
}
|
||||
|
||||
export function overwriteAnkiFields(incoming, existing, templates, { includeAudio = false } = {}) {
|
||||
return Object.fromEntries(Object.entries(templates).filter(([, template]) => includeAudio || !isAnkiAudioOnlyTemplate(template.value))
|
||||
.map(([field, template]) => [field, overwriteValue(existing[field] ?? "", incoming[field] ?? "", template.overwriteMode)]));
|
||||
}
|
||||
|
||||
function checkResult(result, detailed) {
|
||||
if (!Array.isArray(result) || result.length !== 1
|
||||
|| (detailed ? typeof result[0]?.canAdd !== "boolean" : typeof result[0] !== "boolean")) {
|
||||
throw new Error("AnkiConnect returned invalid duplicate check results.");
|
||||
}
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function checkAnkiDuplicate(invoke, note, config) {
|
||||
// Anki also validates clozes in non-first fields. Keep all rendered fields,
|
||||
// but omit media-upload objects: preflight must not write collection media.
|
||||
const checkNote = allowDuplicate => ({ deckName: note.deckName, modelName: note.modelName, fields: note.fields, tags: note.tags,
|
||||
options: { ...note.options, allowDuplicate } });
|
||||
let result;
|
||||
try {
|
||||
result = checkResult(await invoke("canAddNotesWithErrorDetail", { notes: [checkNote(false)] }), true);
|
||||
} catch (error) {
|
||||
if (!/unsupported action/iu.test(error.message)) throw error;
|
||||
const allowed = checkResult(await invoke("canAddNotes", { notes: [checkNote(true)] }), false);
|
||||
const prevented = checkResult(await invoke("canAddNotes", { notes: [checkNote(false)] }), false);
|
||||
return { duplicate: allowed && !prevented, addable: allowed && prevented, error: null };
|
||||
}
|
||||
const error = typeof result.error === "string" && result.error ? result.error : null;
|
||||
return { duplicate: isAnkiDuplicateError(error), addable: result.canAdd && !error, error };
|
||||
}
|
||||
|
||||
export async function validateAnkiNote(invoke, note) {
|
||||
const checkNote = {
|
||||
deckName: note.deckName,
|
||||
modelName: note.modelName,
|
||||
fields: note.fields,
|
||||
tags: note.tags,
|
||||
options: { ...note.options, allowDuplicate: true },
|
||||
};
|
||||
try {
|
||||
const result = checkResult(await invoke("canAddNotesWithErrorDetail", { notes: [checkNote] }), true);
|
||||
const error = typeof result.error === "string" && result.error ? result.error : null;
|
||||
return { addable: result.canAdd && error === null, error };
|
||||
} catch (error) {
|
||||
if (!/unsupported action/iu.test(error.message)) throw error;
|
||||
const addable = checkResult(await invoke("canAddNotes", { notes: [checkNote] }), false);
|
||||
return { addable, error: addable ? null : "Anki rejected this note." };
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateQuery(note, firstField, modelId) {
|
||||
// Native Anki dupe search uses the same case-sensitive, HTML-stripped
|
||||
// comparison as duplicate validation. Ordinary field search does not.
|
||||
// Unlike ordinary search, dupe text treats wildcard/colon/comma literally.
|
||||
const text = (note.fields[firstField] ?? "").replace(/[\\"]/gu, String.raw`\$&`);
|
||||
return `"dupe:${modelId},${text}"`;
|
||||
}
|
||||
|
||||
async function scopedNoteIds(invoke, infos, config) {
|
||||
if (config.duplicateScope !== "deck") return null;
|
||||
const ids = infos.flatMap(info => Array.isArray(info?.cards) ? info.cards.filter(positiveId) : []);
|
||||
if (!ids.length) return new Set();
|
||||
const cards = await invoke("cardsInfo", { cards: ids });
|
||||
if (!Array.isArray(cards)) throw new Error("AnkiConnect returned invalid duplicate card details.");
|
||||
const exact = config.deck.toLowerCase();
|
||||
return new Set(cards.filter(card => {
|
||||
if (typeof card?.deckName !== "string" || !positiveId(card.note)) return false;
|
||||
const deck = card.deckName.toLowerCase();
|
||||
return deck === exact || deck.startsWith(`${exact}::`);
|
||||
}).map(card => card.note));
|
||||
}
|
||||
|
||||
export async function findAnkiDuplicateNotes(invoke, note, firstField, config, {
|
||||
allModels = false,
|
||||
} = {}) {
|
||||
const models = await invoke("modelNamesAndIds");
|
||||
const modelId = models?.[config.model];
|
||||
if (Array.isArray(models) || !positiveId(modelId)) throw new Error("AnkiConnect returned no valid ID for the selected note type.");
|
||||
const modelEntries = [[config.model, modelId]];
|
||||
if (allModels) {
|
||||
for (const [modelName, id] of Object.entries(models)) {
|
||||
if (modelName === config.model) continue;
|
||||
if (!positiveId(id)) throw new Error("AnkiConnect returned invalid note type IDs.");
|
||||
modelEntries.push([modelName, id]);
|
||||
}
|
||||
}
|
||||
const ids = [];
|
||||
const modelByNote = new Map();
|
||||
for (const [modelName, id] of modelEntries) {
|
||||
const found = await invoke("findNotes", { query: duplicateQuery(note, firstField, id) });
|
||||
if (!Array.isArray(found) || !found.every(positiveId)) throw new Error("AnkiConnect returned invalid duplicate note IDs.");
|
||||
for (const noteId of found) {
|
||||
if (modelByNote.has(noteId)) continue;
|
||||
ids.push(noteId);
|
||||
modelByNote.set(noteId, modelName);
|
||||
}
|
||||
}
|
||||
if (!ids.length) return [];
|
||||
const infos = await invoke("notesInfo", { notes: ids });
|
||||
if (!Array.isArray(infos)) throw new Error("AnkiConnect returned invalid duplicate note details.");
|
||||
const scoped = await scopedNoteIds(invoke, infos, config);
|
||||
const byId = new Map(infos.filter(info => positiveId(info?.noteId)).map(info => [info.noteId, info]));
|
||||
const matches = [];
|
||||
for (const id of ids) {
|
||||
if (scoped && !scoped.has(id)) continue;
|
||||
const info = byId.get(id);
|
||||
const expectedModel = modelByNote.get(id);
|
||||
if (typeof info?.modelName !== "string" || info.modelName.toLowerCase() !== expectedModel.toLowerCase()) continue;
|
||||
let fields = null;
|
||||
if (info.modelName.toLowerCase() === config.model.toLowerCase()) {
|
||||
if (!info.fields || typeof info.fields !== "object" || Array.isArray(info.fields)) continue;
|
||||
const entries = Object.entries(info.fields).map(([field, value]) =>
|
||||
[field, typeof value === "string" ? value : value?.value]);
|
||||
if (entries.some(([, value]) => typeof value !== "string")) {
|
||||
throw new Error("AnkiConnect returned invalid duplicate note fields.");
|
||||
}
|
||||
fields = Object.fromEntries(entries);
|
||||
}
|
||||
matches.push({ noteId: id, modelName: info.modelName, fields });
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export async function findAnkiOverwriteTarget(invoke, note, firstField, config) {
|
||||
const [target] = await findAnkiDuplicateNotes(invoke, note, firstField, config, { allModels: false });
|
||||
return target ? { noteId: target.noteId, fields: target.fields } : null;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { ankiTemplateMarkerNames } from "./anki-templates.js";
|
||||
import { canonicalAnkiFields, overwriteAnkiFields } from "./anki-duplicates.js";
|
||||
import { readAnkiNoteFields, verifyAnkiFields } from "./anki-mining.js";
|
||||
|
||||
function pronunciationFields(incoming, current, appliedFields, existingFields, warnings) {
|
||||
const fields = {};
|
||||
for (const [field, value] of Object.entries(incoming)) {
|
||||
const baseline = appliedFields[field] ?? existingFields?.[field] ?? "";
|
||||
if (typeof current[field] !== "string" || current[field].normalize("NFC") !== baseline.normalize("NFC")) {
|
||||
warnings.push(`Field “${field}” changed in Anki; its pronunciation update was skipped.`);
|
||||
} else if (current[field] !== value) fields[field] = value;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export async function enrichAnkiNote(context, { audio, render, store }) {
|
||||
const { request, invoke, noteId, appliedFields, existingFields, resolved, resources } = context;
|
||||
if (resources.pronunciationWarning) return [resources.pronunciationWarning];
|
||||
const warnings = [];
|
||||
const confirmed = new Set(Array.isArray(resources.confirmedMedia) ? resources.confirmedMedia : []);
|
||||
async function ensure(file, kind) {
|
||||
if (confirmed.has(file.filename)) return;
|
||||
await store(file, kind);
|
||||
confirmed.add(file.filename);
|
||||
}
|
||||
// Dictionary media and any first-field pronunciation have already been
|
||||
// confirmed before the note mutation. Only deferred pronunciation remains.
|
||||
const canonical = existingFields ? canonicalAnkiFields({}, resolved.templates, existingFields).templates : resolved.templates;
|
||||
const templates = Object.fromEntries(Object.entries(canonical).filter(([field, template]) => {
|
||||
if (!ankiTemplateMarkerNames(template.value).includes("audio")) return false;
|
||||
return !existingFields || (template.overwriteMode !== "skip"
|
||||
&& !(template.overwriteMode === "coalesce" && existingFields[field]));
|
||||
}));
|
||||
// No enabled audio source means no pronunciation, like a screenshot turned off.
|
||||
if (!Object.keys(templates).length || (!resources.audioPrepared && !context.config.audioSources.length)) return warnings;
|
||||
try {
|
||||
const file = resources.audioPrepared ? resources.audio : await audio(request, context.config);
|
||||
await ensure(file, "pronunciation");
|
||||
const rendered = await render(request, templates, `[sound:${file.filename}]`, resources);
|
||||
const incoming = existingFields ? overwriteAnkiFields(rendered.fields, existingFields, templates, { includeAudio: true }) : rendered.fields;
|
||||
// Audio work may have taken seconds. Re-read just before updating; never
|
||||
// clobber an external edit or append to our already-applied text a second
|
||||
// time. AnkiConnect has no CAS, so the final inter-call race remains.
|
||||
const current = await readAnkiNoteFields(invoke, noteId);
|
||||
const fields = pronunciationFields(incoming, current, appliedFields, existingFields, warnings);
|
||||
if (Object.keys(fields).length) {
|
||||
const result = await invoke("updateNoteFields", { note: { id: noteId, fields } }, 10_000);
|
||||
if (result !== null) throw new Error("Anki returned an invalid pronunciation-update acknowledgement. Inspect the saved note.");
|
||||
await verifyAnkiFields(invoke, noteId, fields);
|
||||
}
|
||||
} catch (error) { warnings.push(`Pronunciation: ${error.message}`); }
|
||||
return warnings;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import "./external-links.js";
|
||||
import "./render/glossary.js";
|
||||
|
||||
const BLOCKS = new Set(["BR", "DIV", "LI", "OL", "P", "TABLE", "TBODY", "TD", "TFOOT", "TH", "THEAD", "TR", "UL"]);
|
||||
function plainText(node) {
|
||||
if (node.nodeType === 3) return node.textContent;
|
||||
if (node.getAttribute?.("aria-hidden") === "true") return "";
|
||||
return [...node.childNodes].map(plainText).join("") + (BLOCKS.has(node.nodeName) ? "\n" : "");
|
||||
}
|
||||
|
||||
function imageSize(image, value) {
|
||||
const units = value.sizeUnits === "em" ? "em" : "px";
|
||||
for (const dimension of ["width", "height"]) {
|
||||
if (Number.isFinite(value[dimension]) && value[dimension] > 0) image.setAttribute(dimension, String(value[dimension]));
|
||||
const preferred = value[dimension === "width" ? "preferredWidth" : "preferredHeight"];
|
||||
if (Number.isFinite(preferred) && preferred > 0) image.style[dimension] = `${preferred}${units}`;
|
||||
}
|
||||
if (image.style.width && !image.style.height) image.style.height = "auto";
|
||||
else if (image.style.height && !image.style.width) image.style.width = "auto";
|
||||
}
|
||||
|
||||
export function createAnkiDefinitionRenderer(document, request, filenameFor) {
|
||||
const inert = document.implementation.createHTMLDocument("");
|
||||
const groups = new Map();
|
||||
for (const glossary of request.term.glossaries) {
|
||||
if (!groups.has(glossary.dictionary)) groups.set(glossary.dictionary, []);
|
||||
groups.get(glossary.dictionary).push(glossary);
|
||||
}
|
||||
const media = new Map(request.dictionaryMedia.map(item => [JSON.stringify([item.dictionary, item.path]), item.filename]));
|
||||
const dictionaryAlias = dictionary => Object.hasOwn(request.dictionaryAliases, dictionary)
|
||||
? request.dictionaryAliases[dictionary] : dictionary;
|
||||
const escape = text => { const span = inert.createElement("span"); span.textContent = text; return span.innerHTML; };
|
||||
|
||||
function appendImage(doc, parent, value, { dictionary, path }, pending) {
|
||||
const image = doc.createElement("img");
|
||||
image.className = "gloss-sc-img";
|
||||
pending.push(Promise.resolve(filenameFor ? filenameFor(dictionary, path) : media.get(JSON.stringify([dictionary, path])))
|
||||
.then(filename => { if (filename) image.setAttribute("src", filename); }));
|
||||
image.alt = typeof value.title === "string" ? value.title : "Dictionary image";
|
||||
image.style.maxWidth = "100%";
|
||||
image.style.objectFit = "contain";
|
||||
imageSize(image, value);
|
||||
parent.append(image);
|
||||
}
|
||||
|
||||
function content(glossary, pending) {
|
||||
const body = inert.createElement("div");
|
||||
body.className = "gsm-hoshidicts-glossary-content";
|
||||
body.dataset.hoshidictsDictionary = glossary.dictionary;
|
||||
globalThis.HDGlossary.appendTextOnlyGlossary(inert, body, glossary.glossary, {
|
||||
dictionary: glossary.dictionary, appendImage: pending ? (...args) => appendImage(...args, pending) : () => {},
|
||||
});
|
||||
return body;
|
||||
}
|
||||
|
||||
function plainDefinition(selected, noDictionary) {
|
||||
const lines = [];
|
||||
for (const [dictionary, glossaries] of selected) {
|
||||
if (!noDictionary) lines.push(`(${escape(dictionaryAlias(dictionary))})`);
|
||||
for (const glossary of glossaries) {
|
||||
lines.push(...plainText(content(glossary)).split(/\r?\n|\r/u).map(line => line.trim()).filter(Boolean).map(escape));
|
||||
}
|
||||
}
|
||||
return lines.join("<br>");
|
||||
}
|
||||
|
||||
function entry(glossary, brief, noDictionary, pending) {
|
||||
const wrapper = inert.createElement("div");
|
||||
const labels = brief ? [] : [glossary.definitionTags, glossary.termTags,
|
||||
noDictionary ? "" : dictionaryAlias(glossary.dictionary)].filter(Boolean);
|
||||
if (labels.length) {
|
||||
const meta = inert.createElement("i");
|
||||
meta.className = "yomitan-glossary-meta";
|
||||
meta.textContent = `(${labels.join(", ")})`;
|
||||
wrapper.append(meta, " ");
|
||||
}
|
||||
wrapper.append(content(glossary, pending));
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function appendStyles(root, selected) {
|
||||
const names = new Set(selected.map(([name]) => name));
|
||||
const styles = request.dictionaryStyles.filter(style => names.has(style.dictionary));
|
||||
if (!styles.length) return;
|
||||
const applied = globalThis.HDGlossary.applyDictionaryStyles(document, root, request.generation, styles);
|
||||
// Escape a serialized closing style tag's slash without corrupting CSS
|
||||
// strings or pre-existing selector escapes.
|
||||
for (const style of applied) style.textContent = style.textContent.replace(/<\/style/giu, value => String.raw`<\/${value.slice(2)}`);
|
||||
}
|
||||
|
||||
function appendDetails(root) {
|
||||
const details = [];
|
||||
if (request.term.rules) details.push(`Rules: ${escape(request.term.rules)}`);
|
||||
if (request.trace.length) details.push(`Deinflection: ${request.trace.map(step => escape(step.name)).join(" > ")}`);
|
||||
if (!details.length) return;
|
||||
const small = inert.createElement("small");
|
||||
small.className = "yomitan-glossary-details";
|
||||
small.innerHTML = details.join("<br>");
|
||||
root.append(small);
|
||||
}
|
||||
|
||||
return async ({ dictionary, firstOnly = false, brief = false, noDictionary = false, plain = false }) => {
|
||||
let selected = [...groups].filter(([name]) => dictionary === undefined || name === dictionary);
|
||||
if (firstOnly) selected = selected.slice(0, 1);
|
||||
if (!selected.length) return "";
|
||||
if (plain) return plainDefinition(selected, noDictionary);
|
||||
const pending = [];
|
||||
const root = inert.createElement("div");
|
||||
root.className = "yomitan-glossary";
|
||||
root.style.cssText = "text-align: left; contain: layout paint style; isolation: isolate;";
|
||||
const list = inert.createElement("ol");
|
||||
for (const [name, glossaries] of selected) {
|
||||
const page = inert.createElement("li");
|
||||
page.dataset.dictionary = name;
|
||||
if (glossaries.length === 1) page.append(entry(glossaries[0], brief, noDictionary, pending));
|
||||
else {
|
||||
const senses = inert.createElement("ul");
|
||||
for (const glossary of glossaries) { const sense = inert.createElement("li"); sense.append(entry(glossary, brief, noDictionary, pending)); senses.append(sense); }
|
||||
page.append(senses);
|
||||
}
|
||||
list.append(page);
|
||||
}
|
||||
root.append(list);
|
||||
appendStyles(root, selected);
|
||||
if (!brief) appendDetails(root);
|
||||
await Promise.all(pending);
|
||||
return root.outerHTML;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { ankiIndexSource, ankiWordKey } from "./anki-index.js";
|
||||
|
||||
export const ANKI_INDEX_KEY = "ankiDuplicateIndex";
|
||||
export const ANKI_INDEX_ALARM = "hachidori-anki-index";
|
||||
export const ANKI_INDEX_REFRESH_MS = 30 * 60 * 1000;
|
||||
|
||||
const positiveId = value => Number.isSafeInteger(value) && value > 0;
|
||||
const compare = (left, right) => Number(left > right) - Number(left < right);
|
||||
|
||||
function normalizedRow(value) {
|
||||
if (!Array.isArray(value) || value.length !== 3 || typeof value[0] !== "string" || !value[0]
|
||||
|| typeof value[1] !== "boolean" || !Array.isArray(value[2]) || !value[2].every(positiveId)) return null;
|
||||
const noteIds = [...new Set(value[2])].sort((left, right) => left - right);
|
||||
if (!noteIds.length) return null;
|
||||
return [value[0], value[1], noteIds];
|
||||
}
|
||||
|
||||
function normalizedRows(value) {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const rows = value.map(normalizedRow);
|
||||
if (rows.some(row => row === null)) return null;
|
||||
rows.sort(([left], [right]) => compare(left, right));
|
||||
if (rows.some((row, index) => index > 0 && rows[index - 1][0] === row[0])) return null;
|
||||
return rows;
|
||||
}
|
||||
|
||||
function cacheState(value) {
|
||||
const state = value?.version === 1 ? value : {};
|
||||
const snapshot = state.snapshot;
|
||||
const rows = normalizedRows(snapshot?.rows);
|
||||
const attempt = state.attempt;
|
||||
return {
|
||||
version: 1,
|
||||
configurationRevision: Number.isInteger(state.configurationRevision) ? state.configurationRevision : 0,
|
||||
rowRevision: Number.isInteger(state.rowRevision) ? state.rowRevision : 0,
|
||||
snapshot: typeof snapshot?.sourceKey === "string" && Number.isFinite(snapshot.refreshedAt) && rows !== null
|
||||
? { sourceKey: snapshot.sourceKey, refreshedAt: snapshot.refreshedAt, rows } : null,
|
||||
attempt: typeof attempt?.sourceKey === "string" && Number.isFinite(attempt.startedAt)
|
||||
? {
|
||||
sourceKey: attempt.sourceKey,
|
||||
startedAt: attempt.startedAt,
|
||||
...(Number.isFinite(attempt.finishedAt) ? { finishedAt: attempt.finishedAt } : {}),
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function sourceFor(options) {
|
||||
return ankiIndexSource(options.anki);
|
||||
}
|
||||
|
||||
export async function ankiIndexConfigurationChange(previous, next, value) {
|
||||
const [before, after] = await Promise.all([sourceFor(previous), sourceFor(next)]);
|
||||
if (before?.key === after?.key) return undefined;
|
||||
const state = cacheState(value);
|
||||
return { ...state, configurationRevision: state.configurationRevision + 1, attempt: null };
|
||||
}
|
||||
|
||||
function rowMap(snapshot) {
|
||||
return new Map((snapshot?.rows ?? []).map(([word, mature, noteIds]) =>
|
||||
[word, { mature, noteIds: [...noteIds] }]));
|
||||
}
|
||||
|
||||
function rowsFromMap(rows) {
|
||||
return [...rows].sort(([left], [right]) => compare(left, right))
|
||||
.map(([word, row]) => [word, row.mature, [...row.noteIds]]);
|
||||
}
|
||||
|
||||
function sameRow(left, right) {
|
||||
return left?.mature === right?.mature
|
||||
&& JSON.stringify(left?.noteIds ?? null) === JSON.stringify(right?.noteIds ?? null);
|
||||
}
|
||||
|
||||
function normalizedLookup(value, wordKey) {
|
||||
if (!value || value.wordKey !== wordKey || typeof value.mature !== "boolean"
|
||||
|| !Array.isArray(value.noteIds) || !value.noteIds.every(positiveId)) {
|
||||
throw new Error("Anki returned an invalid duplicate lookup result.");
|
||||
}
|
||||
return {
|
||||
wordKey,
|
||||
mature: value.mature,
|
||||
noteIds: [...new Set(value.noteIds)].sort((left, right) => left - right),
|
||||
};
|
||||
}
|
||||
|
||||
function ownsAttempt(current, source, token) {
|
||||
return current.attempt?.sourceKey === source.key && current.attempt.startedAt === token.startedAt;
|
||||
}
|
||||
|
||||
export function createAnkiDuplicateIndex({
|
||||
fetchRows,
|
||||
lookupLive,
|
||||
readOptions,
|
||||
readState,
|
||||
updateState,
|
||||
alarms,
|
||||
now = Date.now,
|
||||
reportError = error => console.warn("hachidori: Anki index refresh failed:", error),
|
||||
}) {
|
||||
let snapshot = null;
|
||||
let rows = new Map();
|
||||
let active = null;
|
||||
// The reservation this worker made most recently. A stored attempt that has
|
||||
// no recorded outcome and was not reserved here belongs to a worker that
|
||||
// stopped mid-pull (host torn down, worker restarted): it is due now rather
|
||||
// than at its 30-minute mark, which no alarm may be armed to reach.
|
||||
let ownStartedAt = null;
|
||||
let controlTail = Promise.resolve();
|
||||
let suspended = false;
|
||||
const liveLookups = new Map();
|
||||
const hydrate = readState().then(value => {
|
||||
snapshot = cacheState(value).snapshot;
|
||||
rows = rowMap(snapshot);
|
||||
}).catch(reportError);
|
||||
|
||||
function install(value) {
|
||||
snapshot = cacheState(value).snapshot;
|
||||
rows = rowMap(snapshot);
|
||||
}
|
||||
|
||||
function control(job) {
|
||||
const run = controlTail.then(job);
|
||||
controlTail = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
async function schedule(when) {
|
||||
const existing = await alarms.get(ANKI_INDEX_ALARM);
|
||||
if (when === null) {
|
||||
if (existing) await alarms.clear(ANKI_INDEX_ALARM);
|
||||
} else if (!existing || existing.scheduledTime !== when || existing.periodInMinutes !== undefined) {
|
||||
await alarms.create(ANKI_INDEX_ALARM, { when });
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRow(source, wordKey, change) {
|
||||
await hydrate;
|
||||
return control(async () => {
|
||||
let changed = false;
|
||||
const saved = await updateState(async ({ options, state: value }) => {
|
||||
const current = cacheState(value);
|
||||
const currentSource = await sourceFor(options);
|
||||
if (currentSource?.key !== source.key) return;
|
||||
const currentRows = current.snapshot?.sourceKey === source.key ? rowMap(current.snapshot) : new Map();
|
||||
const previous = currentRows.get(wordKey) ?? null;
|
||||
const next = change(previous);
|
||||
if (next === null) currentRows.delete(wordKey);
|
||||
else currentRows.set(wordKey, {
|
||||
mature: next.mature,
|
||||
noteIds: [...new Set(next.noteIds)].sort((left, right) => left - right),
|
||||
});
|
||||
if (sameRow(previous, next)) return;
|
||||
changed = true;
|
||||
return {
|
||||
...current,
|
||||
rowRevision: current.rowRevision + 1,
|
||||
snapshot: {
|
||||
sourceKey: source.key,
|
||||
refreshedAt: current.snapshot?.sourceKey === source.key ? current.snapshot.refreshedAt : now(),
|
||||
rows: rowsFromMap(currentRows),
|
||||
},
|
||||
};
|
||||
});
|
||||
if (changed) install(saved);
|
||||
});
|
||||
}
|
||||
|
||||
async function pull(source, token) {
|
||||
try {
|
||||
const nextRows = normalizedRows(await fetchRows(source));
|
||||
if (nextRows === null) throw new Error("Anki returned an invalid index snapshot.");
|
||||
await control(async () => {
|
||||
let committed = false;
|
||||
const saved = await updateState(async ({ options, state: value }) => {
|
||||
const current = cacheState(value);
|
||||
const currentSource = await sourceFor(options);
|
||||
if (current.configurationRevision !== token.configurationRevision
|
||||
|| currentSource?.key !== source.key
|
||||
|| !ownsAttempt(current, source, token)) return;
|
||||
// A repaired miss or confirmed write after this pull began must not
|
||||
// be erased by a response that took its snapshot before that change.
|
||||
if (current.rowRevision !== token.rowRevision) {
|
||||
return { ...current, attempt: null };
|
||||
}
|
||||
committed = true;
|
||||
return {
|
||||
...current,
|
||||
rowRevision: current.rowRevision + 1,
|
||||
snapshot: { sourceKey: source.key, refreshedAt: now(), rows: nextRows },
|
||||
attempt: { ...current.attempt, finishedAt: now() },
|
||||
};
|
||||
});
|
||||
if (committed) install(saved);
|
||||
});
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
// A pull that ran to failure keeps its 30-minute backoff; only a pull
|
||||
// that never records an outcome is retried by the next worker start.
|
||||
await updateState(async ({ state: value }) => {
|
||||
const current = cacheState(value);
|
||||
if (!ownsAttempt(current, source, token) || current.attempt.finishedAt !== undefined) return;
|
||||
return { ...current, attempt: { ...current.attempt, finishedAt: now() } };
|
||||
}).catch(reportError);
|
||||
} finally {
|
||||
await control(() => { if (active?.token === token) active = null; });
|
||||
// A configuration or row change during the pull may require an immediate
|
||||
// replacement. A successful or failed current pull remains due in 30 min.
|
||||
void reconcile();
|
||||
}
|
||||
}
|
||||
|
||||
function refreshDue(attempt, source) {
|
||||
if (attempt?.sourceKey !== source.key) return 0;
|
||||
if (attempt.finishedAt === undefined && attempt.startedAt !== ownStartedAt) return 0;
|
||||
return attempt.startedAt + ANKI_INDEX_REFRESH_MS;
|
||||
}
|
||||
|
||||
async function startDueRefresh() {
|
||||
await hydrate;
|
||||
if (suspended) {
|
||||
await schedule(null);
|
||||
return null;
|
||||
}
|
||||
const source = await sourceFor(await readOptions());
|
||||
if (!source) {
|
||||
await schedule(null);
|
||||
return null;
|
||||
}
|
||||
if (active) return { promise: active.promise };
|
||||
const state = cacheState(await readState());
|
||||
const due = refreshDue(state.attempt, source);
|
||||
if (due > now()) {
|
||||
await schedule(due);
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = { startedAt: now() };
|
||||
let reserved = false;
|
||||
await updateState(async ({ options, state: value }) => {
|
||||
const current = cacheState(value);
|
||||
const currentSource = await sourceFor(options);
|
||||
if (currentSource?.key !== source.key) return;
|
||||
if (refreshDue(current.attempt, source) > now()) return;
|
||||
reserved = true;
|
||||
token.configurationRevision = current.configurationRevision;
|
||||
token.rowRevision = current.rowRevision;
|
||||
return { ...current, attempt: { sourceKey: source.key, startedAt: token.startedAt } };
|
||||
});
|
||||
if (!reserved) return null;
|
||||
ownStartedAt = token.startedAt;
|
||||
await schedule(token.startedAt + ANKI_INDEX_REFRESH_MS);
|
||||
const promise = pull(source, token);
|
||||
active = { token, promise };
|
||||
return { promise };
|
||||
}
|
||||
|
||||
function reconcile() {
|
||||
return control(startDueRefresh).then(job => job?.promise).catch(reportError);
|
||||
}
|
||||
|
||||
async function suspend() {
|
||||
suspended = true;
|
||||
await hydrate;
|
||||
// Let a refresh which already reserved its pull publish `active`, then
|
||||
// wait outside the control queue so its completion can use that queue.
|
||||
await control(async () => {});
|
||||
await active?.promise;
|
||||
await schedule(null);
|
||||
}
|
||||
|
||||
function resume() {
|
||||
suspended = false;
|
||||
return reconcile();
|
||||
}
|
||||
|
||||
async function local(config, expression) {
|
||||
const [source, wordKey] = await Promise.all([ankiIndexSource(config), Promise.resolve(ankiWordKey(expression))]);
|
||||
if (source === null || wordKey === null) {
|
||||
return { source, wordKey, mature: false, noteIds: [], cached: false };
|
||||
}
|
||||
await hydrate;
|
||||
const cached = snapshot?.sourceKey === source.key ? rows.get(wordKey) : null;
|
||||
return cached
|
||||
? { source, wordKey, mature: cached.mature, noteIds: [...cached.noteIds], cached: true }
|
||||
: { source, wordKey, mature: false, noteIds: [], cached: false };
|
||||
}
|
||||
|
||||
async function find(config, expression, invoke, force) {
|
||||
const cached = await local(config, expression);
|
||||
const { source, wordKey } = cached;
|
||||
if (source === null || wordKey === null) {
|
||||
return { wordKey, mature: false, noteIds: [], cached: false };
|
||||
}
|
||||
if (!force && cached.cached) {
|
||||
return { wordKey, mature: cached.mature, noteIds: cached.noteIds, cached: true };
|
||||
}
|
||||
const liveKey = `${source.key}\n${wordKey}\n${force ? "repair" : "lookup"}`;
|
||||
let operation = liveLookups.get(liveKey);
|
||||
if (!operation) {
|
||||
operation = Promise.resolve(lookupLive(source, expression, invoke))
|
||||
.then(value => normalizedLookup(value, wordKey))
|
||||
.then(async value => {
|
||||
if (value.noteIds.length) {
|
||||
await updateRow(source, wordKey, () => ({ mature: value.mature, noteIds: value.noteIds }));
|
||||
} else if (force) {
|
||||
await updateRow(source, wordKey, () => null);
|
||||
}
|
||||
return value;
|
||||
})
|
||||
.finally(() => liveLookups.delete(liveKey));
|
||||
liveLookups.set(liveKey, operation);
|
||||
}
|
||||
return { ...await operation, cached: false };
|
||||
}
|
||||
|
||||
return {
|
||||
reconcile,
|
||||
suspend,
|
||||
resume,
|
||||
source: ankiIndexSource,
|
||||
async peek(config, expression) {
|
||||
const result = await local(config, expression);
|
||||
return { wordKey: result.wordKey, mature: result.mature, noteIds: result.noteIds, cached: result.cached };
|
||||
},
|
||||
lookup: (config, expression, invoke) => find(config, expression, invoke, false),
|
||||
repair: (config, expression, invoke) => find(config, expression, invoke, true),
|
||||
async recordWrite(config, expression, noteId, { mature = false } = {}) {
|
||||
if (!positiveId(noteId)) throw new Error("Anki returned an invalid written note ID.");
|
||||
const [source, wordKey] = await Promise.all([ankiIndexSource(config), Promise.resolve(ankiWordKey(expression))]);
|
||||
if (source === null || wordKey === null) return;
|
||||
await updateRow(source, wordKey, previous => ({
|
||||
mature: previous?.mature ?? mature,
|
||||
noteIds: [...(previous?.noteIds ?? []), noteId],
|
||||
}));
|
||||
},
|
||||
async has(config, expression) {
|
||||
const [source, wordKey] = await Promise.all([ankiIndexSource(config), Promise.resolve(ankiWordKey(expression))]);
|
||||
if (source === null || wordKey === null) return false;
|
||||
await hydrate;
|
||||
return snapshot?.sourceKey === source.key && rows.get(wordKey)?.mature === true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { createAnkiGateway } from "./anki.js";
|
||||
import { fetchAnkiIndex } from "./anki-index.js";
|
||||
|
||||
self.onmessage = async ({ data: source }) => {
|
||||
try {
|
||||
const gateway = createAnkiGateway();
|
||||
const invoke = (action, params, timeoutMs) =>
|
||||
gateway.invoke(action, params, source.apiKey, timeoutMs, source.url);
|
||||
self.postMessage({ rows: await fetchAnkiIndex(invoke, source) });
|
||||
} catch (error) {
|
||||
self.postMessage({ error: error.message || String(error) });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,265 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { ankiMultiResults } from "./anki.js";
|
||||
import { ankiDigest } from "./anki-digest.js";
|
||||
import { ankiSetupFamily, ankiSetupTemplates } from "./anki-setup.js";
|
||||
import { escapeAnkiHtml, resolveAnkiTemplates } from "./anki-templates.js";
|
||||
import "./reader-options.js";
|
||||
|
||||
// Anki parses these field names as operators before considering a field search.
|
||||
// They cannot identify a direct expression value reliably.
|
||||
const SEARCH_OPERATORS = new Set(["deck", "note", "tag", "card", "flag", "resched", "prop", "added", "edited",
|
||||
"introduced", "rated", "is", "did", "mid", "nid", "cid", "re", "nc", "sc", "w", "dupe", "has-cd", "preset"]);
|
||||
const positiveId = value => Number.isSafeInteger(value) && value > 0;
|
||||
const foldAscii = value => value.replace(/[A-Z]/gu, character => character.toLowerCase());
|
||||
const nameKey = value => value.normalize("NFC").toLowerCase();
|
||||
const compare = (left, right) => Number(left > right) - Number(left < right);
|
||||
const escapeQuery = value => value.replace(/[\\"*_:]/gu, String.raw`\$&`);
|
||||
const searchToken = (operator, value) => `"${escapeQuery(operator)}:${escapeQuery(value)}"`;
|
||||
|
||||
function directExpressionFields(config, names = config.fieldTemplates === null
|
||||
? Object.values(config.fields).filter(Boolean) : Object.keys(config.fieldTemplates)) {
|
||||
const { templates } = resolveAnkiTemplates(config, names);
|
||||
return [...new Set(Object.entries(templates)
|
||||
.filter(([field, template]) => /^\{expression\}$/iu.test(template.value)
|
||||
&& !SEARCH_OPERATORS.has(field.toLowerCase()))
|
||||
.map(([field]) => nameKey(field)))].sort(compare);
|
||||
}
|
||||
|
||||
export function ankiWordKey(expression) {
|
||||
if (typeof expression !== "string" || !expression) return null;
|
||||
// Ordinary Anki field search escapes the rendered HTML, folds ASCII only,
|
||||
// and normalizes query text to NFC. Stored field values remain unnormalized.
|
||||
return foldAscii(escapeAnkiHtml(expression).normalize("NFC"));
|
||||
}
|
||||
|
||||
function storedWordKey(value) {
|
||||
return typeof value === "string" && value ? foldAscii(value) : null;
|
||||
}
|
||||
|
||||
export async function ankiIndexSource(config) {
|
||||
if (!config.model) return null;
|
||||
const url = globalThis.HDReaderOptions.normaliseAnkiConnectUrl(
|
||||
config.url === undefined ? globalThis.HDReaderOptions.DEFAULT_OPTIONS.anki.url : config.url
|
||||
);
|
||||
if (!url) return null;
|
||||
const fields = directExpressionFields(config);
|
||||
if (!fields.length) return null;
|
||||
const source = {
|
||||
url,
|
||||
apiKey: config.apiKey,
|
||||
scope: config.duplicateScope,
|
||||
model: config.model,
|
||||
fields,
|
||||
...(config.duplicateScope === "deck" ? { deck: config.deck } : {}),
|
||||
};
|
||||
return { key: await ankiDigest(new TextEncoder().encode(JSON.stringify(source))), ...source };
|
||||
}
|
||||
|
||||
function modelMap(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("AnkiConnect returned an invalid note type list.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function fieldList(value) {
|
||||
if (!Array.isArray(value) || value.some(field => typeof field !== "string" || !field)) {
|
||||
throw new Error("AnkiConnect returned an invalid field list.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function recognizedModels(invoke, source) {
|
||||
if (source.scope === "model") return [{ name: source.model, fields: source.fields }];
|
||||
const available = modelMap(await invoke("modelNamesAndIds", {}));
|
||||
const models = [];
|
||||
if (positiveId(available[source.model])) {
|
||||
models.push({ name: source.model, fields: source.fields });
|
||||
}
|
||||
const base = globalThis.HDReaderOptions.DEFAULT_OPTIONS.anki;
|
||||
for (const [model, id] of Object.entries(available)) {
|
||||
if (model === source.model) continue;
|
||||
const family = ankiSetupFamily(model);
|
||||
if (family === null || !positiveId(id)) continue;
|
||||
const fields = fieldList(await invoke("modelFieldNames", { modelName: model }));
|
||||
const templates = ankiSetupTemplates(family, model, source.deck ?? "Default", fields, base);
|
||||
if (templates === null) continue;
|
||||
const expressionFields = directExpressionFields({ ...base, model, fieldTemplates: templates }, fields);
|
||||
if (expressionFields.length) models.push({ name: model, fields: expressionFields });
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
function modelQuery(models) {
|
||||
const clauses = models.map(model => searchToken("note", model.name));
|
||||
if (clauses.length === 0) return null;
|
||||
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" or ")})`;
|
||||
}
|
||||
|
||||
function scopedQuery(source, query) {
|
||||
return source.scope === "deck" ? `${query} ${searchToken("deck", source.deck)}` : query;
|
||||
}
|
||||
|
||||
function completeQuery(source, models) {
|
||||
const query = modelQuery(models);
|
||||
return query === null ? null : scopedQuery(source, query);
|
||||
}
|
||||
|
||||
function lookupQuery(source, models, expression) {
|
||||
const value = escapeAnkiHtml(expression).normalize("NFC");
|
||||
const clauses = models.flatMap(model => model.fields.map(field =>
|
||||
`(${searchToken("note", model.name)} ${searchToken(field, value)})`));
|
||||
if (clauses.length === 0) return null;
|
||||
const query = clauses.length === 1 ? clauses[0] : `(${clauses.join(" or ")})`;
|
||||
return scopedQuery(source, query);
|
||||
}
|
||||
|
||||
function noteFields(info) {
|
||||
if (!info?.fields || typeof info.fields !== "object" || Array.isArray(info.fields)) {
|
||||
throw new Error("AnkiConnect returned invalid note details.");
|
||||
}
|
||||
const entries = Object.entries(info.fields).map(([field, value]) =>
|
||||
[field, typeof value === "string" ? value : value?.value]);
|
||||
if (entries.some(([, value]) => typeof value !== "string")) {
|
||||
throw new Error("AnkiConnect returned invalid note details.");
|
||||
}
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function indexedNotes(value, models, expectedIds = null) {
|
||||
if (!Array.isArray(value)) throw new Error("AnkiConnect returned invalid note details.");
|
||||
const byModel = new Map(models.map(model => [nameKey(model.name), model]));
|
||||
const expected = expectedIds === null ? null : new Set(expectedIds);
|
||||
const seen = new Set();
|
||||
const notes = value.map(info => {
|
||||
if (!positiveId(info?.noteId) || typeof info.modelName !== "string") {
|
||||
throw new Error("AnkiConnect returned invalid note details.");
|
||||
}
|
||||
if ((expected && !expected.has(info.noteId)) || seen.has(info.noteId)) {
|
||||
throw new Error("AnkiConnect returned invalid note details.");
|
||||
}
|
||||
seen.add(info.noteId);
|
||||
const model = byModel.get(nameKey(info.modelName));
|
||||
if (!model) throw new Error("AnkiConnect returned notes outside the requested note types.");
|
||||
const fields = noteFields(info);
|
||||
const names = new Map(Object.keys(fields).map(field => [nameKey(field), field]));
|
||||
if (model.fields.some(field => !names.has(field))) {
|
||||
throw new Error("AnkiConnect returned invalid note details.");
|
||||
}
|
||||
return { noteId: info.noteId, model, fields, names };
|
||||
});
|
||||
if (expected && seen.size !== expected.size) throw new Error("AnkiConnect returned invalid note details.");
|
||||
return notes;
|
||||
}
|
||||
|
||||
function returnedNoteIds(value, message = "AnkiConnect returned invalid note IDs.") {
|
||||
if (!Array.isArray(value) || !value.every(positiveId)) {
|
||||
throw new Error(message);
|
||||
}
|
||||
return [...new Set(value)].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
const matureQuery = query => `${query} is:review -is:learn prop:ivl>=21`;
|
||||
|
||||
function matureNoteIds(value, candidates) {
|
||||
const mature = new Set(returnedNoteIds(value, "AnkiConnect returned invalid mature note IDs."));
|
||||
if ([...mature].some(noteId => !candidates.has(noteId))) {
|
||||
throw new Error("AnkiConnect returned invalid mature note IDs.");
|
||||
}
|
||||
return mature;
|
||||
}
|
||||
|
||||
function compactRows(notes, mature) {
|
||||
const rows = new Map();
|
||||
for (const note of notes) {
|
||||
for (const field of note.model.fields) {
|
||||
const key = storedWordKey(note.fields[note.names.get(field)]);
|
||||
if (key === null) continue;
|
||||
const row = rows.get(key) ?? { mature: false, noteIds: new Set() };
|
||||
row.noteIds.add(note.noteId);
|
||||
row.mature ||= mature.has(note.noteId);
|
||||
rows.set(key, row);
|
||||
}
|
||||
}
|
||||
return [...rows].sort(([left], [right]) => compare(left, right))
|
||||
.map(([word, row]) => [word, row.mature, [...row.noteIds].sort((left, right) => left - right)]);
|
||||
}
|
||||
|
||||
export async function fetchAnkiIndex(invoke, source) {
|
||||
const models = await recognizedModels(invoke, source);
|
||||
const query = completeQuery(source, models);
|
||||
if (query === null) return [];
|
||||
const [candidateResult, matureResult] = await Promise.all([
|
||||
invoke("findNotes", { query }, 25_000),
|
||||
invoke("findNotes", { query: matureQuery(query) }, 25_000),
|
||||
]);
|
||||
const candidateIds = returnedNoteIds(candidateResult);
|
||||
const mature = matureNoteIds(matureResult, new Set(candidateIds));
|
||||
if (!candidateIds.length) return [];
|
||||
const infos = await invoke("notesInfo", { notes: candidateIds }, 25_000);
|
||||
return compactRows(indexedNotes(infos, models, candidateIds), mature);
|
||||
}
|
||||
|
||||
// A popup cache miss waits on this, and each AnkiConnect request costs one
|
||||
// poll interval, so the candidate and maturity searches share one `multi`
|
||||
// round trip and `notesInfo` is the only other stage. Maturity is the scoped
|
||||
// query's mature subset intersected with the exactly matching notes: Anki
|
||||
// searches cards, so in deck scope a note counts as mature only through a
|
||||
// mature card inside the configured deck, exactly as the complete index does.
|
||||
export async function lookupAnkiIndex(invoke, source, expression) {
|
||||
const wordKey = ankiWordKey(expression);
|
||||
if (wordKey === null) return { wordKey, mature: false, noteIds: [] };
|
||||
const models = await recognizedModels(invoke, source);
|
||||
const query = lookupQuery(source, models, expression);
|
||||
if (query === null) return { wordKey, mature: false, noteIds: [] };
|
||||
const [candidateResult, matureResult] = ankiMultiResults(await invoke("multi", { actions: [
|
||||
{ action: "findNotes", params: { query } },
|
||||
{ action: "findNotes", params: { query: matureQuery(query) } },
|
||||
] }));
|
||||
const candidateIds = returnedNoteIds(candidateResult);
|
||||
const mature = matureNoteIds(matureResult, new Set(candidateIds));
|
||||
if (!candidateIds.length) return { wordKey, mature: false, noteIds: [] };
|
||||
const candidates = indexedNotes(await invoke("notesInfo", { notes: candidateIds }), models, candidateIds);
|
||||
const noteIds = [];
|
||||
for (const note of candidates) {
|
||||
if (note.model.fields.some(field => storedWordKey(note.fields[note.names.get(field)]) === wordKey)) {
|
||||
noteIds.push(note.noteId);
|
||||
}
|
||||
}
|
||||
noteIds.sort((left, right) => left - right);
|
||||
const unique = [...new Set(noteIds)];
|
||||
return { wordKey, mature: unique.some(noteId => mature.has(noteId)), noteIds: unique };
|
||||
}
|
||||
|
||||
export async function inspectAnkiNoteIds(invoke, source, expression, noteIds) {
|
||||
if (!Array.isArray(noteIds) || !noteIds.every(positiveId)) {
|
||||
throw new Error("Anki duplicate inspection requires valid note IDs.");
|
||||
}
|
||||
const ids = [...new Set(noteIds)].sort((left, right) => left - right);
|
||||
const infos = await invoke("notesInfo", { notes: ids });
|
||||
if (!Array.isArray(infos)) throw new Error("AnkiConnect returned invalid duplicate note details.");
|
||||
const requested = new Set(ids);
|
||||
const byId = new Map();
|
||||
for (const info of infos) {
|
||||
if (!positiveId(info?.noteId) || !requested.has(info.noteId) || byId.has(info.noteId)
|
||||
|| typeof info.modelName !== "string") {
|
||||
throw new Error("AnkiConnect returned invalid duplicate note details.");
|
||||
}
|
||||
byId.set(info.noteId, info);
|
||||
}
|
||||
const wordKey = ankiWordKey(expression);
|
||||
let stale = byId.size !== ids.length;
|
||||
let target = null;
|
||||
for (const noteId of ids) {
|
||||
const info = byId.get(noteId);
|
||||
if (!info || nameKey(info.modelName) !== nameKey(source.model)) continue;
|
||||
const fields = noteFields(info);
|
||||
const names = new Map(Object.keys(fields).map(field => [nameKey(field), field]));
|
||||
if (wordKey === null || !source.fields.some(field => storedWordKey(fields[names.get(field)]) === wordKey)) {
|
||||
stale = true;
|
||||
continue;
|
||||
}
|
||||
target ??= { noteId, fields };
|
||||
}
|
||||
return { stale, target };
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { decodedBase64Length } from "./anki-client-media.js";
|
||||
|
||||
const GENERATED_MEDIA_FILENAME = /^hachidori_[0-9a-f]{64}\.[a-z0-9]+$/u;
|
||||
|
||||
function validatePayload(data, kind) {
|
||||
const bytes = decodedBase64Length(data);
|
||||
if (bytes === null || bytes < 1) throw new Error(`The ${kind} payload is not valid base64.`);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function exactMediaExists(invoke, filename) {
|
||||
const names = await invoke("getMediaFilesNames", { pattern: filename }, 10_000);
|
||||
if (!Array.isArray(names) || names.some(name => typeof name !== "string")) {
|
||||
throw new Error("Anki returned an invalid media inventory.");
|
||||
}
|
||||
return names.includes(filename);
|
||||
}
|
||||
|
||||
function referenced(fields, filename) {
|
||||
return Object.values(fields).some(value => typeof value === "string" && value.includes(filename));
|
||||
}
|
||||
|
||||
function requiredDictionaryMedia(resources, fields) {
|
||||
if (!Array.isArray(resources.media)) throw new Error("The planned dictionary media is invalid.");
|
||||
const required = new Map();
|
||||
for (const item of resources.media) {
|
||||
if (!item || typeof item !== "object" || typeof item.filename !== "string"
|
||||
|| typeof item.dictionary !== "string" || typeof item.path !== "string") {
|
||||
throw new Error("The planned dictionary media entry is invalid.");
|
||||
}
|
||||
if (!referenced(fields, item.filename)) continue;
|
||||
const previous = required.get(item.filename);
|
||||
if (previous && (previous.dictionary !== item.dictionary || previous.path !== item.path)) {
|
||||
throw new Error(`Two dictionary resources planned the same Anki filename: ${item.filename}`);
|
||||
}
|
||||
required.set(item.filename, item);
|
||||
}
|
||||
return [...required.values()];
|
||||
}
|
||||
|
||||
async function ensure({
|
||||
invoke,
|
||||
filename,
|
||||
kind,
|
||||
data,
|
||||
load,
|
||||
validate,
|
||||
}) {
|
||||
if (!GENERATED_MEDIA_FILENAME.test(filename)) {
|
||||
throw new Error("The generated Anki media filename is invalid.");
|
||||
}
|
||||
await validate?.();
|
||||
const exists = await exactMediaExists(invoke, filename);
|
||||
await validate?.();
|
||||
if (exists) {
|
||||
return { filename, status: "existing", bytes: 0 };
|
||||
}
|
||||
const loaded = data === undefined ? await load() : { data };
|
||||
const payload = typeof loaded === "string" ? loaded : loaded?.data;
|
||||
const bytes = validatePayload(payload, kind);
|
||||
await validate?.();
|
||||
let stored;
|
||||
try {
|
||||
stored = await invoke("storeMediaFile", { filename, data: payload, deleteExisting: false }, 30_000);
|
||||
} catch (error) {
|
||||
// A timed-out acknowledgement may follow a completed write. Confirm the
|
||||
// exact deterministic name before deciding that the note cannot proceed.
|
||||
if (await exactMediaExists(invoke, filename).catch(() => false)) {
|
||||
return { filename, status: "confirmed-after-error", bytes };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (stored !== filename) {
|
||||
const exists = await exactMediaExists(invoke, filename);
|
||||
if (!exists) throw new Error(`Anki stored ${kind} under a different filename.`);
|
||||
return { filename, status: "existing-race", bytes };
|
||||
}
|
||||
if (!await exactMediaExists(invoke, filename)) {
|
||||
throw new Error(`Anki acknowledged ${kind} without confirming the requested media filename.`);
|
||||
}
|
||||
return { filename, status: "stored", bytes };
|
||||
}
|
||||
|
||||
export function createAnkiMediaStore() {
|
||||
async function prepare({
|
||||
request,
|
||||
invoke,
|
||||
appliedFields,
|
||||
resources,
|
||||
media,
|
||||
validate,
|
||||
}) {
|
||||
const files = [];
|
||||
try {
|
||||
for (const item of requiredDictionaryMedia(resources, appliedFields)) {
|
||||
files.push(await ensure({
|
||||
invoke,
|
||||
filename: item.filename,
|
||||
kind: "dictionary image",
|
||||
validate,
|
||||
load: () => media(item, request.generation),
|
||||
}));
|
||||
}
|
||||
if (resources.audioPrepared && resources.audio && referenced(appliedFields, resources.audio.filename)) {
|
||||
files.push(await ensure({
|
||||
invoke,
|
||||
filename: resources.audio.filename,
|
||||
data: resources.audio.data,
|
||||
kind: "pronunciation",
|
||||
validate,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
const retained = files.length;
|
||||
if (retained > 0) {
|
||||
throw new Error(`${error.message} ${retained} confirmed media ${retained === 1 ? "file was" : "files were"} retained for a safe retry.`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
resources.confirmedMedia = files.map(file => file.filename);
|
||||
return {
|
||||
files,
|
||||
required: files.length,
|
||||
existing: files.filter(file => file.status !== "stored").length,
|
||||
stored: files.filter(file => file.status === "stored").length,
|
||||
uploadedBytes: files.reduce((sum, file) => sum + (file.status === "stored" ? file.bytes : 0), 0),
|
||||
};
|
||||
}
|
||||
|
||||
return { ensure, prepare };
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { ankiAvailability, isUndispatchedAnkiTransportError } from "./anki.js";
|
||||
import { ankiCaptureRequirements, resolveAnkiTemplates } from "./anki-templates.js";
|
||||
import { ankiDigest } from "./anki-digest.js";
|
||||
import { ankiSetupFamily } from "./anki-setup.js";
|
||||
import { inspectAnkiNoteIds } from "./anki-index.js";
|
||||
import { ankiBrowseQuery, ankiNoteIdsQuery, ankiNoteOptions, canonicalAnkiFields, checkAnkiDuplicate, findAnkiDuplicateNotes,
|
||||
isAnkiDuplicateError, overwriteAnkiFields, validateAnkiNote } from "./anki-duplicates.js";
|
||||
|
||||
const CONFIG_CHANGED = "Anki configuration changed. Refresh this result before adding a note.";
|
||||
const AUTOMATIC_CAPTURE_FIELDS = {
|
||||
kiku: { picture: "Picture", audio: "SentenceAudio" },
|
||||
lapis: { picture: "Picture", audio: "SentenceAudio" },
|
||||
senren: { picture: "picture", audio: "sentenceAudio" },
|
||||
};
|
||||
|
||||
function requestConfiguration(current, request) {
|
||||
const fields = AUTOMATIC_CAPTURE_FIELDS[ankiSetupFamily(current.config.model)];
|
||||
if (!fields) return current;
|
||||
const templates = Object.fromEntries(Object.entries(current.resolved.templates)
|
||||
.map(([field, template]) => [field, { ...template }]));
|
||||
const routed = { ...current, resolved: { ...current.resolved, templates } };
|
||||
const capture = current.config.mediaCapture;
|
||||
if (!request.capturePin || capture?.enabled !== true) return routed;
|
||||
if (capture.includeAnimation === true && templates[fields.picture]) {
|
||||
templates[fields.picture].value = templates[fields.picture].value
|
||||
.replaceAll("{screenshot}", "{capture-animation}");
|
||||
}
|
||||
if (capture.includeCapturedAudio === true && templates[fields.audio]?.value.trim() === "") {
|
||||
templates[fields.audio].value = "{capture-audio}";
|
||||
}
|
||||
return routed;
|
||||
}
|
||||
|
||||
export async function readAnkiNoteFields(invoke, noteId) {
|
||||
const infos = await invoke("notesInfo", { notes: [noteId] });
|
||||
const info = Array.isArray(infos) ? infos.find(value => value.noteId === noteId) : null;
|
||||
if (!info?.fields || typeof info.fields !== "object" || Array.isArray(info.fields)) throw new Error("Anki did not return the saved note fields.");
|
||||
return Object.fromEntries(Object.entries(info.fields).map(([field, value]) => [field, value?.value]));
|
||||
}
|
||||
|
||||
export async function verifyAnkiFields(invoke, noteId, expected) {
|
||||
const fields = await readAnkiNoteFields(invoke, noteId);
|
||||
const missing = [], changed = [];
|
||||
for (const [field, value] of Object.entries(expected)) {
|
||||
if (typeof fields[field] !== "string") missing.push(field);
|
||||
else if (fields[field].normalize("NFC") !== value.normalize("NFC")) changed.push(field);
|
||||
}
|
||||
if (missing.length === 0 && changed.length === 0) return;
|
||||
const list = names => names.map(name => `“${name}”`).join(", ");
|
||||
const parts = [];
|
||||
if (missing.length) parts.push(`${missing.length === 1 ? "field" : "fields"} ${list(missing)} ${missing.length === 1 ? "is" : "are"} missing from note ${noteId}`);
|
||||
if (changed.length) parts.push(`${changed.length === 1 ? "field" : "fields"} ${list(changed)} ${changed.length === 1 ? "was" : "were"} saved with different content`);
|
||||
throw new Error(`Anki's saved note differs from the submitted values: ${parts.join("; ")}. Inspect note ${noteId} in Anki.`);
|
||||
}
|
||||
|
||||
async function addableDecision(prepared) {
|
||||
const check = await validateAnkiNote(prepared.invoke, prepared.note);
|
||||
return { state: check.addable ? "addable" : "invalid", canAdd: check.addable, error: check.error };
|
||||
}
|
||||
|
||||
async function unindexedDecision(prepared) {
|
||||
const { invoke, note, config, firstField } = prepared;
|
||||
const checked = await checkAnkiDuplicate(invoke, note, config);
|
||||
if (!checked.duplicate) {
|
||||
return { state: checked.addable ? "addable" : "invalid", canAdd: checked.addable, error: checked.error };
|
||||
}
|
||||
// A non-direct destination field cannot be keyed by the word index. Keep
|
||||
// Anki's exact first-field identity as a compatibility path, restricted to
|
||||
// the configured destination type so unrelated custom models never block.
|
||||
const matches = await findAnkiDuplicateNotes(invoke, note, firstField, config);
|
||||
const noteIds = matches.map(match => match.noteId);
|
||||
if (config.duplicateBehavior === "overwrite") {
|
||||
const target = matches.find(match => match.fields !== null) ?? null;
|
||||
return { state: "duplicate", canAdd: target !== null, action: "overwrite", target, noteIds, mature: false,
|
||||
error: target ? null : "A duplicate exists, but no matching configured note type is inside the selected scope." };
|
||||
}
|
||||
if (config.duplicateBehavior === "new") {
|
||||
const addable = await addableDecision(prepared);
|
||||
if (!addable.canAdd) return addable;
|
||||
}
|
||||
return {
|
||||
state: "duplicate",
|
||||
canAdd: config.duplicateBehavior === "new",
|
||||
error: null,
|
||||
noteIds,
|
||||
mature: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function decision(prepared, request, duplicateIndex) {
|
||||
const { invoke, config } = prepared;
|
||||
const expression = request.term?.expression ?? request.expression;
|
||||
const source = await duplicateIndex.source(config);
|
||||
if (source === null) return unindexedDecision(prepared);
|
||||
let duplicate = await duplicateIndex.lookup(config, expression, invoke);
|
||||
if (!duplicate.noteIds.length) return addableDecision(prepared);
|
||||
if (config.duplicateBehavior === "overwrite") {
|
||||
let inspected = await inspectAnkiNoteIds(invoke, source, expression, duplicate.noteIds);
|
||||
if (inspected.stale) {
|
||||
duplicate = await duplicateIndex.repair(config, expression, invoke);
|
||||
if (!duplicate.noteIds.length) return addableDecision(prepared);
|
||||
inspected = await inspectAnkiNoteIds(invoke, source, expression, duplicate.noteIds);
|
||||
}
|
||||
const target = inspected.target;
|
||||
return { state: "duplicate", canAdd: target !== null, action: "overwrite", target, noteIds: duplicate.noteIds,
|
||||
mature: duplicate.mature,
|
||||
error: target ? null : "A duplicate exists, but no matching configured note type is inside the selected scope." };
|
||||
}
|
||||
if (config.duplicateBehavior === "new") {
|
||||
const addable = await addableDecision(prepared);
|
||||
if (!addable.canAdd) return addable;
|
||||
}
|
||||
return { state: "duplicate", canAdd: config.duplicateBehavior === "new", error: null,
|
||||
noteIds: duplicate.noteIds, mature: duplicate.mature };
|
||||
}
|
||||
|
||||
function omitUnchangedFields(fields, existing) {
|
||||
if (existing) for (const [field, value] of Object.entries(fields)) {
|
||||
if (value === existing[field]) delete fields[field];
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function fieldsForDecision(prepared, checked) {
|
||||
const target = checked.target;
|
||||
if (!target) {
|
||||
return {
|
||||
fields: prepared.note.fields,
|
||||
target: null,
|
||||
templates: prepared.resolved.templates,
|
||||
};
|
||||
}
|
||||
const canonical = canonicalAnkiFields(prepared.note.fields, prepared.resolved.templates, target.fields);
|
||||
// Only the initial write omits unchanged values. Pronunciation enrichment
|
||||
// compares its complete desired value with the text-only write it replaces.
|
||||
const fields = omitUnchangedFields(overwriteAnkiFields(canonical.fields, target.fields, canonical.templates), target.fields);
|
||||
return {
|
||||
fields,
|
||||
target,
|
||||
templates: Object.fromEntries(Object.entries(canonical.templates).filter(([field]) => Object.hasOwn(fields, field))),
|
||||
};
|
||||
}
|
||||
|
||||
function captureForApplication(request, templates) {
|
||||
const requirements = ankiCaptureRequirements(templates);
|
||||
const unavailable = new Set(Array.isArray(request.captureUnavailable) ? request.captureUnavailable : []);
|
||||
requirements.includeAnimation &&= !unavailable.has("animation");
|
||||
requirements.includeAudio &&= !unavailable.has("audio");
|
||||
if (!requirements.includeAnimation && !requirements.includeAudio) return null;
|
||||
const pin = request.capturePin;
|
||||
return {
|
||||
requirements,
|
||||
sourceLabel: pin?.sourceLabel,
|
||||
partial: pin?.partial === true,
|
||||
readyAtMs: pin?.readyAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
// Names the looked-up word in an error, so a reader mining several results
|
||||
// can tell which one Anki refused.
|
||||
function describeRequestTerm(request) {
|
||||
const expression = request?.term?.expression ?? request?.expression;
|
||||
return typeof expression === "string" && expression.trim() ? `“${expression}”` : "this result";
|
||||
}
|
||||
|
||||
async function writeAnkiNote(invoke, note, target, fields, duplicateNoteIds) {
|
||||
let noteId;
|
||||
if (target) {
|
||||
const reply = await invoke("updateNoteFields", { note: { id: target.noteId, fields }, subminerEnrich: true }, 10_000);
|
||||
if (reply !== null) throw new Error("Anki returned an invalid field-update acknowledgement.");
|
||||
noteId = target.noteId;
|
||||
} else {
|
||||
try {
|
||||
noteId = await invoke("addNote", { note, subminerDuplicateNoteIds: duplicateNoteIds }, 10_000);
|
||||
} catch (error) {
|
||||
throw addNoteContext(error, note);
|
||||
}
|
||||
}
|
||||
if (!Number.isSafeInteger(noteId) || noteId <= 0) {
|
||||
throw new Error(`Anki did not return a valid note ID for the ${target ? "updated" : "added"} note in deck “${note.deckName}”. Check the note in Anki.`);
|
||||
}
|
||||
return noteId;
|
||||
}
|
||||
|
||||
// AnkiConnect's "empty" refusal names neither the note nor the field; Anki
|
||||
// strips HTML before judging, so a first field this side considered filled
|
||||
// can still be refused. A duplicate refusal is described by the caller.
|
||||
function addNoteContext(error, note) {
|
||||
const message = error?.message ?? String(error);
|
||||
const [firstField] = Object.keys(note.fields ?? {});
|
||||
const firstValue = firstField === undefined ? "" : String(note.fields[firstField] ?? "");
|
||||
const context = `deck “${note.deckName}”, note type “${note.modelName}”`;
|
||||
if (!/cannot create note because it is empty/iu.test(message)) return error;
|
||||
const detail = `Anki refused the note for ${context} because its first field “${firstField}” is empty`
|
||||
+ `${firstValue.trim() ? " once Anki stripped its formatting" : ""}.`;
|
||||
const raw = (/\(AnkiConnect: (.+)\)$/u.exec(message)?.[1] ?? message).replace(/^AnkiConnect: /u, "");
|
||||
return new Error(`${detail} (AnkiConnect: ${raw})`, { cause: error });
|
||||
}
|
||||
|
||||
export function createAnkiMiningService({
|
||||
gateway,
|
||||
readConfig,
|
||||
buildFields,
|
||||
beforeWrite,
|
||||
beforeMutation = async () => {},
|
||||
afterConfirmed = async () => {},
|
||||
afterRejected = async () => {},
|
||||
preflightExtra = async () => ({}),
|
||||
validateCapture = async () => {},
|
||||
enrich,
|
||||
duplicateIndex,
|
||||
now = Date.now,
|
||||
}) {
|
||||
const cached = new Map();
|
||||
let mutations = Promise.resolve();
|
||||
const invokeFor = config => (action, params, timeoutMs) => gateway.invoke(action, params, config.apiKey, timeoutMs, config.url);
|
||||
|
||||
async function identity(templateId) {
|
||||
const config = await readConfig(templateId);
|
||||
if (!config) throw new Error("The selected Anki Template is no longer available.");
|
||||
const configJson = JSON.stringify(config);
|
||||
const configKey = await ankiDigest(new TextEncoder().encode(JSON.stringify({
|
||||
templateId: templateId ?? null,
|
||||
config,
|
||||
})));
|
||||
return { config, configJson, configKey, templateId: templateId ?? null };
|
||||
}
|
||||
|
||||
async function configuration(templateId, fresh = false) {
|
||||
const current = await identity(templateId);
|
||||
const { config, configJson } = current;
|
||||
const cacheKey = templateId ?? "";
|
||||
const previous = cached.get(cacheKey);
|
||||
if (!fresh && previous?.key === configJson && now() < previous.expires) return previous.promise;
|
||||
const promise = (async () => {
|
||||
// Correlate reader requests without returning the saved API key/source
|
||||
// credentials in a serialized configuration string to each content script.
|
||||
const { configKey } = current;
|
||||
if (!config.model) return { config, configKey, configJson, errors: ["Choose an Anki note type in Settings."] };
|
||||
const discovery = await gateway.discover(config);
|
||||
const resolved = resolveAnkiTemplates(config, discovery.fields);
|
||||
return { config, configKey, configJson, discovery, resolved, errors: ankiAvailability(config, discovery, resolved) };
|
||||
})();
|
||||
// GSM's two-second status cache, sharing concurrent callers as well. Only
|
||||
// read-only preparation may use it; each submission refreshes discovery.
|
||||
cached.set(cacheKey, { key: configJson, expires: now() + 2000, promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function status(templateId) {
|
||||
const current = await configuration(templateId);
|
||||
return { available: current.errors.length === 0, configKey: current.configKey, error: current.errors.join("\n") };
|
||||
}
|
||||
|
||||
async function view(request) {
|
||||
const current = await identity(request?.templateId);
|
||||
const expression = request?.term?.expression ?? request?.expression;
|
||||
const unknown = {
|
||||
state: "unknown",
|
||||
canAdd: false,
|
||||
noteIds: [],
|
||||
configKey: current.configKey,
|
||||
cached: false,
|
||||
};
|
||||
if (current.config.duplicateBehavior !== "prevent") return unknown;
|
||||
const duplicate = await duplicateIndex.peek(current.config, expression);
|
||||
if (!duplicate.noteIds.length) return unknown;
|
||||
return {
|
||||
state: "duplicate",
|
||||
canAdd: false,
|
||||
noteIds: duplicate.noteIds,
|
||||
mature: duplicate.mature,
|
||||
configKey: current.configKey,
|
||||
cached: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function prepare(request, fresh) {
|
||||
const configured = await configuration(request?.templateId, fresh);
|
||||
const current = requestConfiguration(configured, request);
|
||||
if (request.configKey !== current.configKey) throw new Error(CONFIG_CHANGED);
|
||||
if (current.errors.length) throw new Error(current.errors.join("\n"));
|
||||
const resources = await buildFields(request, current, { preflight: !fresh });
|
||||
const { fields } = resources;
|
||||
const firstField = current.discovery.fields[0];
|
||||
if (!fields[firstField]?.trim()) {
|
||||
const template = current.resolved.templates[firstField]?.value ?? "";
|
||||
throw new Error(`The first field of note type “${current.config.model}”, “${firstField}”, is empty for this result`
|
||||
+ `${template ? `: its template ${template} produced nothing for ${describeRequestTerm(request)}` : ""}. Anki requires it.`);
|
||||
}
|
||||
const note = { deckName: current.config.deck, modelName: current.config.model, fields,
|
||||
options: ankiNoteOptions(current.config), tags: [...new Set(current.config.tags)] };
|
||||
return { ...current, note, resources, firstField, invoke: invokeFor(current.config) };
|
||||
}
|
||||
|
||||
async function preflight(request) {
|
||||
const prepared = await prepare(request, false);
|
||||
if (prepared.resources.deferDuplicateCheck === true) {
|
||||
const capture = captureForApplication(request, prepared.resolved.templates);
|
||||
if (capture) await validateCapture({ request, prepared, capture });
|
||||
const extra = await preflightExtra({ request, prepared, applied: null, deferred: true });
|
||||
return {
|
||||
state: "addable",
|
||||
canAdd: true,
|
||||
error: null,
|
||||
deferred: true,
|
||||
capture,
|
||||
screenshot: prepared.config.captureScreenshot === true
|
||||
&& ankiCaptureRequirements(prepared.resolved.templates).includeScreenshot,
|
||||
...(extra ?? {}),
|
||||
};
|
||||
}
|
||||
const result = await decision(prepared, request, duplicateIndex);
|
||||
const applied = result.canAdd ? fieldsForDecision(prepared, result) : null;
|
||||
const capture = applied ? captureForApplication(request, applied.templates) : null;
|
||||
if (capture) await validateCapture({ request, prepared, capture });
|
||||
const extra = await preflightExtra({ request, prepared, applied, deferred: false });
|
||||
return {
|
||||
state: result.state,
|
||||
canAdd: result.canAdd,
|
||||
error: result.error,
|
||||
action: result.action,
|
||||
noteIds: result.noteIds,
|
||||
capture,
|
||||
// A mapped {screenshot} that the user has left switched on: the reader
|
||||
// takes the viewport picture itself, when it submits. The whole
|
||||
// request-specific mapping decides, not the subset this preflight would
|
||||
// apply, because the authoritative decision is made again inside the
|
||||
// write and may then apply a field this one would have kept.
|
||||
screenshot: prepared.config.captureScreenshot === true
|
||||
&& ankiCaptureRequirements(prepared.resolved.templates).includeScreenshot,
|
||||
...(extra ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function write(request) {
|
||||
const prepared = await prepare(request, true);
|
||||
const checked = await decision(prepared, request, duplicateIndex);
|
||||
if (!checked.canAdd) return { state: checked.state, error: checked.error,
|
||||
action: checked.action, noteIds: checked.noteIds };
|
||||
const { config, configJson, firstField, note, invoke } = prepared;
|
||||
const { fields, target, templates } = fieldsForDecision(prepared, checked);
|
||||
const capture = captureForApplication(request, templates);
|
||||
if (capture) await validateCapture({ request, prepared, capture });
|
||||
if (JSON.stringify(await readConfig(request?.templateId)) !== configJson) throw new Error(CONFIG_CHANGED);
|
||||
const writeResources = await beforeWrite({
|
||||
request,
|
||||
...prepared,
|
||||
target,
|
||||
appliedFields: fields,
|
||||
capture,
|
||||
});
|
||||
// Failed media can restore a field's original value after preparation.
|
||||
// Leave it untouched instead of overwriting an intervening Anki edit.
|
||||
omitUnchangedFields(fields, target?.fields);
|
||||
// A definitive no-write releases whatever only this note would have used.
|
||||
// An uncertain write keeps it: the note may exist in Anki after all.
|
||||
const releaseRejected = () => afterRejected({ request, ...prepared, writeResources })
|
||||
.catch(() => undefined);
|
||||
if (JSON.stringify(await readConfig(request?.templateId)) !== configJson) {
|
||||
await releaseRejected();
|
||||
throw new Error(CONFIG_CHANGED);
|
||||
}
|
||||
// Uploads and configuration reads can outlive Stop. Validate the remaining
|
||||
// write ownership last, with no unrelated await before sending the mutation.
|
||||
try {
|
||||
await beforeMutation({ request, capture, writeResources });
|
||||
} catch (error) {
|
||||
await releaseRejected();
|
||||
throw error;
|
||||
}
|
||||
let noteId;
|
||||
try {
|
||||
noteId = await writeAnkiNote(invoke, note, target, fields, checked.noteIds ?? []);
|
||||
} catch (error) {
|
||||
if (isAnkiDuplicateError(error.message)) {
|
||||
await releaseRejected();
|
||||
let noteIds = [];
|
||||
try {
|
||||
const expression = request.term?.expression ?? request.expression;
|
||||
noteIds = await duplicateIndex.source(config) === null
|
||||
? (await findAnkiDuplicateNotes(invoke, note, firstField, config)).map(match => match.noteId)
|
||||
: (await duplicateIndex.repair(config, expression, invoke)).noteIds;
|
||||
} catch { /* The duplicate result is definitive even if browse discovery fails. */ }
|
||||
const firstValue = String(fields[firstField] ?? note.fields[firstField] ?? "").trim();
|
||||
return { state: "duplicate", noteIds,
|
||||
error: `Anki already has a note in deck “${note.deckName}” (note type “${note.modelName}”) whose first field “${firstField}” is `
|
||||
+ `${firstValue ? `“${firstValue}”` : "empty"}.` };
|
||||
}
|
||||
if (isUndispatchedAnkiTransportError(error)) {
|
||||
// A failed endpoint generation rejected this queued mutation before it
|
||||
// entered fetch. Its note cannot exist, so release request-owned media
|
||||
// and return a definitive retryable failure instead of uncertainty.
|
||||
await releaseRejected();
|
||||
throw error;
|
||||
}
|
||||
// A lost acknowledgement may follow a completed write. Neither this
|
||||
// worker nor the reader retries it automatically, including append modes.
|
||||
return { state: "uncertain", error: `The write could not be confirmed. Check Anki before trying again. ${error.message}` };
|
||||
}
|
||||
const warnings = [...(Array.isArray(writeResources?.warnings) ? writeResources.warnings : [])];
|
||||
try {
|
||||
await duplicateIndex.recordWrite(config, request.term?.expression ?? request.expression, noteId,
|
||||
{ mature: checked.mature === true });
|
||||
} catch (error) {
|
||||
warnings.push(`Duplicate index: ${error.message}`);
|
||||
}
|
||||
let verified = false;
|
||||
try {
|
||||
await verifyAnkiFields(invoke, noteId, fields);
|
||||
verified = true;
|
||||
} catch (error) {
|
||||
warnings.push(error.message);
|
||||
}
|
||||
try {
|
||||
await afterConfirmed({
|
||||
request,
|
||||
...prepared,
|
||||
noteId,
|
||||
existingFields: target?.fields,
|
||||
appliedFields: fields,
|
||||
capture,
|
||||
writeResources,
|
||||
verified,
|
||||
});
|
||||
} catch (error) {
|
||||
warnings.push(`Captured media cleanup: ${error.message}`);
|
||||
}
|
||||
if (verified) {
|
||||
try {
|
||||
warnings.push(...await enrich({ request, ...prepared, noteId, existingFields: target?.fields, appliedFields: fields }));
|
||||
} catch (error) {
|
||||
warnings.push(error.message);
|
||||
}
|
||||
}
|
||||
cached.delete(request?.templateId ?? "");
|
||||
return { state: target ? "updated" : "added", noteId, warnings };
|
||||
}
|
||||
|
||||
function submit(request) {
|
||||
const operation = mutations.then(() => write(request));
|
||||
mutations = operation.catch(() => {});
|
||||
return operation;
|
||||
}
|
||||
|
||||
async function browse(request) {
|
||||
const value = typeof request === "string" ? { expression: request } : request;
|
||||
const config = await readConfig(value?.templateId);
|
||||
if (!config) throw new Error("The selected Anki Template is no longer available.");
|
||||
if (typeof value?.configKey === "string") {
|
||||
const configKey = await ankiDigest(new TextEncoder().encode(JSON.stringify({
|
||||
templateId: value?.templateId ?? null,
|
||||
config,
|
||||
})));
|
||||
if (value.configKey !== configKey) throw new Error(CONFIG_CHANGED);
|
||||
}
|
||||
const invoke = invokeFor(config);
|
||||
const supplied = Array.isArray(value?.noteIds) && value.noteIds.length;
|
||||
let noteIds = supplied ? [...value.noteIds] : [];
|
||||
let repaired = false;
|
||||
if (supplied && typeof value?.expression === "string" && value.expression
|
||||
&& await duplicateIndex.source(config) !== null) {
|
||||
const refreshed = await duplicateIndex.repair(config, value.expression, invoke);
|
||||
noteIds = refreshed.noteIds;
|
||||
if (!noteIds.length) return { opened: false, noteIds: [], repaired: true };
|
||||
repaired = true;
|
||||
}
|
||||
const query = noteIds.length ? ankiNoteIdsQuery(noteIds) : ankiBrowseQuery(value?.expression ?? "");
|
||||
await invoke("guiBrowse", { query }, 30_000);
|
||||
return { opened: true, noteIds, repaired };
|
||||
}
|
||||
|
||||
return { status, view, preflight, submit, browse };
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { extensionApi } from "./browser-api.js";
|
||||
import { decodeBase64 } from "./base64.js";
|
||||
import { buildAnkiResourceFields } from "./anki-resources.js";
|
||||
import { exportAnkiAudio } from "./anki-audio.js";
|
||||
import { MINING_CAPABILITIES } from "./overlay-mode.js";
|
||||
|
||||
// Resolve and parse the complete scoped note set away from the background and
|
||||
// engine request threads; only compact index rows cross back to the commit.
|
||||
async function refreshAnkiIndex(window, source) {
|
||||
const worker = new window.Worker(new URL("./anki-index-worker.js", import.meta.url), { type: "module" });
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
worker.onmessage = ({ data }) => data.error ? reject(new Error(data.error)) : resolve({ rows: data.rows });
|
||||
worker.onerror = event => reject(new Error(event.message || "Anki index refresh worker failed."));
|
||||
worker.postMessage(source);
|
||||
});
|
||||
} finally {
|
||||
worker.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
async function recordSpeechAudio(...args) {
|
||||
if (!MINING_CAPABILITIES.browserSpeech) {
|
||||
throw new Error("Browser text-to-speech recording is unavailable in Firefox.");
|
||||
}
|
||||
const capture = await import("./capture-host.js");
|
||||
return capture.recordSpeechAudio(...args);
|
||||
}
|
||||
|
||||
function clientSpeechPlan(source, term) {
|
||||
return {
|
||||
sourceId: source.id,
|
||||
sourceKey: JSON.stringify(source),
|
||||
expression: term.expression,
|
||||
reading: term.reading,
|
||||
};
|
||||
}
|
||||
|
||||
function sameClientSpeech(plan, source, term) {
|
||||
const expected = clientSpeechPlan(source, term);
|
||||
return plan && Object.entries(expected).every(([key, value]) => plan[key] === value);
|
||||
}
|
||||
|
||||
function decodeClientSpeech(window, data) {
|
||||
return decodeBase64(data, { atob: window.atob.bind(window) });
|
||||
}
|
||||
|
||||
function linkedSpeechRecorder(window, message) {
|
||||
if (message.clientSpeechProbe !== true && !message.clientSpeech) return recordSpeechAudio;
|
||||
return async (source, term, signal, { record = true } = {}) => {
|
||||
signal.throwIfAborted();
|
||||
const plan = clientSpeechPlan(source, term);
|
||||
if (!record || message.clientSpeechProbe === true) {
|
||||
return { recordingRequired: true, clientSpeech: plan };
|
||||
}
|
||||
const supplied = message.clientSpeech;
|
||||
if (!sameClientSpeech(supplied, source, term)) {
|
||||
throw new Error("The linked browser did not supply the requested browser speech.");
|
||||
}
|
||||
return {
|
||||
data: decodeClientSpeech(window, supplied.data),
|
||||
filename: supplied.filename,
|
||||
candidate: { name: "Linked browser speech", text: term.reading || term.expression, voice: "", index: 0 },
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function createAnkiOffscreenService(window, getAudioRepository, captureSpeech = recordSpeechAudio) {
|
||||
return async message => {
|
||||
if (message.type === "hd_anki_index_refresh") return refreshAnkiIndex(window, message.source);
|
||||
if (message.type === "hd_anki_audio") {
|
||||
return exportAnkiAudio(window, await getAudioRepository(), message, window.AbortSignal.timeout(30_000), {
|
||||
recordSpeechAudio: message.clientSpeechProbe === true || message.clientSpeech
|
||||
? linkedSpeechRecorder(window, message)
|
||||
: captureSpeech,
|
||||
});
|
||||
}
|
||||
if (message.type !== "hd_anki_fields") throw new Error("Unknown Anki rendering request.");
|
||||
return buildAnkiResourceFields(message.request, message.templates, {
|
||||
document: window.document, dictionaryPaths: message.dictionaryPaths, audio: message.audio,
|
||||
styles: async () => {
|
||||
const reply = await extensionApi.runtime.sendMessage({ target: "hoshidicts-offscreen", type: "hd_styles", requestId: message.requestId });
|
||||
if (!reply.ok || reply.generation !== message.request.generation) throw new Error(reply.error || "Dictionary styles changed during Anki preparation.");
|
||||
return reply.styles;
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2023-2026 Yomitan Authors
|
||||
// Copyright (C) 2021-2022 Yomichan Authors
|
||||
// Graph notation follows Yomitan's pronunciation-generator.js (GPL-3.0-or-later),
|
||||
// including its Jidoujisho-style kana graph: https://github.com/yomidevs/yomitan
|
||||
import "./render/glossary.js";
|
||||
import { escapeAnkiHtml as escape } from "./anki-templates.js";
|
||||
|
||||
function pitchContour(reading, pitch) {
|
||||
const { splitPitchAccentMorae, buildPitchAccentMorae } = globalThis.HDGlossary;
|
||||
if (pitch.pattern) {
|
||||
const morae = splitPitchAccentMorae(reading);
|
||||
// The engine stores string positions in pattern, with a placeholder numeric
|
||||
// position of zero. Never interpret an unsupported pattern as heiban.
|
||||
if (!morae.length || !/^[HL]+$/u.test(pitch.pattern)
|
||||
|| pitch.pattern.length < morae.length || pitch.pattern.length > morae.length + 1) return null;
|
||||
return { morae, levels: [...pitch.pattern.padEnd(morae.length + 1, pitch.pattern.at(-1))] };
|
||||
}
|
||||
const morae = buildPitchAccentMorae(reading, pitch.position);
|
||||
if (!morae) return null;
|
||||
return { morae: morae.map(mora => mora.text),
|
||||
levels: [...morae.map(mora => mora.level === "high" ? "H" : "L"), pitch.position === 0 ? "H" : "L"] };
|
||||
}
|
||||
|
||||
function graphLine(from, to, radius) {
|
||||
// Stop at the dots' edges so hollow downstep/particle marks stay transparent
|
||||
// on both light and dark cards, without masks or document-wide SVG IDs.
|
||||
const dx = to.x - from.x, dy = to.y - from.y;
|
||||
const scale = radius / Math.hypot(dx, dy);
|
||||
return `M${from.x + dx * scale} ${from.y + dy * scale}L${to.x - dx * scale} ${to.y - dy * scale}`;
|
||||
}
|
||||
|
||||
function pitchGraph(reading, pitch, kana) {
|
||||
const contour = pitchContour(reading, pitch);
|
||||
if (!contour) return "";
|
||||
const { morae, levels } = contour;
|
||||
const step = kana ? 35 : 50, height = kana ? 80 : 100, radius = kana ? 5 : 15;
|
||||
const highY = kana ? 10 : 25;
|
||||
const lowY = kana ? 35 : 75;
|
||||
const points = levels.map((level, index) => ({ x: step * (index + 0.5),
|
||||
y: level === "H" ? highY : lowY }));
|
||||
const width = step * points.length;
|
||||
const label = `${reading}: pitch accent ${pitch.pattern || pitch.position}`;
|
||||
const lines = points.slice(1).map((point, index) => {
|
||||
const tail = index === morae.length - 1;
|
||||
return `<path d="${graphLine(points[index], point, radius)}" fill="none" stroke="currentColor" stroke-width="${kana ? 1.5 : 5}"${tail && !kana ? ' stroke-dasharray="5 5"' : ""}/>`;
|
||||
});
|
||||
const dots = points.slice(0, -1).map(({ x, y }, index) => {
|
||||
const downstep = !kana && levels[index] === "H" && levels[index + 1] === "L";
|
||||
return `<circle class="pronunciation-graph-dot" cx="${x}" cy="${y}" r="${radius}" fill="${downstep ? "none" : "currentColor"}"${downstep ? ' stroke="currentColor" stroke-width="5"' : ""}/>`
|
||||
+ (downstep ? `<circle cx="${x}" cy="${y}" r="5" fill="currentColor"/>` : "");
|
||||
});
|
||||
const tail = points.at(-1);
|
||||
const tailAttributes = `class="pronunciation-graph-tail" data-pitch="${levels.at(-1) === "H" ? "high" : "low"}" fill="none" stroke="currentColor"`;
|
||||
// Match Yomitan's distinct particle radius so card CSS for 5-unit mora dots
|
||||
// does not fill the hollow JJ particle.
|
||||
const particle = kana
|
||||
? `<circle ${tailAttributes} cx="${tail.x}" cy="${tail.y}" r="4" stroke-width="2"/>`
|
||||
: `<path ${tailAttributes} d="M${tail.x} ${tail.y + 13}l15 -26h-30Z" stroke-width="5"/>`;
|
||||
const labels = kana ? morae.map((mora, index) =>
|
||||
`<text x="${points[index].x}" y="70" text-anchor="middle"${Array.from(mora).length > 1 ? ' textLength="30" lengthAdjust="spacingAndGlyphs"' : ""} style="font: 20px sans-serif; fill: currentColor;">${escape(mora)}</text>`).join("") : "";
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" class="pronunciation-graph" viewBox="0 0 ${width} ${height}" role="img" aria-label="${escape(label)}" focusable="false" style="display: inline-block; vertical-align: middle; width: ${width / height * (kana ? 3 : 2)}em; height: ${kana ? 3 : 2}em; max-width: 100%;">`
|
||||
+ `<title>${escape(label)}</title>${lines.join("")}${dots.join("")}${particle}${labels}</svg>`;
|
||||
}
|
||||
|
||||
export function ankiPitchGraphs(term, kana = false) {
|
||||
const reading = term.reading || term.expression;
|
||||
return term.pitches.map(group => {
|
||||
const graphs = group.pitches.map(pitch => pitchGraph(reading, pitch, kana)).filter(Boolean);
|
||||
return graphs.length ? `<b>${escape(group.dictionary)}</b>: ${graphs.join(" ")}` : "";
|
||||
}).filter(Boolean).join("<br>");
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { buildAnkiFields } from "./anki-values.js";
|
||||
import { createAnkiDefinitionRenderer } from "./anki-glossary.js";
|
||||
import { ankiDigest } from "./anki-digest.js";
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "avif", "bmp", "ico", "tiff"]);
|
||||
|
||||
export async function ankiMediaFilename(bytes, extension) {
|
||||
return `hachidori_${await ankiDigest(bytes)}.${extension}`;
|
||||
}
|
||||
|
||||
// Planning reads no dictionary bytes and uploads nothing. Stable generation
|
||||
// paths, unlike an engine's restart counter, keep first-field image identities
|
||||
// identical between preflight and the authoritative write.
|
||||
export async function buildAnkiResourceFields(request, templates, { document, dictionaryPaths, styles, audio = "" }) {
|
||||
const media = new Map();
|
||||
const source = { ...request, dictionaryMedia: [], dictionaryStyles: [] };
|
||||
let plainRenderer, richRenderer;
|
||||
function filenameFor(dictionary, path) {
|
||||
const generationPath = Object.hasOwn(dictionaryPaths, dictionary) ? dictionaryPaths[dictionary] : null;
|
||||
if (!generationPath) throw new Error(`The dictionary generation is no longer available: ${dictionary}`);
|
||||
const key = JSON.stringify([dictionary, path]);
|
||||
if (!media.has(key)) {
|
||||
const suffix = path.split(".").at(-1).toLowerCase();
|
||||
const bytes = new TextEncoder().encode(JSON.stringify([generationPath, path]));
|
||||
media.set(key, ankiMediaFilename(bytes, IMAGE_EXTENSIONS.has(suffix) ? suffix : "bin")
|
||||
.then(filename => ({ dictionary, path, filename })));
|
||||
}
|
||||
return media.get(key).then(item => item.filename);
|
||||
}
|
||||
const fields = await buildAnkiFields(request, templates, { audio, definition: async options => {
|
||||
if (options.plain) {
|
||||
plainRenderer ??= createAnkiDefinitionRenderer(document, source);
|
||||
return plainRenderer(options);
|
||||
}
|
||||
richRenderer ??= Promise.resolve(styles()).then(dictionaryStyles =>
|
||||
createAnkiDefinitionRenderer(document, { ...source, dictionaryStyles }, filenameFor));
|
||||
return (await richRenderer)(options);
|
||||
} });
|
||||
return { fields, media: await Promise.all(media.values()) };
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { ankiAvailability } from "./anki.js";
|
||||
import { ankiPresetCoreMapped, applyAnkiPreset, resolveAnkiTemplates } from "./anki-templates.js";
|
||||
|
||||
/*
|
||||
* Read-only Anki detection shared by startup and Settings: recognise an installed Senren, Lapis or
|
||||
* Kiku note type, rank note types by distinct existing notes and decks by
|
||||
* distinct notes represented in them, and propose the preset mapping for the
|
||||
* winner. Nothing here writes to Anki; every call is one of the fixed
|
||||
* read-only actions below, issued through the worker's AnkiConnect gateway.
|
||||
*/
|
||||
|
||||
export const ANKI_SETUP_FAMILIES = Object.freeze(["senren", "lapis", "kiku"]);
|
||||
const FAMILY_LABELS = { senren: "Senren", lapis: "Lapis", kiku: "Kiku" };
|
||||
// The family name must lead the model name and end at a word boundary, so
|
||||
// ordinary versioned names match ("Kiku v2", "Lapis 1.4") while an unrelated
|
||||
// or ambiguous name that merely contains the word does not ("Kikuchi", "My Kiku").
|
||||
const FAMILY_PATTERN = /^(senren|lapis|kiku)(?![\p{L}\p{N}])/iu;
|
||||
|
||||
export function ankiSetupFamily(modelName) {
|
||||
const match = FAMILY_PATTERN.exec(String(modelName).trim());
|
||||
return match === null ? null : match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function positiveId(value) {
|
||||
return Number.isSafeInteger(value) && value > 0;
|
||||
}
|
||||
|
||||
function idList(value, what) {
|
||||
if (!Array.isArray(value) || !value.every(positiveId)) throw new Error(`AnkiConnect returned invalid ${what}.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
// The preset is the mapping the user would get from Settings; a model that
|
||||
// carries a family name but not its field shape is not eligible. The preset
|
||||
// must have mapped the family's core fields — a namesake with one recognised
|
||||
// field would otherwise pass on its first field alone.
|
||||
export function ankiSetupTemplates(family, model, deck, fields, baseConfig) {
|
||||
const config = applyAnkiPreset({ ...baseConfig, model, deck }, fields, family);
|
||||
if (!ankiPresetCoreMapped(config.fieldTemplates, family)) return null;
|
||||
const resolved = resolveAnkiTemplates(config, fields);
|
||||
const errors = ankiAvailability(config, { connected: true, model, decks: [deck], models: [model], fields, errors: [] }, resolved);
|
||||
return errors.length === 0 ? config.fieldTemplates : null;
|
||||
}
|
||||
|
||||
function uniqueMaximum(entries) {
|
||||
let best = null;
|
||||
let tied = false;
|
||||
for (const entry of entries) {
|
||||
if (entry.count <= 0) continue;
|
||||
if (best === null || entry.count > best.count) {
|
||||
best = entry;
|
||||
tied = false;
|
||||
} else if (entry.count === best.count) {
|
||||
tied = true;
|
||||
}
|
||||
}
|
||||
return { best, tied };
|
||||
}
|
||||
|
||||
function attention(detail) {
|
||||
return { status: "needs-attention", detail, model: null, deck: null, fieldTemplates: null };
|
||||
}
|
||||
|
||||
function modelMap(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("AnkiConnect returned an invalid note type list.");
|
||||
return value;
|
||||
}
|
||||
|
||||
function fieldList(value) {
|
||||
if (!Array.isArray(value) || value.some((field) => typeof field !== "string" || field === "")) {
|
||||
throw new Error("AnkiConnect returned an invalid field list.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Every note type whose name leads with a supported family and whose fields the
|
||||
// preset can map, with the distinct notes each one already holds.
|
||||
async function eligibleCandidates(invoke, baseConfig, models) {
|
||||
const eligible = [];
|
||||
for (const [model, id] of Object.entries(models)) {
|
||||
const family = ankiSetupFamily(model);
|
||||
if (family === null || !positiveId(id)) continue;
|
||||
const fields = fieldList(await invoke("modelFieldNames", { modelName: model }));
|
||||
// Deck is settled later; the shape check only needs the fields.
|
||||
if (ankiSetupTemplates(family, model, "Default", fields, baseConfig) === null) continue;
|
||||
const notes = idList(await invoke("findNotes", { query: `mid:${id}` }), "note IDs");
|
||||
eligible.push({ model, id, family, fields, count: new Set(notes).size });
|
||||
}
|
||||
return eligible;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mapping the user already saved, checked the way Settings checks it: the
|
||||
* note types, the decks and that model's fields are read and the shared
|
||||
* availability rules decide. Nothing is written and nothing is proposed.
|
||||
* @param {(action: string, params: object) => Promise<unknown>} invoke fixed read-only AnkiConnect call
|
||||
* @param {object} config the saved Anki options
|
||||
*/
|
||||
export async function verifyAnkiSetup(invoke, config) {
|
||||
const models = modelMap(await invoke("modelNamesAndIds", {}));
|
||||
const decks = await invoke("deckNames", {});
|
||||
if (!Array.isArray(decks) || decks.some((deck) => typeof deck !== "string")) {
|
||||
throw new Error("AnkiConnect returned an invalid deck list.");
|
||||
}
|
||||
const fields = Object.hasOwn(models, config.model) ? fieldList(await invoke("modelFieldNames", { modelName: config.model })) : [];
|
||||
const errors = ankiAvailability(config, { connected: true, model: config.model, models: Object.keys(models), decks, fields, errors: [] });
|
||||
return errors.length === 0
|
||||
? { status: "already-configured", detail: null, model: config.model, deck: config.deck, fieldTemplates: null }
|
||||
: attention(errors[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(action: string, params: object) => Promise<unknown>} invoke fixed read-only AnkiConnect call
|
||||
* @param {object} baseConfig the current (unconfigured) Anki options
|
||||
*/
|
||||
export async function detectAnkiSetup(invoke, baseConfig) {
|
||||
const eligible = await eligibleCandidates(invoke, baseConfig, modelMap(await invoke("modelNamesAndIds", {})));
|
||||
if (eligible.length === 0) return attention("No Senren, Lapis or Kiku note type with its expected fields was found.");
|
||||
const ranked = uniqueMaximum(eligible);
|
||||
if (ranked.best === null) return attention("The supported note types have no notes yet.");
|
||||
if (ranked.tied) return attention("Two note types share the highest note count.");
|
||||
const { model, id, family, fields } = ranked.best;
|
||||
|
||||
// Filtered decks are temporary; the remaining cards are grouped by their
|
||||
// exact deck and each deck counts the distinct notes it represents.
|
||||
const cards = idList(await invoke("findCards", { query: `mid:${id} -deck:filtered` }), "card IDs");
|
||||
if (cards.length === 0) return attention(`${model} has no cards in an ordinary deck.`);
|
||||
const decks = await invoke("getDecks", { cards });
|
||||
if (!decks || typeof decks !== "object" || Array.isArray(decks)) throw new Error("AnkiConnect returned an invalid deck grouping.");
|
||||
const counted = [];
|
||||
for (const [deck, deckCards] of Object.entries(decks)) {
|
||||
const notes = idList(await invoke("cardsToNotes", { cards: idList(deckCards, "deck card IDs") }), "deck note IDs");
|
||||
counted.push({ deck, count: new Set(notes).size });
|
||||
}
|
||||
const deckRank = uniqueMaximum(counted);
|
||||
if (deckRank.best === null) return attention(`${model} has no notes in an ordinary deck.`);
|
||||
if (deckRank.tied) return attention(`Two decks share the most ${model} notes.`);
|
||||
const fieldTemplates = ankiSetupTemplates(family, model, deckRank.best.deck, fields, baseConfig);
|
||||
if (fieldTemplates === null) return attention(`${model} does not match the ${FAMILY_LABELS[family]} field layout.`);
|
||||
return { status: "configured", detail: null, model, deck: deckRank.best.deck, fieldTemplates };
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import "./reader-options.js";
|
||||
|
||||
const { ANKI_FIELDS } = globalThis.HDReaderOptions;
|
||||
const CORE_MARKERS = ["expression", "reading", "furigana", "furigana-plain", "dictionary", "dictionary-alias",
|
||||
"definition", "glossary", "glossary-brief", "glossary-no-dictionary", "glossary-plain", "glossary-plain-no-dictionary",
|
||||
"glossary-first", "glossary-first-brief", "glossary-first-no-dictionary", "main-definition", "jpmn-primary-definition",
|
||||
"conjugation", "part-of-speech", "phonetic-transcriptions", "tags", "popup-selection-text", "search-query", "document-title",
|
||||
"sentence", "sentence-furigana", "sentence-furigana-plain", "cloze-prefix", "cloze-body", "cloze-suffix",
|
||||
"frequency", "frequencies", "frequency-harmonic-rank", "frequency-harmonic-occurrence", "frequency-average-rank",
|
||||
"frequency-average-occurrence", "pitch", "pitch-position", "pitch-accent-positions", "pitch-categories",
|
||||
"pitch-accent-categories", "pitch-accent-graphs", "pitch-accent-graphs-jj",
|
||||
"audio", "capture-animation", "capture-audio", "screenshot"];
|
||||
const MARKER_ALIASES = new Map([["pitch-accent", "pitch"], ["pitch-accents", "pitch"]]);
|
||||
const MARKER_DESCRIPTIONS = {
|
||||
expression: "Dictionary form of the selected term",
|
||||
reading: "Reading of the selected term",
|
||||
furigana: "Expression with ruby furigana",
|
||||
"furigana-plain": "Expression with bracketed plain-text furigana",
|
||||
dictionary: "Title of the first definition dictionary",
|
||||
"dictionary-alias": "Display name of the first definition dictionary",
|
||||
definition: "All definitions with dictionary names",
|
||||
glossary: "All definitions with dictionary names",
|
||||
"glossary-brief": "Brief definitions from every dictionary",
|
||||
"glossary-no-dictionary": "All definitions without dictionary names",
|
||||
"glossary-plain": "Plain-text definitions with dictionary names",
|
||||
"glossary-plain-no-dictionary": "Plain-text definitions without dictionary names",
|
||||
"glossary-first": "First available definition",
|
||||
"glossary-first-brief": "Brief form of the first definition",
|
||||
"glossary-first-no-dictionary": "First definition without its dictionary name",
|
||||
"main-definition": "First available definition",
|
||||
"jpmn-primary-definition": "First available definition",
|
||||
conjugation: "Deinflection and conjugation path",
|
||||
"part-of-speech": "Readable part-of-speech names",
|
||||
"phonetic-transcriptions": "Available phonetic transcriptions",
|
||||
tags: "Definition and term tags",
|
||||
"popup-selection-text": "Text selected inside the lookup popup",
|
||||
"search-query": "Text used for the lookup",
|
||||
"document-title": "Title of the source page",
|
||||
sentence: "Source sentence with the matched text emphasized",
|
||||
"sentence-furigana": "Source sentence with furigana when available",
|
||||
"sentence-furigana-plain": "Plain-text source sentence with furigana when available",
|
||||
"cloze-prefix": "Sentence text before the match",
|
||||
"cloze-body": "Matched sentence text",
|
||||
"cloze-suffix": "Sentence text after the match",
|
||||
frequency: "All available frequency values",
|
||||
frequencies: "All available frequency values",
|
||||
"frequency-harmonic-rank": "Harmonic mean of rank-based frequencies",
|
||||
"frequency-harmonic-occurrence": "Harmonic mean of occurrence-based frequencies",
|
||||
"frequency-average-rank": "Arithmetic mean of rank-based frequencies",
|
||||
"frequency-average-occurrence": "Arithmetic mean of occurrence-based frequencies",
|
||||
pitch: "Pitch accent patterns and transcriptions",
|
||||
"pitch-position": "Pitch accent drop positions",
|
||||
"pitch-accent-positions": "Pitch accent drop positions",
|
||||
"pitch-categories": "Pitch accent categories",
|
||||
"pitch-accent-categories": "Pitch accent categories",
|
||||
"pitch-accent-graphs": "Japanese pitch accent SVG graphs",
|
||||
"pitch-accent-graphs-jj": "Japanese pitch accent SVG graphs with kana labels (Jidoujisho style)",
|
||||
audio: "Selected pronunciation audio",
|
||||
"capture-animation": "Captured animated image",
|
||||
"capture-audio": "Captured sentence audio",
|
||||
screenshot: "Screenshot of the source page",
|
||||
};
|
||||
const DYNAMIC_MARKER_OPTIONS = [
|
||||
["single-glossary-DICTIONARY", "Definitions from one dictionary; replace DICTIONARY with its marker name"],
|
||||
["single-glossary-DICTIONARY-brief", "Brief definitions from one dictionary"],
|
||||
["single-glossary-DICTIONARY-no-dictionary", "Definitions from one dictionary without its name"],
|
||||
["single-glossary-DICTIONARY-plain", "Plain-text definitions from one dictionary"],
|
||||
["single-glossary-DICTIONARY-plain-no-dictionary", "Plain-text definitions from one dictionary without its name"],
|
||||
["single-glossary-id--PACKAGE-ID", "Definitions selected by the dictionary package ID"],
|
||||
["single-frequency-DICTIONARY", "Formatted values from one frequency dictionary"],
|
||||
["single-frequency-number-DICTIONARY", "Numeric value from one frequency dictionary"],
|
||||
];
|
||||
export const ANKI_TEMPLATE_MARKERS = Object.freeze([...CORE_MARKERS, ...MARKER_ALIASES.keys()]);
|
||||
export const ANKI_TEMPLATE_MARKER_OPTIONS = Object.freeze([
|
||||
...CORE_MARKERS.map(marker => Object.freeze({
|
||||
marker,
|
||||
value: `{${marker}}`,
|
||||
description: MARKER_DESCRIPTIONS[marker],
|
||||
})),
|
||||
...[...MARKER_ALIASES].map(([marker, canonical]) => Object.freeze({
|
||||
marker,
|
||||
value: `{${marker}}`,
|
||||
description: `Legacy spelling of {${canonical}}`,
|
||||
})),
|
||||
...DYNAMIC_MARKER_OPTIONS.map(([marker, description]) => Object.freeze({
|
||||
marker,
|
||||
value: `{${marker}}`,
|
||||
description,
|
||||
})),
|
||||
]);
|
||||
const MARKERS = new Set([...CORE_MARKERS, ...MARKER_ALIASES.keys()]);
|
||||
const DYNAMIC_PREFIXES = ["single-glossary-", "single-frequency-"];
|
||||
const MARKER_PATTERN = /\{([^{}]+)\}/gu;
|
||||
const BREAK_PATTERN = /<br\s*\/?>/giu;
|
||||
const genericAliases = {
|
||||
expression: ["Expression", "Word", "Term", "Front"], reading: ["Reading", "Word Reading", "WordReading", "Kana"],
|
||||
definition: ["Definition", "Definitions", "Meaning", "Glossary"], sentence: ["Sentence", "Context", "Example Sentence"],
|
||||
frequency: ["Frequency", "Frequencies"], pitch: ["Pitch Accent", "PitchAccent", "Pitch", "Accent"],
|
||||
audio: ["WordAudio", "PronunciationAudio", "Pronunciation", "Audio"],
|
||||
captureAnimation: ["Capture Animation", "CaptureAnimation", "Sentence Animation", "SentenceAnimation"],
|
||||
captureAudio: ["Capture Audio", "CaptureAudio", "Sentence Audio", "SentenceAudio"],
|
||||
};
|
||||
// Reviewed against the complete Kiku 2.1.0, Lapis 1.7.0 and Senren 5.1.0
|
||||
// package schemas. Every known unsupported field is explicit so the upstream
|
||||
// compatibility contract can distinguish an intentional blank from drift.
|
||||
const KIKU = {
|
||||
Expression: "{expression}", ExpressionFurigana: "{furigana-plain}", ExpressionReading: "{reading}", ExpressionAudio: "{audio}",
|
||||
RelatedExpression: "", SelectionText: "{popup-selection-text}", MainDefinition: "{main-definition}", DefinitionPicture: "",
|
||||
Sentence: "{cloze-prefix}<b>{cloze-body}</b>{cloze-suffix}", SentenceFurigana: "{sentence-furigana-plain}",
|
||||
SentenceTranslation: "", SentenceAudio: "", Picture: "{screenshot}", Glossary: "{glossary}", Hint: "",
|
||||
IsWordAndSentenceCard: "", IsClickCard: "", IsSentenceCard: "", IsAudioCard: "",
|
||||
PitchPosition: "{pitch-accent-positions}", PitchCategories: "{pitch-accent-categories}", Frequency: "{frequencies}",
|
||||
FreqSort: "{frequency-harmonic-rank}", MiscInfo: "{document-title}",
|
||||
};
|
||||
const LAPIS = {
|
||||
Expression: "{expression}", ExpressionFurigana: "{furigana-plain}", ExpressionReading: "{reading}", ExpressionAudio: "{audio}",
|
||||
SelectionText: "{popup-selection-text}", MainDefinition: "{main-definition}", DefinitionPicture: "",
|
||||
Sentence: "{cloze-prefix}<b>{cloze-body}</b>{cloze-suffix}", SentenceFurigana: "", SentenceAudio: "",
|
||||
Picture: "{screenshot}", Glossary: "{glossary}", Hint: "",
|
||||
IsWordAndSentenceCard: "", IsClickCard: "", IsSentenceCard: "", IsAudioCard: "",
|
||||
PitchPosition: "{pitch-accent-positions}", PitchCategories: "{pitch-accent-categories}", Frequency: "{frequencies}",
|
||||
FreqSort: "{frequency-harmonic-rank}", MiscInfo: "{document-title}",
|
||||
};
|
||||
const KIKU_LAPIS_SLOTS = { ExpressionFurigana: "expression-furigana", ExpressionReading: "reading", ExpressionAudio: "audio",
|
||||
SelectionText: "selection-text", MainDefinition: "main-definition", SentenceFurigana: "sentence-furigana",
|
||||
PitchPosition: "pitch", PitchCategories: "pitch-categories", FreqSort: "frequency-sort", MiscInfo: "document-title" };
|
||||
const SENREN = {
|
||||
word: "{expression}", reading: "{reading}",
|
||||
sentence: '<span class="group">{cloze-prefix}<span class="highlight">{cloze-body}</span>{cloze-suffix}</span>',
|
||||
sentenceFurigana: '<span class="group">{sentence-furigana}</span>',
|
||||
sentenceTranslation: "", sentenceCard: "", audioCard: "", notes: "", hint: "",
|
||||
picture: "{screenshot}", wordAudio: "{audio}", sentenceAudio: "",
|
||||
selectionText: "{popup-selection-text}", definition: "{main-definition}", glossary: "{glossary}",
|
||||
pitchAccents: "{pitch}", pitchPositions: "{pitch-accent-positions}", pitchCategories: "{pitch-accent-categories}",
|
||||
frequencies: "{frequencies}", freqSort: "{frequency-harmonic-rank}", miscInfo: "{document-title}",
|
||||
dictionaryPreference: "",
|
||||
};
|
||||
const PRESETS = { kiku: KIKU, lapis: LAPIS, senren: SENREN };
|
||||
const fieldKey = value => value.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
|
||||
const knownMarker = value => MARKERS.has(value) || DYNAMIC_PREFIXES.some(prefix => value.startsWith(prefix) && value.length > prefix.length);
|
||||
const blankTemplate = () => ({ value: "", overwriteMode: "coalesce" });
|
||||
const semanticMarker = semantic => ({ captureAnimation: "capture-animation", captureAudio: "capture-audio" })[semantic] ?? semantic;
|
||||
const semanticLabel = semantic => ({ captureAnimation: "captured animation", captureAudio: "captured audio" })[semantic] ?? semantic;
|
||||
// Names the fields Anki reported, so a stale mapping can be corrected without
|
||||
// opening Anki. An empty list means the note type itself is still unknown.
|
||||
const availableFields = fields => fields.length === 0 ? ""
|
||||
: ` Its fields are ${fields.map(field => `“${field}”`).join(", ")}.`;
|
||||
|
||||
export const escapeAnkiHtml = value => String(value).replaceAll("&", "&").replaceAll("<", "<")
|
||||
.replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||
|
||||
export function ankiFieldNames(fields) {
|
||||
return new Map(fields.map(field => [field.toLowerCase(), field]));
|
||||
}
|
||||
|
||||
export function ankiTemplateMarkerNames(template) {
|
||||
return [...template.matchAll(MARKER_PATTERN)].map(match => {
|
||||
const name = match[1].toLowerCase();
|
||||
return MARKER_ALIASES.get(name) ?? name;
|
||||
});
|
||||
}
|
||||
|
||||
export function ankiCaptureRequirements(templates) {
|
||||
const markers = new Set(Object.values(templates).flatMap(template => ankiTemplateMarkerNames(template.value)));
|
||||
return {
|
||||
includeAnimation: markers.has("capture-animation"),
|
||||
includeAudio: markers.has("capture-audio"),
|
||||
includeScreenshot: markers.has("screenshot"),
|
||||
};
|
||||
}
|
||||
|
||||
export function ankiTemplateErrors(template) {
|
||||
return [...new Set([...template.matchAll(MARKER_PATTERN)].filter(match => !knownMarker(match[1].toLowerCase()))
|
||||
.map(match => `Unknown marker: ${match[0]}`))];
|
||||
}
|
||||
|
||||
export function isAnkiAudioOnlyTemplate(template) {
|
||||
let hasAudio = false;
|
||||
const rest = template.replace(MARKER_PATTERN, (match, name) => {
|
||||
if (name.toLowerCase() !== "audio") return match;
|
||||
hasAudio = true;
|
||||
return "";
|
||||
}).replace(BREAK_PATTERN, "");
|
||||
return hasAudio && !rest.trim();
|
||||
}
|
||||
|
||||
export function renderAnkiTemplate(template, values) {
|
||||
const errors = ankiTemplateErrors(template);
|
||||
if (errors.length) throw new Error(errors.join("\n"));
|
||||
return template.split(BREAK_PATTERN).flatMap(segment => {
|
||||
const markers = [...segment.matchAll(MARKER_PATTERN)];
|
||||
const rendered = segment.replace(MARKER_PATTERN, (_, name) => {
|
||||
const key = name.toLowerCase();
|
||||
return values[key] ?? values[MARKER_ALIASES.get(key)] ?? "";
|
||||
});
|
||||
return markers.length && !rendered.trim() ? [] : [rendered];
|
||||
}).join("<br>");
|
||||
}
|
||||
|
||||
function basicTemplates(config, fields) {
|
||||
const canonical = ankiFieldNames(fields);
|
||||
const rows = new Map(fields.map(field => [field, blankTemplate()]));
|
||||
const errors = [];
|
||||
for (const semantic of ANKI_FIELDS) {
|
||||
const name = config.fields[semantic];
|
||||
if (!name) continue;
|
||||
const field = canonical.get(name.toLowerCase());
|
||||
if (!field) {
|
||||
errors.push(`The ${semanticLabel(semantic)} mapping points at field “${name}”, which is unavailable in note type “${config.model}”.${availableFields(fields)}`);
|
||||
continue;
|
||||
}
|
||||
const row = rows.get(field);
|
||||
const marker = semantic === "pitch" && field.toLowerCase() === "pitchposition" ? "pitch-position"
|
||||
: semanticMarker(semantic);
|
||||
row.value += `${row.value ? "<br>" : ""}{${marker}}`;
|
||||
}
|
||||
return { templates: Object.fromEntries(rows), staleFields: [], errors };
|
||||
}
|
||||
|
||||
// The core a mined card needs: the expression, its reading, the sentence and a
|
||||
// definition body. A note type that only shares a family name maps fewer than
|
||||
// these, so first-run detection can tell a real setup from a namesake.
|
||||
export function ankiPresetCoreMapped(fieldTemplates, family) {
|
||||
const table = PRESETS[family];
|
||||
if (table === undefined) return false;
|
||||
const values = new Set(Object.values(fieldTemplates).map(template => template.value));
|
||||
const expression = table === SENREN ? table.word : table.Expression;
|
||||
const reading = table === SENREN ? table.reading : table.ExpressionReading;
|
||||
const definition = table === SENREN ? table.definition : table.MainDefinition;
|
||||
const glossary = table === SENREN ? table.glossary : table.Glossary;
|
||||
return values.has(expression) && values.has(reading)
|
||||
&& (values.has(definition) || values.has(glossary))
|
||||
&& values.has(table.sentence ?? table.Sentence);
|
||||
}
|
||||
|
||||
export function resolveAnkiTemplates(config, fields) {
|
||||
if (config.fieldTemplates === null) return basicTemplates(config, fields);
|
||||
const saved = config.fieldTemplates;
|
||||
const folded = new Map();
|
||||
for (const name of Object.keys(saved)) {
|
||||
if (!folded.has(name.toLowerCase())) folded.set(name.toLowerCase(), name);
|
||||
}
|
||||
const used = new Set();
|
||||
const templates = Object.fromEntries(fields.map(field => {
|
||||
const name = Object.hasOwn(saved, field) ? field : folded.get(field.toLowerCase());
|
||||
if (name === undefined) return [field, blankTemplate()];
|
||||
used.add(name);
|
||||
return [field, { ...saved[name] }];
|
||||
}));
|
||||
const staleFields = Object.keys(saved).filter(field => !used.has(field));
|
||||
const errors = staleFields.map(field =>
|
||||
`Template field “${field}” is unavailable in note type “${config.model}”.${availableFields(fields)}`);
|
||||
for (const [field, template] of Object.entries(templates)) {
|
||||
errors.push(...ankiTemplateErrors(template.value).map(error => `Field “${field}”: ${error}`));
|
||||
}
|
||||
return { templates, staleFields, errors };
|
||||
}
|
||||
|
||||
export function applyAnkiPreset(config, fields, preset) {
|
||||
const table = PRESETS[preset] ?? KIKU;
|
||||
const suggestions = new Map();
|
||||
if (preset === "automatic") {
|
||||
for (const [semantic, aliases] of Object.entries(genericAliases)) {
|
||||
for (const alias of aliases) suggestions.set(fieldKey(alias), { slot: semantic, value: `{${semanticMarker(semantic)}}` });
|
||||
}
|
||||
}
|
||||
for (const [field, value] of Object.entries(table)) {
|
||||
if (preset === "automatic" && value === "") continue;
|
||||
suggestions.set(fieldKey(field), {
|
||||
slot: table === SENREN ? fieldKey(field) : KIKU_LAPIS_SLOTS[field] ?? field.toLowerCase(), value,
|
||||
});
|
||||
}
|
||||
const used = new Set();
|
||||
const fieldTemplates = Object.fromEntries(fields.map(field => {
|
||||
const suggestion = suggestions.get(fieldKey(field));
|
||||
const template = blankTemplate();
|
||||
if (suggestion && !used.has(suggestion.slot)) {
|
||||
used.add(suggestion.slot);
|
||||
template.value = suggestion.value;
|
||||
}
|
||||
return [field, template];
|
||||
}));
|
||||
return { ...config, fieldTemplates };
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import "./render/glossary.js";
|
||||
import { ankiPitchGraphs } from "./anki-pitch.js";
|
||||
import { ankiTemplateMarkerNames, renderAnkiTemplate, escapeAnkiHtml as escape } from "./anki-templates.js";
|
||||
|
||||
// Browser-native port of GSM PR #549's hoshidicts_mining.py marker values.
|
||||
// DOM glossary rendering and resource preparation remain separate; only values
|
||||
// actually used by the selected templates are built here.
|
||||
const uniqueTokens = values => [...new Set(values.flatMap(value => value.split(/[\s,]+/u).filter(Boolean)))];
|
||||
// Keep this sanitizer unchanged: frequency and existing title-based glossary
|
||||
// marker mappings depend on its exact output.
|
||||
const dictionaryMarker = name => name.replace(/[_\s]/gu, "-").replace(/[^\p{L}\p{N}-]/gu, "")
|
||||
.replace(/-+/gu, "-").replace(/^-|-$/gu, "").toLowerCase();
|
||||
const normalisedDictionaryMarker = name => typeof name === "string"
|
||||
? dictionaryMarker(name.normalize("NFKC")) : "";
|
||||
const dictionaryIdMarker = id => /^[0-9a-f]{32}$/u.test(id) ? `id--${id}` : "";
|
||||
const GLOSSARY_MARKER_PREFIX = "single-glossary-";
|
||||
const GLOSSARY_IDENTITY_PHASES = ["legacy", "alias", "id"];
|
||||
const GLOSSARY_VARIANTS = [
|
||||
{ ending: "-brief", options: { brief: true } },
|
||||
{ ending: "-no-dictionary", options: { noDictionary: true } },
|
||||
{ ending: "-plain", options: { plain: true } },
|
||||
{ ending: "-plain-no-dictionary", options: { plain: true, noDictionary: true } },
|
||||
];
|
||||
const PARTS_OF_SPEECH = { v1: "Ichidan verb", v5: "Godan verb", vk: "Kuru verb", vs: "Suru verb",
|
||||
vz: "Zuru verb", "adj-i": "I-adjective", n: "Noun" };
|
||||
const VALUE_ALIASES = { definition: "glossary", "main-definition": "glossary-first", "jpmn-primary-definition": "glossary-first",
|
||||
frequency: "frequencies", "pitch-accent-positions": "pitch-position", "pitch-categories": "pitch-accent-categories" };
|
||||
const alias = (request, dictionary) => Object.hasOwn(request.dictionaryAliases, dictionary)
|
||||
? request.dictionaryAliases[dictionary] : dictionary;
|
||||
|
||||
function expressionFurigana(term, plain) {
|
||||
return globalThis.HDGlossary.segmentFurigana(term.expression, term.reading).map(({ text, reading }, index) => {
|
||||
if (!reading) return escape(text);
|
||||
const prefix = index ? " " : "";
|
||||
return plain ? `${prefix}${escape(text)}[${escape(reading)}]`
|
||||
: `<ruby>${escape(text)}<rt>${escape(reading)}</rt></ruby>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function frequencyNumber(frequency) {
|
||||
const prefix = typeof frequency.displayValue === "string" ? /^\d+/u.exec(frequency.displayValue) : null;
|
||||
const display = prefix ? Number(prefix[0]) : 0;
|
||||
return display > 0 ? display : frequency.value;
|
||||
}
|
||||
|
||||
function frequencyAggregate(term, mode, harmonic) {
|
||||
const values = [];
|
||||
for (const group of term.frequencies) {
|
||||
if (group.frequencyMode && group.frequencyMode !== mode) continue;
|
||||
for (const frequency of group.frequencies) {
|
||||
const value = frequencyNumber(frequency);
|
||||
if (value > 0) { values.push(value); break; }
|
||||
}
|
||||
}
|
||||
if (!values.length) return mode === "rank-based" ? "9999999" : "0";
|
||||
const mean = harmonic ? values.length / values.reduce((sum, value) => sum + 1 / value, 0)
|
||||
: values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
return String(Math.floor(mean));
|
||||
}
|
||||
|
||||
function frequencyHtml(term) {
|
||||
return term.frequencies.filter(group => group.frequencies.length).map(group =>
|
||||
`<b>${escape(group.dictionary)}</b>: ${group.frequencies.map(value => escape(value.displayValue ?? value.value)).join(", ")}`
|
||||
).join("<br>");
|
||||
}
|
||||
|
||||
function singleFrequency(request, dictionary, numeric) {
|
||||
const groups = request.term.frequencies.filter(group => group.dictionary === dictionary && group.frequencies.length);
|
||||
if (numeric) {
|
||||
const value = groups.length ? frequencyNumber(groups[0].frequencies[0]) : 0;
|
||||
return value > 0 ? String(value) : "";
|
||||
}
|
||||
const items = groups.flatMap(group => group.frequencies.map(value =>
|
||||
`<li>${escape(alias(request, dictionary))}: ${escape(value.displayValue ?? value.value)}</li>`));
|
||||
return items.length ? `<ul style="text-align: left;">${items.join("")}</ul>` : "";
|
||||
}
|
||||
|
||||
function pitchHtml(term) {
|
||||
return term.pitches.map(group => {
|
||||
const descriptions = group.pitches.map(pitch => {
|
||||
const details = [];
|
||||
if (pitch.nasal.length) details.push(`nasal ${pitch.nasal.join(",")}`);
|
||||
if (pitch.devoice.length) details.push(`devoice ${pitch.devoice.join(",")}`);
|
||||
return (pitch.pattern || `position ${pitch.position}`) + (details.length ? ` (${details.join("; ")})` : "");
|
||||
});
|
||||
descriptions.push(...group.transcriptions);
|
||||
return descriptions.length ? `<b>${escape(group.dictionary)}</b>: ${descriptions.map(escape).join(", ")}` : "";
|
||||
}).filter(Boolean).join("<br>");
|
||||
}
|
||||
|
||||
function pitchCategories(term) {
|
||||
const classes = new Set(uniqueTokens([term.rules]));
|
||||
const inflected = ["v1", "v5", "vk", "vs", "vz", "adj-i"].some(rule => classes.has(rule))
|
||||
&& !(classes.has("vs") && classes.has("n"));
|
||||
const morae = globalThis.HDGlossary.splitPitchAccentMorae(term.reading || term.expression).length;
|
||||
const categories = term.pitches.flatMap(group => group.pitches.map(pitch => {
|
||||
if (pitch.position === 0) return "heiban";
|
||||
if (pitch.position < 0) return null;
|
||||
if (inflected) return "kifuku";
|
||||
if (pitch.position === 1) return "atamadaka";
|
||||
return pitch.position >= morae ? "odaka" : "nakadaka";
|
||||
}));
|
||||
return [...new Set(categories.filter(Boolean))].join(",");
|
||||
}
|
||||
|
||||
function requestedGlossaryMarkers(requestedNames) {
|
||||
const exactRequests = new Map();
|
||||
const variantRequests = new Map();
|
||||
const candidateBases = new Set();
|
||||
for (const name of requestedNames) {
|
||||
const key = name.slice(GLOSSARY_MARKER_PREFIX.length);
|
||||
exactRequests.set(key, name);
|
||||
candidateBases.add(key);
|
||||
for (const { ending, options } of GLOSSARY_VARIANTS) {
|
||||
if (!key.endsWith(ending)) continue;
|
||||
const base = key.slice(0, -ending.length);
|
||||
candidateBases.add(base);
|
||||
if (!variantRequests.has(base)) variantRequests.set(base, []);
|
||||
variantRequests.get(base).push({ name, options });
|
||||
}
|
||||
}
|
||||
return { exactRequests, variantRequests, candidateBases };
|
||||
}
|
||||
|
||||
function glossaryDescriptors(request, candidateBases) {
|
||||
const dictionaries = [...new Set(request.term.glossaries.map(glossary => glossary.dictionary))];
|
||||
const aliases = request.dictionaryAliases ?? {};
|
||||
const wantsId = [...candidateBases].some(key => /^id--[0-9a-f]{32}$/u.test(key));
|
||||
return dictionaries.map(dictionary => ({
|
||||
dictionary,
|
||||
legacy: dictionaryMarker(dictionary),
|
||||
alias: Object.hasOwn(aliases, dictionary)
|
||||
? normalisedDictionaryMarker(aliases[dictionary]) : "",
|
||||
id: wantsId ? dictionaryIdMarker(request.dictionaryIds?.[dictionary]) : "",
|
||||
}));
|
||||
}
|
||||
|
||||
function claimGlossaryOwner(owners, candidateBases, key, dictionary) {
|
||||
if (!key || !candidateBases.has(key)) return;
|
||||
if (!owners.has(key)) owners.set(key, dictionary);
|
||||
else if (owners.get(key) !== dictionary) owners.set(key, null);
|
||||
}
|
||||
|
||||
function glossaryIdentityOwners(descriptors, candidateBases) {
|
||||
const owners = new Map();
|
||||
for (const descriptor of descriptors) {
|
||||
claimGlossaryOwner(owners, candidateBases, descriptor.legacy, descriptor.dictionary);
|
||||
if (descriptor.alias !== descriptor.legacy) {
|
||||
claimGlossaryOwner(owners, candidateBases, descriptor.alias, descriptor.dictionary);
|
||||
}
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
|
||||
function glossaryIdentity(descriptor, phase, owners) {
|
||||
const key = descriptor[phase];
|
||||
return phase !== "alias" || owners.get(key) === descriptor.dictionary ? key : "";
|
||||
}
|
||||
|
||||
function resolveGlossaryPhase(resolved, descriptors, phase, owners, exactRequests, variantRequests) {
|
||||
for (const descriptor of descriptors) {
|
||||
const key = glossaryIdentity(descriptor, phase, owners);
|
||||
const name = key ? exactRequests.get(key) : null;
|
||||
if (name && !resolved.has(name)) resolved.set(name, { dictionary: descriptor.dictionary });
|
||||
}
|
||||
// Within each namespace, all exact bases retain precedence over suffix
|
||||
// variants and dictionary order matches the former eager map.
|
||||
for (const descriptor of descriptors) {
|
||||
const key = glossaryIdentity(descriptor, phase, owners);
|
||||
for (const match of key ? variantRequests.get(key) ?? [] : []) {
|
||||
if (!resolved.has(match.name)) {
|
||||
resolved.set(match.name, { dictionary: descriptor.dictionary, ...match.options });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function dynamicGlossaries(request, requestedNames) {
|
||||
const { exactRequests, variantRequests, candidateBases } = requestedGlossaryMarkers(requestedNames);
|
||||
const descriptors = glossaryDescriptors(request, candidateBases);
|
||||
const owners = glossaryIdentityOwners(descriptors, candidateBases);
|
||||
const resolved = new Map();
|
||||
// Complete the legacy namespace before considering new identities so aliases
|
||||
// and IDs cannot steal an existing title-derived suffix marker.
|
||||
for (const phase of GLOSSARY_IDENTITY_PHASES) {
|
||||
resolveGlossaryPhase(resolved, descriptors, phase, owners, exactRequests, variantRequests);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function dynamicFrequencies(request) {
|
||||
const variants = new Map();
|
||||
for (const dictionary of request.frequencyDictionaries) {
|
||||
const key = dictionaryMarker(dictionary);
|
||||
if (!key) continue;
|
||||
variants.set(`single-frequency-number-${key}`, { dictionary, numeric: true });
|
||||
variants.set(`single-frequency-${key}`, { dictionary, numeric: false });
|
||||
}
|
||||
return variants;
|
||||
}
|
||||
|
||||
export async function buildAnkiFields(request, templates, { definition, audio = "" }) {
|
||||
const { term } = request;
|
||||
let sentenceParts;
|
||||
const parts = () => sentenceParts ??= [request.sentence.slice(0, request.matchOffset),
|
||||
request.sentence.slice(request.matchOffset, request.matchOffset + request.matched.length),
|
||||
request.sentence.slice(request.matchOffset + request.matched.length)].map(escape);
|
||||
const sentence = () => { const [prefix, body, suffix] = parts(); return `${prefix}<b>${body}</b>${suffix}`; };
|
||||
const firstDictionary = () => term.glossaries[0]?.dictionary || "";
|
||||
const table = {
|
||||
expression: () => escape(term.expression), reading: () => escape(term.reading),
|
||||
furigana: () => expressionFurigana(term, false), "furigana-plain": () => expressionFurigana(term, true),
|
||||
dictionary: () => escape(firstDictionary()), "dictionary-alias": () => escape(alias(request, firstDictionary())),
|
||||
glossary: () => definition({}), "glossary-brief": () => definition({ brief: true }),
|
||||
"glossary-no-dictionary": () => definition({ noDictionary: true }), "glossary-plain": () => definition({ plain: true }),
|
||||
"glossary-plain-no-dictionary": () => definition({ plain: true, noDictionary: true }),
|
||||
"glossary-first": () => definition({ firstOnly: true }),
|
||||
"glossary-first-brief": () => definition({ firstOnly: true, brief: true }),
|
||||
"glossary-first-no-dictionary": () => definition({ firstOnly: true, noDictionary: true }),
|
||||
conjugation: () => request.trace.map(step => escape(step.name)).join(" « ") || escape(term.rules),
|
||||
"part-of-speech": () => uniqueTokens([term.rules, ...term.glossaries.map(glossary => glossary.termTags)])
|
||||
.map(tag => escape(Object.hasOwn(PARTS_OF_SPEECH, tag) ? PARTS_OF_SPEECH[tag] : tag)).join(", ") || "Unknown",
|
||||
tags: () => uniqueTokens(term.glossaries.flatMap(glossary => [glossary.definitionTags, glossary.termTags]))
|
||||
.map(tag => `<span class="tag" data-details="${escape(tag)}">${escape(tag)}</span>`).join(", "),
|
||||
"phonetic-transcriptions": () => {
|
||||
const items = term.pitches.flatMap(group => group.transcriptions).filter(Boolean).map(value =>
|
||||
`<li class="pronunciation" data-pronunciation-type="phonetic-transcription">${escape(value)}</li>`);
|
||||
return items.length ? `<ul>${items.join("")}</ul>` : "";
|
||||
},
|
||||
"popup-selection-text": () => escape(request.popupSelectionText), "search-query": () => escape(request.searchQuery),
|
||||
"document-title": () => escape(request.documentTitle), sentence,
|
||||
// GSM falls back to highlighted text when its optional native tokenizer is
|
||||
// unavailable. There is no MeCab/native-helper dependency in the extension.
|
||||
"sentence-furigana": sentence, "sentence-furigana-plain": sentence,
|
||||
"cloze-prefix": () => parts()[0], "cloze-body": () => parts()[1], "cloze-suffix": () => parts()[2],
|
||||
frequencies: () => frequencyHtml(term),
|
||||
"frequency-harmonic-rank": () => frequencyAggregate(term, "rank-based", true),
|
||||
"frequency-harmonic-occurrence": () => frequencyAggregate(term, "occurrence-based", true),
|
||||
"frequency-average-rank": () => frequencyAggregate(term, "rank-based", false),
|
||||
"frequency-average-occurrence": () => frequencyAggregate(term, "occurrence-based", false),
|
||||
pitch: () => pitchHtml(term), "pitch-position": () => [...new Set(term.pitches.flatMap(group => group.pitches.map(value => value.position)))].join(", "),
|
||||
"pitch-accent-graphs": () => ankiPitchGraphs(term),
|
||||
"pitch-accent-graphs-jj": () => ankiPitchGraphs(term, true),
|
||||
"pitch-accent-categories": () => pitchCategories(term), audio: () => audio,
|
||||
"capture-animation": () => request.capturePin?.animationFilename
|
||||
&& !request.captureUnavailable?.includes("animation")
|
||||
? `<img src="${escape(request.capturePin.animationFilename)}">` : "",
|
||||
"capture-audio": () => request.capturePin?.audioFilename
|
||||
&& !request.captureUnavailable?.includes("audio")
|
||||
? `[sound:${request.capturePin.audioFilename}]` : "",
|
||||
// The viewport screenshot this mining request was made from. A capture or
|
||||
// upload that failed marks itself unavailable, and the field stays empty
|
||||
// rather than referring to a picture Anki does not have.
|
||||
screenshot: () => request.screenshot?.filename && !request.captureUnavailable?.includes("screenshot")
|
||||
? `<img src="${escape(request.screenshot.filename)}">` : "",
|
||||
};
|
||||
const values = new Map();
|
||||
let glossaries, frequencies;
|
||||
const glossaryMarkerNames = new Set();
|
||||
const planned = Object.entries(templates).map(([field, template]) => {
|
||||
const names = new Set(ankiTemplateMarkerNames(template.value));
|
||||
for (const name of names) {
|
||||
if (name.startsWith(GLOSSARY_MARKER_PREFIX)) glossaryMarkerNames.add(name);
|
||||
}
|
||||
return { field, template, names, markers: {} };
|
||||
});
|
||||
function valueFor(name) {
|
||||
if (Object.hasOwn(VALUE_ALIASES, name)) return valueFor(VALUE_ALIASES[name]);
|
||||
if (values.has(name)) return values.get(name);
|
||||
let value = "";
|
||||
if (Object.hasOwn(table, name)) value = table[name]();
|
||||
else if (name.startsWith(GLOSSARY_MARKER_PREFIX)) {
|
||||
glossaries ??= dynamicGlossaries(request, glossaryMarkerNames);
|
||||
if (glossaries.has(name)) value = definition(glossaries.get(name));
|
||||
} else if (name.startsWith("single-frequency-")) {
|
||||
frequencies ??= dynamicFrequencies(request);
|
||||
const variant = frequencies.get(name);
|
||||
if (variant) value = singleFrequency(request, variant.dictionary, variant.numeric);
|
||||
}
|
||||
values.set(name, value);
|
||||
return value;
|
||||
}
|
||||
const pending = [];
|
||||
for (const { names, markers } of planned) {
|
||||
for (const name of names) {
|
||||
const value = valueFor(name);
|
||||
if (value && typeof value.then === "function") pending.push(value.then(resolved => { markers[name] = resolved; }));
|
||||
else markers[name] = value;
|
||||
}
|
||||
}
|
||||
// Only glossary/resource values need asynchronous settlement. Ordinary text
|
||||
// and frequency templates should not create a promise per field and marker.
|
||||
if (pending.length) await Promise.all(pending);
|
||||
return Object.fromEntries(planned.map(({ field, template, markers }) => [field, renderAnkiTemplate(template.value, markers)]));
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { createAnkiMiningService } from "./anki-mining.js";
|
||||
import { enrichAnkiNote } from "./anki-enrichment.js";
|
||||
import { createAnkiMediaStore } from "./anki-media.js";
|
||||
import { ankiTemplateMarkerNames } from "./anki-templates.js";
|
||||
import {
|
||||
CAPTURE_FILENAMES, CAPTURE_LIMITS, MAX_LINKED_SCREENSHOT_BYTES, decodedBase64Length,
|
||||
validateLinkedAnkiClientMedia,
|
||||
} from "./anki-client-media.js";
|
||||
|
||||
function assertCapturePin(pin) {
|
||||
if (!pin || typeof pin !== "object"
|
||||
|| typeof pin.token !== "string" || !pin.token || pin.token.length > 256
|
||||
|| typeof pin.captureSessionId !== "string" || !pin.captureSessionId || pin.captureSessionId.length > 256
|
||||
|| !["texthooker", "cue", "dom", "recent"].includes(pin.sourceKind)
|
||||
|| typeof pin.sourceLabel !== "string" || !pin.sourceLabel || pin.sourceLabel.length > 100
|
||||
|| typeof pin.partial !== "boolean"
|
||||
|| !Number.isFinite(pin.readyAtMs)
|
||||
|| !CAPTURE_FILENAMES.animation.test(pin.animationFilename)
|
||||
|| !CAPTURE_FILENAMES.audio.test(pin.audioFilename)) {
|
||||
throw new Error("The captured-media pin is invalid or expired. Look up the text again.");
|
||||
}
|
||||
}
|
||||
|
||||
// A note that was definitively not written leaves no picture of its own behind.
|
||||
async function releaseScreenshot({ writeResources, invoke }) {
|
||||
const filename = writeResources?.screenshotFilename;
|
||||
if (typeof filename !== "string" || filename === "") return;
|
||||
await invoke("deleteMediaFile", { filename }, 10_000);
|
||||
}
|
||||
|
||||
function validateCapture({ request, prepared, capture: selected }) {
|
||||
const media = prepared.config.mediaCapture;
|
||||
if (!media?.enabled) throw new Error("Enable media capture in Settings before using captured-media markers.");
|
||||
if (selected.requirements.includeAnimation && !media.includeAnimation) {
|
||||
throw new Error("This note maps captured animation, but animation capture is disabled.");
|
||||
}
|
||||
if (selected.requirements.includeAudio && !media.includeCapturedAudio) {
|
||||
throw new Error("This note maps captured audio, but captured-audio output is disabled.");
|
||||
}
|
||||
assertCapturePin(request.capturePin);
|
||||
}
|
||||
|
||||
export function createAnkiWorkerService({
|
||||
gateway,
|
||||
readOptions,
|
||||
readDictionaries,
|
||||
engine,
|
||||
offscreen,
|
||||
capture = null,
|
||||
duplicateIndex,
|
||||
}) {
|
||||
const confirmedCaptureUploads = new Map();
|
||||
const linkedClientMedia = new WeakMap();
|
||||
const linkedClientPreflights = new WeakSet();
|
||||
const ankiMediaStore = createAnkiMediaStore();
|
||||
|
||||
function isLinkedSubmission(request) {
|
||||
return linkedClientMedia.has(request);
|
||||
}
|
||||
|
||||
async function currentGeneration(request) {
|
||||
const status = await engine({ type: "hd_status" });
|
||||
if (!status.ready || status.loading || status.generation !== request.generation) {
|
||||
throw new Error("The dictionary generation changed or is being updated. Look up this result again before adding it.");
|
||||
}
|
||||
}
|
||||
|
||||
async function dictionaryMedia(item, generation) {
|
||||
const reply = await engine({ type: "hd_media", dictionary: item.dictionary, path: item.path, generation });
|
||||
const match = typeof reply.dataUrl === "string"
|
||||
? /^data:([^;,]+);base64,([A-Za-z0-9+/]*={0,2})$/u.exec(reply.dataUrl)
|
||||
: null;
|
||||
if (!match) throw new Error("The dictionary image is no longer available or is malformed.");
|
||||
return match[2];
|
||||
}
|
||||
const audio = (request, config, { recordSpeech = true } = {}) => {
|
||||
const clientSpeech = linkedClientMedia.get(request)?.speech;
|
||||
return offscreen({
|
||||
type: "hd_anki_audio",
|
||||
term: request.term,
|
||||
selection: request.audioSelection,
|
||||
sources: config.audioSources,
|
||||
recordSpeech,
|
||||
...(linkedClientPreflights.has(request) ? { clientSpeechProbe: true } : {}),
|
||||
...(clientSpeech ? { clientSpeech } : {}),
|
||||
});
|
||||
};
|
||||
const render = (request, templates, audio, resources) => offscreen({ type: "hd_anki_fields", request, templates, audio,
|
||||
dictionaryPaths: resources.dictionaryPaths });
|
||||
|
||||
async function captureRequest(type, fields) {
|
||||
if (typeof capture !== "function") throw new Error("The captured-media host is unavailable.");
|
||||
const reply = await capture({ type, ...fields });
|
||||
if (!reply || reply.ok === false) throw new Error(reply?.error || "The captured-media host did not reply.");
|
||||
return reply;
|
||||
}
|
||||
|
||||
async function uploadCaptureAsset(kind, expectedFilename, metadata, { request, invoke, configKey }) {
|
||||
if (!metadata || metadata.filename !== expectedFilename
|
||||
|| !Number.isSafeInteger(metadata.byteLength) || metadata.byteLength < 1
|
||||
|| metadata.byteLength > CAPTURE_LIMITS[kind]) {
|
||||
throw new Error(`The encoded captured ${kind} is invalid or exceeds its size limit.`);
|
||||
}
|
||||
// An upload confirmed by one Anki endpoint says nothing about another.
|
||||
const uploadKey = `${request.captureJobId}:${configKey}:${kind}`;
|
||||
if (confirmedCaptureUploads.get(uploadKey) === expectedFilename) return;
|
||||
const asset = isLinkedSubmission(request)
|
||||
? metadata
|
||||
: await captureRequest("hd_capture_asset", { jobId: request.captureJobId, kind });
|
||||
const byteLength = decodedBase64Length(asset.data);
|
||||
if (asset.filename !== expectedFilename || byteLength !== metadata.byteLength
|
||||
|| byteLength > CAPTURE_LIMITS[kind]) {
|
||||
throw new Error(`The captured ${kind} payload changed during preparation.`);
|
||||
}
|
||||
await currentGeneration(request);
|
||||
const stored = await invoke("storeMediaFile", {
|
||||
filename: expectedFilename,
|
||||
data: asset.data,
|
||||
deleteExisting: false,
|
||||
}, 30_000);
|
||||
if (stored !== expectedFilename) {
|
||||
throw new Error(`Anki stored the captured ${kind} under a different filename.`);
|
||||
}
|
||||
confirmedCaptureUploads.set(uploadKey, expectedFilename);
|
||||
}
|
||||
|
||||
// One pending viewport picture at a time: a later capture supersedes an
|
||||
// earlier one, and a note that is written consumes it. Nothing is uploaded
|
||||
// until then, so a rejected note leaves no unreferenced media in Anki.
|
||||
let pendingScreenshot = null;
|
||||
let screenshotRequestToken = null;
|
||||
|
||||
// Stored inside the queued write, once the generation, configuration and
|
||||
// duplicate decisions have been made. A refused upload is a warning, and the
|
||||
// fields that referenced the picture are emptied so the note never points at
|
||||
// an image Anki does not have.
|
||||
async function storePendingScreenshot({ request, appliedFields, invoke }) {
|
||||
const filename = request.screenshot?.filename;
|
||||
if (typeof filename !== "string" || filename === "") return { warnings: [] };
|
||||
const reference = `<img src="${filename}">`;
|
||||
const fields = Object.keys(appliedFields).filter(field => appliedFields[field].includes(reference));
|
||||
if (fields.length === 0) {
|
||||
// The fields this note actually applies keep their existing picture, so the
|
||||
// one that was captured for it is released rather than left held.
|
||||
if (!isLinkedSubmission(request) && pendingScreenshot?.token === request.screenshot.token) pendingScreenshot = null;
|
||||
return { warnings: [] };
|
||||
}
|
||||
const withoutPicture = reason => {
|
||||
// Pronunciation enrichment renders this request again after the note is
|
||||
// saved; keep that render from restoring a picture that was not stored.
|
||||
request.captureUnavailable = [...(request.captureUnavailable ?? []), "screenshot"];
|
||||
for (const field of fields) appliedFields[field] = appliedFields[field].replaceAll(reference, "");
|
||||
return { warnings: [`Screenshot: ${reason}`] };
|
||||
};
|
||||
const pending = isLinkedSubmission(request)
|
||||
? linkedClientMedia.get(request)?.screenshot
|
||||
: pendingScreenshot;
|
||||
// Only this note's own picture is consumed: another Add's newer capture is
|
||||
// left where it is rather than taken away from it.
|
||||
if (!pending || pending.token !== request.screenshot.token || pending.filename !== filename) {
|
||||
return withoutPicture("the captured picture was replaced before this note was saved.");
|
||||
}
|
||||
if (!isLinkedSubmission(request)) pendingScreenshot = null;
|
||||
try {
|
||||
const stored = await invoke("storeMediaFile", { filename, data: pending.data, deleteExisting: false }, 30_000);
|
||||
if (stored !== filename) throw new Error("Anki stored it under a different filename.");
|
||||
} catch (error) {
|
||||
// The store may have happened even though its answer did not arrive, and
|
||||
// the note is about to be written without the picture: take it back out.
|
||||
await releaseScreenshot({ writeResources: { screenshotFilename: filename }, invoke }).catch(() => undefined);
|
||||
return withoutPicture(error.message);
|
||||
}
|
||||
return { warnings: [], screenshotFilename: filename };
|
||||
}
|
||||
|
||||
async function prepareCapture(context) {
|
||||
const screenshot = await storePendingScreenshot(context);
|
||||
let clip;
|
||||
try {
|
||||
clip = await prepareClipCapture(context);
|
||||
} catch (error) {
|
||||
// No note will be written, and this rejection never reaches the caller's
|
||||
// own cleanup, so the picture is taken back out here.
|
||||
await releaseScreenshot({ writeResources: screenshot, invoke: context.invoke }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
if (clip === null) {
|
||||
return screenshot.warnings.length === 0 && screenshot.screenshotFilename === undefined ? null : screenshot;
|
||||
}
|
||||
return { ...clip, ...screenshot, warnings: [...clip.warnings, ...screenshot.warnings] };
|
||||
}
|
||||
|
||||
async function prepareWrite(context) {
|
||||
const captureResources = await prepareCapture(context);
|
||||
try {
|
||||
await ankiMediaStore.prepare({
|
||||
...context,
|
||||
media: dictionaryMedia,
|
||||
validate: () => currentGeneration(context.request),
|
||||
});
|
||||
} catch (error) {
|
||||
await releaseScreenshot({ writeResources: captureResources, invoke: context.invoke }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
return captureResources;
|
||||
}
|
||||
|
||||
async function prepareClipCapture(context) {
|
||||
const { appliedFields, capture: selected, request } = context;
|
||||
await currentGeneration(request);
|
||||
if (!selected) return null;
|
||||
assertCapturePin(request.capturePin);
|
||||
if (typeof request.captureJobId !== "string" || !request.captureJobId || request.captureJobId.length > 256) {
|
||||
throw new Error("Encode the pinned clip before submitting this note.");
|
||||
}
|
||||
const supplied = linkedClientMedia.get(request)?.capture;
|
||||
const status = isLinkedSubmission(request)
|
||||
? { state: "ready", warnings: supplied?.warnings, assets: supplied?.assets }
|
||||
: await captureRequest("hd_capture_job_status", { jobId: request.captureJobId });
|
||||
if (status.state === "finishing") throw new Error("The selected clip is still finishing.");
|
||||
if (status.state === "encoding") throw new Error("The selected clip is still encoding.");
|
||||
if (status.state !== "ready") throw new Error(status.error || "The selected clip could not be encoded.");
|
||||
|
||||
const kinds = [
|
||||
["animation", "includeAnimation", request.capturePin.animationFilename],
|
||||
["audio", "includeAudio", request.capturePin.audioFilename],
|
||||
];
|
||||
for (const [kind, requirement, expectedFilename] of kinds) {
|
||||
if (!selected.requirements[requirement]) continue;
|
||||
if (!Object.values(appliedFields).some(value => value.includes(expectedFilename))) {
|
||||
throw new Error(`The applied note fields do not reference the captured ${kind}.`);
|
||||
}
|
||||
await uploadCaptureAsset(kind, expectedFilename, status.assets?.[kind], context);
|
||||
}
|
||||
return {
|
||||
captureJobId: request.captureJobId,
|
||||
linkedClient: isLinkedSubmission(request),
|
||||
warnings: Array.isArray(status.warnings)
|
||||
? status.warnings.filter(value => typeof value === "string").map(value => value.slice(0, 500)) : [],
|
||||
};
|
||||
}
|
||||
|
||||
async function completeCapture({ writeResources }) {
|
||||
const jobId = writeResources?.captureJobId;
|
||||
if (!jobId) return;
|
||||
if (!writeResources.linkedClient) await captureRequest("hd_capture_complete", { jobId });
|
||||
for (const key of confirmedCaptureUploads.keys()) {
|
||||
if (key.startsWith(`${jobId}:`)) confirmedCaptureUploads.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function beforeMutation({ request, writeResources }) {
|
||||
await currentGeneration(request);
|
||||
if (!writeResources?.captureJobId || writeResources.linkedClient) return;
|
||||
const status = await captureRequest("hd_capture_job_status", { jobId: writeResources.captureJobId });
|
||||
if (status.state !== "ready") throw new Error(status.error || "The captured-media export was cancelled or expired.");
|
||||
}
|
||||
|
||||
const mining = createAnkiMiningService({ gateway,
|
||||
readConfig: async templateId => {
|
||||
const options = await readOptions();
|
||||
const template = globalThis.HDReaderOptions.ankiTemplateConfig(options.anki, templateId);
|
||||
if (template === null) return null;
|
||||
return {
|
||||
...template,
|
||||
audioSources: options.audioSources.filter(source => source.enabled),
|
||||
mediaCapture: options.mediaCapture,
|
||||
prepareAudioBeforeWrite: await gateway.isSubminerEndpoint?.(template.url) === true,
|
||||
};
|
||||
},
|
||||
buildFields: async (request, current, { preflight = false } = {}) => {
|
||||
if (!Number.isSafeInteger(request?.generation) || request.generation < 0
|
||||
|| typeof request.term?.expression !== "string" || !request.term.expression
|
||||
|| typeof request.term.reading !== "string") throw new Error("Mining requires a current dictionary result.");
|
||||
await currentGeneration(request);
|
||||
const dictionaries = await readDictionaries();
|
||||
const resources = { dictionaryPaths: Object.fromEntries(dictionaries.filter(item => item.enabled !== false)
|
||||
.map(item => [item.title, item.path])), audioPrepared: false, audio: null, deferDuplicateCheck: false };
|
||||
const first = current.resolved.templates[current.discovery.fields[0]];
|
||||
const firstFieldAudio = ankiTemplateMarkerNames(first.value).includes("audio");
|
||||
// SubMiner measures pronunciation as soon as the note is written. Include
|
||||
// it in that write instead of racing its media enrichment with ours.
|
||||
const prepareAudio = firstFieldAudio || (!preflight && current.config.prepareAudioBeforeWrite
|
||||
&& Object.values(current.resolved.templates).some(template => ankiTemplateMarkerNames(template.value).includes("audio")));
|
||||
if (prepareAudio && current.config.audioSources.length) {
|
||||
// Audio in the first field is part of Anki's duplicate identity. A
|
||||
// failed/stale selection must not turn that identity into text-only.
|
||||
// Browser speech is audible work, so preflight verifies only that the
|
||||
// active capture can record it and defers the exact duplicate identity
|
||||
// until the user submits.
|
||||
let prepared;
|
||||
try {
|
||||
prepared = await audio(request, current.config, { recordSpeech: !preflight });
|
||||
} catch (error) {
|
||||
if (firstFieldAudio) throw error;
|
||||
resources.pronunciationWarning = `Pronunciation: ${error.message}`;
|
||||
}
|
||||
if (prepared?.recordingRequired === true) {
|
||||
if (!preflight) throw new Error("Browser text-to-speech was not recorded for this note.");
|
||||
if (prepared.clientSpeech) resources.clientSpeech = prepared.clientSpeech;
|
||||
resources.deferDuplicateCheck = true;
|
||||
}
|
||||
else if (prepared) {
|
||||
resources.audioPrepared = true;
|
||||
resources.audio = prepared;
|
||||
}
|
||||
}
|
||||
const pronunciation = resources.audio ? `[sound:${resources.audio.filename}]`
|
||||
: resources.deferDuplicateCheck ? "[sound:hachidori_pending_speech.wav]" : "";
|
||||
const built = await render(request, current.resolved.templates, pronunciation, resources);
|
||||
return { ...resources, ...built };
|
||||
},
|
||||
validateCapture,
|
||||
beforeWrite: prepareWrite,
|
||||
beforeMutation,
|
||||
preflightExtra: async ({ request, prepared, applied }) => {
|
||||
if (!linkedClientPreflights.has(request)) return {};
|
||||
if (prepared.resources.clientSpeech) return { clientSpeech: prepared.resources.clientSpeech };
|
||||
if (prepared.resources.audioPrepared || !applied
|
||||
|| !Object.values(applied.templates).some(template =>
|
||||
ankiTemplateMarkerNames(template.value).includes("audio"))
|
||||
|| prepared.config.audioSources.length === 0) return {};
|
||||
try {
|
||||
const planned = await audio(request, prepared.config, { recordSpeech: false });
|
||||
return planned?.recordingRequired === true && planned.clientSpeech
|
||||
? { clientSpeech: planned.clientSpeech }
|
||||
: {};
|
||||
} catch {
|
||||
// Non-first-field pronunciation remains best-effort. Submission will
|
||||
// report the ordinary warning if no configured source is available.
|
||||
return {};
|
||||
}
|
||||
},
|
||||
afterConfirmed: completeCapture,
|
||||
afterRejected: releaseScreenshot,
|
||||
duplicateIndex,
|
||||
enrich: context => enrichAnkiNote(context, {
|
||||
audio,
|
||||
render,
|
||||
store: (file, kind) => ankiMediaStore.ensure({
|
||||
invoke: context.invoke,
|
||||
filename: file.filename,
|
||||
data: file.data,
|
||||
kind,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// One viewport screenshot for the mining action being taken now. The caller
|
||||
// owns the capture itself, because only it knows which page asked; the picture
|
||||
// is held here under a name a field may reference and uploaded only when the
|
||||
// note is written, so this reply is immediate and the reader can show itself
|
||||
// again without waiting for Anki.
|
||||
async function screenshot(captureViewport, templateId) {
|
||||
const token = crypto.randomUUID();
|
||||
screenshotRequestToken = token;
|
||||
const { anki } = await readOptions();
|
||||
const template = globalThis.HDReaderOptions.ankiTemplateConfig(anki, templateId);
|
||||
if (template === null) throw new Error("The selected Anki Template is no longer available.");
|
||||
if (template.captureScreenshot !== true) throw new Error("Screenshots when mining are turned off in Settings.");
|
||||
const dataUrl = await captureViewport();
|
||||
// Capture retries can complete out of order. Only the latest request may
|
||||
// publish its bytes, even if a newer picture has already been consumed.
|
||||
if (screenshotRequestToken !== token) throw new Error("A newer capture replaced this screenshot request.");
|
||||
const prefix = "data:image/jpeg;base64,";
|
||||
const data = typeof dataUrl === "string" && dataUrl.startsWith(prefix)
|
||||
? dataUrl.slice(prefix.length) : "";
|
||||
const byteLength = decodedBase64Length(data);
|
||||
if (byteLength === null || byteLength > MAX_LINKED_SCREENSHOT_BYTES) {
|
||||
throw new Error("This page produced no screenshot or exceeded the 6 MiB screenshot limit.");
|
||||
}
|
||||
pendingScreenshot = { token, filename: `hachidori-screenshot-${crypto.randomUUID()}.jpg`, data };
|
||||
return { token: pendingScreenshot.token, filename: pendingScreenshot.filename };
|
||||
}
|
||||
|
||||
// An abandoned or definitively rejected submission releases only its own
|
||||
// pending bytes; uploaded media has a separate write-outcome cleanup path.
|
||||
function discardScreenshot(request) {
|
||||
if (pendingScreenshot !== null && pendingScreenshot.token === request?.token) pendingScreenshot = null;
|
||||
return { discarded: true };
|
||||
}
|
||||
|
||||
async function submitRequest(request) {
|
||||
try {
|
||||
const result = await mining.submit(request);
|
||||
if (["duplicate", "invalid"].includes(result.state)) discardScreenshot(request.screenshot);
|
||||
return result;
|
||||
} catch (error) {
|
||||
// The mining service reports a possibly sent mutation as uncertain;
|
||||
// a rejection here confirms that its note write never happened.
|
||||
discardScreenshot(request.screenshot);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitClient(request, clientMedia) {
|
||||
const validated = validateLinkedAnkiClientMedia(request, clientMedia);
|
||||
linkedClientMedia.set(request, validated);
|
||||
try {
|
||||
return await submitRequest(request);
|
||||
} finally {
|
||||
linkedClientMedia.delete(request);
|
||||
}
|
||||
}
|
||||
|
||||
async function preflightClient(request) {
|
||||
linkedClientPreflights.add(request);
|
||||
try {
|
||||
return await mining.preflight(request);
|
||||
} finally {
|
||||
linkedClientPreflights.delete(request);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveClientSpeech(request, recordSpeech) {
|
||||
const plan = request?.clientSpeech;
|
||||
if (!plan || typeof plan !== "object" || Array.isArray(plan)
|
||||
|| typeof request.term?.expression !== "string" || typeof request.term.reading !== "string"
|
||||
|| plan.expression !== request.term.expression || plan.reading !== request.term.reading) {
|
||||
throw new Error("The linked browser-speech request is invalid or stale.");
|
||||
}
|
||||
const options = await readOptions();
|
||||
const sources = options.audioSources.filter(source => source.enabled);
|
||||
const source = sources.find(candidate => candidate.id === plan.sourceId
|
||||
&& JSON.stringify(candidate) === plan.sourceKey
|
||||
&& typeof candidate.type === "string" && candidate.type.startsWith("text-to-speech"));
|
||||
if (!source) throw new Error("The browser-speech source changed. Check Audio Settings and try again.");
|
||||
if (request.audioSelection !== undefined
|
||||
&& (request.audioSelection?.sourceId !== source.id || request.audioSelection.sourceKey !== plan.sourceKey)) {
|
||||
throw new Error("The selected pronunciation changed before browser speech could be recorded.");
|
||||
}
|
||||
const file = await offscreen({
|
||||
type: "hd_anki_audio",
|
||||
term: request.term,
|
||||
selection: request.audioSelection,
|
||||
sources: [source],
|
||||
recordSpeech,
|
||||
});
|
||||
if (!recordSpeech) {
|
||||
if (file?.recordingRequired !== true) {
|
||||
throw new Error("Browser text-to-speech preflight returned an unexpected result.");
|
||||
}
|
||||
return { available: true };
|
||||
}
|
||||
const byteLength = decodedBase64Length(file?.data);
|
||||
if (file?.sourceId !== source.id || typeof file.filename !== "string"
|
||||
|| byteLength === null || byteLength < 1) {
|
||||
throw new Error("Browser text-to-speech produced no transferable WAV data.");
|
||||
}
|
||||
return {
|
||||
sourceId: plan.sourceId,
|
||||
sourceKey: plan.sourceKey,
|
||||
expression: plan.expression,
|
||||
reading: plan.reading,
|
||||
filename: file.filename,
|
||||
byteLength,
|
||||
data: file.data,
|
||||
};
|
||||
}
|
||||
|
||||
const clientSpeech = request => resolveClientSpeech(request, true);
|
||||
const preflightClientSpeech = request => resolveClientSpeech(request, false);
|
||||
|
||||
// The reading browser owns these bytes. Export them only when submission is
|
||||
// about to cross the sharing socket, without consulting its local engine or
|
||||
// Anki endpoint.
|
||||
async function clientMedia(request) {
|
||||
const value = {};
|
||||
if (request?.screenshot && !request.captureUnavailable?.includes("screenshot")) {
|
||||
const screenshot = pendingScreenshot;
|
||||
if (!screenshot || screenshot.token !== request.screenshot.token
|
||||
|| screenshot.filename !== request.screenshot.filename) {
|
||||
throw new Error("The screenshot was replaced before it could be sent to the linked Hachidori.");
|
||||
}
|
||||
value.screenshot = { token: screenshot.token, filename: screenshot.filename, data: screenshot.data };
|
||||
}
|
||||
if (typeof request?.captureJobId === "string" && request.captureJobId !== "") {
|
||||
assertCapturePin(request.capturePin);
|
||||
const status = await captureRequest("hd_capture_job_status", { jobId: request.captureJobId });
|
||||
if (status.state === "finishing") throw new Error("The selected clip is still finishing.");
|
||||
if (status.state === "encoding") throw new Error("The selected clip is still encoding.");
|
||||
if (status.state !== "ready") throw new Error(status.error || "The selected clip could not be encoded.");
|
||||
const assets = {};
|
||||
for (const kind of ["animation", "audio"]) {
|
||||
const metadata = status.assets?.[kind];
|
||||
if (!metadata) continue;
|
||||
const asset = await captureRequest("hd_capture_asset", { jobId: request.captureJobId, kind });
|
||||
assets[kind] = { filename: asset.filename, byteLength: metadata.byteLength, data: asset.data };
|
||||
}
|
||||
value.capture = {
|
||||
jobId: request.captureJobId,
|
||||
warnings: Array.isArray(status.warnings)
|
||||
? status.warnings.filter(warning => typeof warning === "string")
|
||||
.map(warning => warning.slice(0, 500)).slice(0, 64)
|
||||
: [],
|
||||
assets,
|
||||
};
|
||||
}
|
||||
if (request?.clientSpeech) value.speech = await clientSpeech(request);
|
||||
return validateLinkedAnkiClientMedia(request, value);
|
||||
}
|
||||
|
||||
async function settleClientMedia(request, state) {
|
||||
discardScreenshot(request?.screenshot);
|
||||
const jobId = request?.captureJobId;
|
||||
if (typeof jobId !== "string" || jobId === "") return { settled: true };
|
||||
if (["added", "updated"].includes(state)) {
|
||||
await captureRequest("hd_capture_complete", { jobId });
|
||||
} else if (["duplicate", "invalid"].includes(state)) {
|
||||
await captureRequest("hd_capture_cancel", { jobId });
|
||||
}
|
||||
return { settled: true };
|
||||
}
|
||||
|
||||
return { ...mining, preflightClient, preflightClientSpeech, submit: submitRequest, submitClient,
|
||||
clientMedia, settleClientMedia,
|
||||
screenshot, discardScreenshot, async maturity(request) {
|
||||
try {
|
||||
const options = await readOptions();
|
||||
return { mature: options.definitionBlurAnkiMature === true
|
||||
&& await duplicateIndex.has(options.anki, request?.term?.expression) };
|
||||
} catch {
|
||||
// Missing local evidence never prevents dictionary lookup.
|
||||
return { mature: false };
|
||||
}
|
||||
} };
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { ankiTemplateMarkerNames, resolveAnkiTemplates } from "./anki-templates.js";
|
||||
import "./reader-options.js";
|
||||
|
||||
export class AnkiTransportError extends Error {
|
||||
constructor(message, { dispatched }) {
|
||||
super(message);
|
||||
Object.defineProperty(this, "name", { value: "AnkiTransportError", configurable: true });
|
||||
Object.defineProperty(this, "dispatched", { value: dispatched === true, enumerable: false });
|
||||
}
|
||||
}
|
||||
|
||||
export function isUndispatchedAnkiTransportError(error) {
|
||||
return error instanceof AnkiTransportError && error.dispatched === false;
|
||||
}
|
||||
|
||||
// Every AnkiConnect API-v6 reply, including each sub-action reply inside a
|
||||
// `multi` batch, is exactly `{ result, error }` with a string or null error.
|
||||
const isEnvelope = payload => payload !== null && typeof payload === "object" && !Array.isArray(payload)
|
||||
&& Object.keys(payload).length === 2 && Object.hasOwn(payload, "result") && Object.hasOwn(payload, "error")
|
||||
&& (payload.error === null || typeof payload.error === "string");
|
||||
const invalidResponse = () => new Error("AnkiConnect returned an invalid response. Check the add-on and retry.");
|
||||
|
||||
// AnkiConnect's own error strings name the cause but not what to do about it.
|
||||
// Each translation keeps the original text so it can still be searched for.
|
||||
const ANKI_CONNECT_EXPLANATIONS = [
|
||||
[/api key/iu, () => "AnkiConnect requires a valid API key. Enter the key from its add-on configuration."],
|
||||
[/collection is not available/iu,
|
||||
() => "Anki has no open collection. Open your profile in Anki, then retry."],
|
||||
[/^deck was not found: (.+)$/iu,
|
||||
([, deck]) => `Anki has no deck named “${deck}”. Choose an available deck in Anki Settings.`],
|
||||
[/^model was not found: (.+)$/iu,
|
||||
([, model]) => `Anki has no note type named “${model}”. Choose an available note type in Anki Settings.`],
|
||||
[/^cannot create note because it is empty$/iu,
|
||||
() => "Anki refused the note because its first field is empty. Map the first field to content this result has."],
|
||||
[/^cannot create note because it is a duplicate$/iu,
|
||||
() => "Anki refused the note because a note with the same first field already exists."],
|
||||
[/^note was not found: (.+)$/iu,
|
||||
([, id]) => `Anki no longer has note ${id}. It was deleted or moved to another collection; refresh and retry.`],
|
||||
[/unsupported action|unknown action/iu,
|
||||
() => "The installed AnkiConnect add-on is too old for this request. Update AnkiConnect in Anki."],
|
||||
];
|
||||
|
||||
// Turns a raw AnkiConnect error string into the message shown to the reader.
|
||||
export function describeAnkiConnectError(error) {
|
||||
for (const [pattern, explain] of ANKI_CONNECT_EXPLANATIONS) {
|
||||
const match = pattern.exec(error);
|
||||
if (match === null) continue;
|
||||
const explanation = explain(match);
|
||||
return pattern === ANKI_CONNECT_EXPLANATIONS[0][0] ? explanation : `${explanation} (AnkiConnect: ${error})`;
|
||||
}
|
||||
return `AnkiConnect: ${error}`;
|
||||
}
|
||||
|
||||
function unwrap(reply) {
|
||||
if (reply.error !== null) throw new Error(describeAnkiConnectError(reply.error));
|
||||
return reply.result;
|
||||
}
|
||||
|
||||
// Unwraps the sub-action replies of one `invoke("multi", …)` result, throwing
|
||||
// the first sub-action failure the way a direct request would.
|
||||
export function ankiMultiResults(replies) {
|
||||
return replies.map(unwrap);
|
||||
}
|
||||
|
||||
function names(action, reply) {
|
||||
const result = unwrap(reply);
|
||||
if (!Array.isArray(result) || result.some(name => typeof name !== "string" || name.trim() === "")) {
|
||||
throw new Error(`AnkiConnect returned an invalid ${action} list.`);
|
||||
}
|
||||
// Exact names remain authoritative; model field order determines Anki's
|
||||
// required first field. Never sort the returned list or truncate it.
|
||||
return [...new Set(result)];
|
||||
}
|
||||
|
||||
// GSM PR #549's API-v6 discovery, adapted to the MV3 worker. AnkiConnect
|
||||
// handles requests through Anki's UI loop, so each endpoint gets a small,
|
||||
// bounded set of transport lanes. Four lanes let a replacement Settings check
|
||||
// pass one delayed stale reply without allowing an unbounded server-side queue.
|
||||
// The private worker's feature handlers still select actions and bind every
|
||||
// conversation to its configured endpoint and API key.
|
||||
export function createAnkiGateway({ fetch = globalThis.fetch, timeoutMs = 10_000,
|
||||
readSubminerProxyUrl = async () => (await globalThis.chrome?.storage?.local?.get("subminerAnkiProxyUrl"))?.subminerAnkiProxyUrl,
|
||||
} = {}) {
|
||||
const maximumActive = 4;
|
||||
const queues = new Map();
|
||||
|
||||
async function isSubminerEndpoint(endpoint) {
|
||||
const url = globalThis.HDReaderOptions.normaliseAnkiConnectUrl(endpoint);
|
||||
if (url === null) return false;
|
||||
try { return url === await readSubminerProxyUrl(); }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
async function dispatch({ url, body, requestTimeoutMs }, queue) {
|
||||
const controller = new AbortController();
|
||||
queue.controllers.add(controller);
|
||||
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
|
||||
const unavailable = () => new AnkiTransportError(
|
||||
controller.signal.aborted ? "AnkiConnect timed out. Check its URL in Settings, open Anki and retry."
|
||||
: "Open Anki with the AnkiConnect add-on installed, check its URL in Settings, then retry.",
|
||||
{ dispatched: true },
|
||||
);
|
||||
const interrupted = () => controller.signal.reason instanceof AnkiTransportError
|
||||
? controller.signal.reason : unavailable();
|
||||
try {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, { method: "POST", credentials: "omit", redirect: "error",
|
||||
headers: { "Content-Type": "application/json" }, signal: controller.signal,
|
||||
body });
|
||||
} catch {
|
||||
throw interrupted();
|
||||
}
|
||||
if (!response.ok) throw new Error(response.status === 403
|
||||
? "AnkiConnect denied permission. Allow this extension in AnkiConnect’s webCorsOriginList, then retry."
|
||||
: `AnkiConnect returned HTTP ${response.status}.`);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (controller.signal.aborted) throw interrupted();
|
||||
if (!isEnvelope(payload)) throw invalidResponse();
|
||||
return unwrap(payload);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
queue.controllers.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
function failQueue(url, queue, error) {
|
||||
if (queue.failure !== null) return;
|
||||
queue.failure = error;
|
||||
if (queues.get(url) === queue) queues.delete(url);
|
||||
for (const pending of queue.pending.splice(0)) {
|
||||
pending.reject(new AnkiTransportError(error.message, { dispatched: false }));
|
||||
}
|
||||
// Every active entry has entered fetch, so aborting one cannot prove that
|
||||
// its Anki mutation did not run. Mark active siblings dispatched and keep
|
||||
// their outcomes conservative while ending a failed endpoint generation
|
||||
// within one transport deadline.
|
||||
for (const controller of queue.controllers) {
|
||||
controller.abort(new AnkiTransportError(error.message, { dispatched: true }));
|
||||
}
|
||||
}
|
||||
|
||||
async function runEntry(url, queue, entry) {
|
||||
try {
|
||||
entry.resolve(await dispatch(entry.request, queue));
|
||||
} catch (error) {
|
||||
entry.reject(error);
|
||||
if (error instanceof AnkiTransportError) failQueue(url, queue, error);
|
||||
} finally {
|
||||
queue.active -= 1;
|
||||
pump(url, queue);
|
||||
}
|
||||
}
|
||||
|
||||
function pump(url, queue) {
|
||||
if (queue.failure === null) {
|
||||
while (queue.active < maximumActive && queue.pending.length > 0) {
|
||||
const entry = queue.pending.shift();
|
||||
queue.active += 1;
|
||||
void runEntry(url, queue, entry);
|
||||
}
|
||||
}
|
||||
if (queue.active === 0 && queue.pending.length === 0 && queues.get(url) === queue) {
|
||||
queues.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(request) {
|
||||
let queue = queues.get(request.url);
|
||||
if (!queue) {
|
||||
queue = { pending: [], active: 0, controllers: new Set(), failure: null };
|
||||
queues.set(request.url, queue);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
queue.pending.push({ request, resolve, reject });
|
||||
pump(request.url, queue);
|
||||
});
|
||||
}
|
||||
|
||||
// AnkiConnect's socket is polled on a timer, so every request costs one poll
|
||||
// interval regardless of content and parallel requests serialise. A `multi`
|
||||
// batch pays that once. AnkiConnect runs each sub-action through its ordinary
|
||||
// handler, which checks the API key and picks the reply shape per sub-action,
|
||||
// so every sub-action is bound to this conversation's key and API v6 here and
|
||||
// the reply is an array of `{ result, error }` envelopes in request order.
|
||||
async function invoke(action, params, apiKey, requestTimeoutMs = timeoutMs,
|
||||
endpoint = globalThis.HDReaderOptions.DEFAULT_OPTIONS.anki.url) {
|
||||
const url = globalThis.HDReaderOptions.normaliseAnkiConnectUrl(endpoint);
|
||||
if (url === null) throw new Error("Enter a valid HTTP or HTTPS AnkiConnect URL in Settings, without a username or password.");
|
||||
const privateParams = value => value && (Object.hasOwn(value, "subminerEnrich")
|
||||
|| Object.hasOwn(value, "subminerDuplicateNoteIds"));
|
||||
const containsPrivateParams = privateParams(params)
|
||||
|| (action === "multi" && params.actions.some(entry => privateParams(entry.params)));
|
||||
if (containsPrivateParams) {
|
||||
if (!await isSubminerEndpoint(url)) {
|
||||
const strip = value => {
|
||||
if (!privateParams(value)) return value;
|
||||
const { subminerEnrich, subminerDuplicateNoteIds, ...publicParams } = value;
|
||||
return publicParams;
|
||||
};
|
||||
params = action === "multi"
|
||||
? { ...strip(params), actions: params.actions.map(entry => ({ ...entry, params: strip(entry.params) })) }
|
||||
: strip(params);
|
||||
}
|
||||
}
|
||||
const key = apiKey ? { key: apiKey } : {};
|
||||
// Sub-actions are rebuilt from their action and params so nothing else a
|
||||
// caller passes reaches the wire.
|
||||
const actions = action === "multi"
|
||||
? params.actions.map(entry => ({ action: entry.action, params: entry.params, version: 6, ...key })) : null;
|
||||
const body = JSON.stringify({ action, version: 6, params: actions ? { actions } : params, ...key });
|
||||
const result = await enqueue({ url, body, requestTimeoutMs });
|
||||
if (actions && (!Array.isArray(result) || result.length !== actions.length || !result.every(isEnvelope))) {
|
||||
throw invalidResponse();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// One round trip: decks, note types and, speculatively, the configured note
|
||||
// type's fields. The field reply is ignored when that note type is absent.
|
||||
async function discover({ model, apiKey = "", url }) {
|
||||
const errors = [];
|
||||
let connected = false;
|
||||
let replies;
|
||||
try {
|
||||
replies = await invoke("multi", { actions: [
|
||||
{ action: "deckNames", params: {} },
|
||||
{ action: "modelNames", params: {} },
|
||||
{ action: "modelFieldNames", params: { modelName: model } },
|
||||
] }, apiKey, undefined, url);
|
||||
} catch (error) {
|
||||
return { connected, model, decks: [], models: [], fields: [], errors: [error.message] };
|
||||
}
|
||||
function read(action, reply) {
|
||||
try {
|
||||
const result = names(action, reply);
|
||||
connected = true;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (!errors.includes(error.message)) errors.push(error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const decks = read("deckNames", replies[0]);
|
||||
const models = read("modelNames", replies[1]);
|
||||
const fields = models.includes(model) ? read("modelFieldNames", replies[2]) : [];
|
||||
return { connected, model, decks, models, fields, errors };
|
||||
}
|
||||
return { discover, invoke, isSubminerEndpoint };
|
||||
}
|
||||
|
||||
// Shared by Settings and authoritative mining readiness checks. Validation
|
||||
// reports missing choices instead of changing a saved or in-progress mapping.
|
||||
export function ankiAvailability(config, discovery, resolvedTemplates) {
|
||||
if (!discovery) return ["Refresh Anki to check this configuration."];
|
||||
if (!discovery.connected) return discovery.errors;
|
||||
const errors = [...discovery.errors];
|
||||
if (!discovery.decks.includes(config.deck)) {
|
||||
errors.push(config.deck
|
||||
? `Anki has no deck named “${config.deck}”. Choose an available deck.`
|
||||
: "Choose an available deck.");
|
||||
}
|
||||
if (!discovery.models.includes(config.model)) {
|
||||
errors.push(config.model
|
||||
? `Anki has no note type named “${config.model}”. Choose an available note type.`
|
||||
: "Choose an available note type.");
|
||||
}
|
||||
if (config.model !== discovery.model) {
|
||||
return [...errors, `Refresh fields for the selected note type, “${config.model}”.`];
|
||||
}
|
||||
const resolved = resolvedTemplates ?? resolveAnkiTemplates(config, discovery.fields);
|
||||
errors.push(...resolved.errors);
|
||||
if (discovery.fields.length > 0 && !resolved.templates[discovery.fields[0]].value.trim()) {
|
||||
errors.push(`Map the first field, “${discovery.fields[0]}”, of note type “${config.model}” before adding notes. Anki requires it.`);
|
||||
}
|
||||
if (discovery.fields.length > 0) {
|
||||
const markers = ankiTemplateMarkerNames(resolved.templates[discovery.fields[0]].value);
|
||||
if (markers.includes("capture-animation") || markers.includes("capture-audio") || markers.includes("screenshot")) {
|
||||
errors.push(`Captured media cannot be mapped to the first field, “${discovery.fields[0]}”.`);
|
||||
}
|
||||
}
|
||||
if (config.model && discovery.fields.length === 0 && errors.length === 0) errors.push("The selected note type has no fields.");
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/*
|
||||
* Host side of the relay's Yomitan-compatible API (hachidori-anki
|
||||
* docs/host-contract.md). The relay connects as a sharing client that lives
|
||||
* inside Anki and forwards each HTTP request as an `hd_api_*` runtime message;
|
||||
* this module answers them from the engine's results, projected onto the
|
||||
* shapes Yomitan's own API produces so existing tools work unchanged.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./render/glossary.js";
|
||||
import { escapeAnkiHtml } from "./anki-templates.js";
|
||||
|
||||
export { API_CAPABILITY, API_CLIENT_ORIGIN } from "./sharing-protocol.js";
|
||||
|
||||
export const API_REQUESTS = new Set([
|
||||
"hd_api_version", "hd_api_term_entries", "hd_api_kanji_entries", "hd_api_anki_fields", "hd_api_tokenize",
|
||||
"hd_api_dictionaries", "hd_api_dictionary_open", "hd_api_dictionary_read", "hd_api_dictionary_close",
|
||||
]);
|
||||
|
||||
const AUDIO_TYPES = { aac: "audio/aac", flac: "audio/flac", m4a: "audio/mp4", mp3: "audio/mpeg", ogg: "audio/ogg",
|
||||
wav: "audio/wav", webm: "audio/webm" };
|
||||
|
||||
function words(value) {
|
||||
return String(value ?? "").split(/\s+/u).filter(Boolean);
|
||||
}
|
||||
|
||||
function strings(value) {
|
||||
return Array.isArray(value) ? value.filter(item => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
function tag(name, dictionary) {
|
||||
return { name, category: "", order: 0, score: 0, content: [], dictionaries: [dictionary], redundant: false };
|
||||
}
|
||||
|
||||
function parseGlossary(text) {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
return [String(text)];
|
||||
}
|
||||
}
|
||||
|
||||
// Yomitan's TermDictionaryEntry, as far as the engine's result carries it.
|
||||
function termEntry(result, where) {
|
||||
const { term, matched, deinflected, trace } = result;
|
||||
const first = term.glossaries[0]?.dictionary ?? "";
|
||||
const headwordTags = [...new Set(term.glossaries.flatMap(glossary => words(glossary.termTags)))];
|
||||
return {
|
||||
type: "term",
|
||||
isPrimary: true,
|
||||
textProcessorRuleChainCandidates: [[]],
|
||||
inflectionRuleChainCandidates: [{
|
||||
source: "dictionary",
|
||||
inflectionRules: trace.map(step => ({ name: step.name, description: step.description })),
|
||||
}],
|
||||
score: term.score,
|
||||
frequencyOrder: 0,
|
||||
dictionaryIndex: where.index(first),
|
||||
dictionaryAlias: where.alias(first),
|
||||
sourceTermExactMatchCount: matched === term.expression ? 1 : 0,
|
||||
matchPrimaryReading: false,
|
||||
maxOriginalTextLength: matched.length,
|
||||
headwords: [{
|
||||
index: 0,
|
||||
term: term.expression,
|
||||
reading: term.reading,
|
||||
sources: [{ originalText: matched, transformedText: deinflected, deinflectedText: deinflected,
|
||||
matchType: "exact", matchSource: "term", isPrimary: true }],
|
||||
tags: headwordTags.map(name => tag(name, first)),
|
||||
wordClasses: words(term.rules),
|
||||
}],
|
||||
definitions: term.glossaries.map((glossary, index) => ({
|
||||
index,
|
||||
headwordIndices: [0],
|
||||
dictionary: glossary.dictionary,
|
||||
dictionaryIndex: where.index(glossary.dictionary),
|
||||
dictionaryAlias: where.alias(glossary.dictionary),
|
||||
id: index,
|
||||
score: term.score,
|
||||
frequencyOrder: 0,
|
||||
sequences: [-1],
|
||||
isPrimary: true,
|
||||
tags: words(glossary.definitionTags).map(name => tag(name, glossary.dictionary)),
|
||||
entries: parseGlossary(glossary.glossary),
|
||||
})),
|
||||
pronunciations: term.pitches.map(group => ({
|
||||
headwordIndex: 0,
|
||||
dictionary: group.dictionary,
|
||||
dictionaryIndex: where.index(group.dictionary),
|
||||
dictionaryAlias: where.alias(group.dictionary),
|
||||
pronunciations: [
|
||||
...group.pitches.map(pitch => ({ type: "pitch-accent", positions: pitch.position,
|
||||
nasalPositions: pitch.nasal, devoicePositions: pitch.devoice, tags: [] })),
|
||||
...group.transcriptions.map(ipa => ({ type: "phonetic-transcription", ipa, tags: [] })),
|
||||
],
|
||||
})),
|
||||
frequencies: term.frequencies.flatMap(group => group.frequencies.map(value => ({
|
||||
index: 0,
|
||||
headwordIndex: 0,
|
||||
dictionary: group.dictionary,
|
||||
dictionaryIndex: where.index(group.dictionary),
|
||||
dictionaryAlias: where.alias(group.dictionary),
|
||||
hasReading: typeof value.reading === 'string' && value.reading.length > 0,
|
||||
frequency: value.value,
|
||||
displayValue: value.displayValue || null,
|
||||
displayValueParsed: false,
|
||||
}))).map((entry, index) => ({ ...entry, index })),
|
||||
};
|
||||
}
|
||||
|
||||
function kanjiEntry(character, entry, where) {
|
||||
const stats = entry.stats.map(stat => ({ name: stat.name, category: "misc", content: "", order: 0, score: 0,
|
||||
dictionary: entry.dictionary, value: stat.value }));
|
||||
return {
|
||||
type: "kanji",
|
||||
character,
|
||||
dictionary: entry.dictionary,
|
||||
dictionaryIndex: where.index(entry.dictionary),
|
||||
dictionaryAlias: where.alias(entry.dictionary),
|
||||
onyomi: words(entry.onyomi),
|
||||
kunyomi: words(entry.kunyomi),
|
||||
tags: words(entry.tags).map(name => tag(name, entry.dictionary)),
|
||||
stats: stats.length ? { misc: stats } : {},
|
||||
definitions: strings(entry.definitions),
|
||||
frequencies: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Yomitan's kanji note fields have no counterpart in mining, which is term-only.
|
||||
function kanjiFields(character, entry, markers, where) {
|
||||
const stat = name => entry.stats.find(item => item.name === name)?.value ?? "";
|
||||
const table = {
|
||||
character: () => escapeAnkiHtml(character),
|
||||
dictionary: () => escapeAnkiHtml(entry.dictionary),
|
||||
"dictionary-alias": () => escapeAnkiHtml(where.alias(entry.dictionary)),
|
||||
onyomi: () => words(entry.onyomi).map(escapeAnkiHtml).join(", "),
|
||||
kunyomi: () => words(entry.kunyomi).map(escapeAnkiHtml).join(", "),
|
||||
glossary: () => `<ul>${strings(entry.definitions).map(text => `<li>${escapeAnkiHtml(text)}</li>`).join("")}</ul>`,
|
||||
tags: () => words(entry.tags).map(escapeAnkiHtml).join(", "),
|
||||
"stroke-count": () => escapeAnkiHtml(stat("strokes")),
|
||||
frequencies: () => escapeAnkiHtml(stat("freq")),
|
||||
};
|
||||
return Object.fromEntries(markers.map(marker => [marker, Object.hasOwn(table, marker) ? table[marker]() : ""]));
|
||||
}
|
||||
|
||||
// Yomitan's distributeFuriganaInflected: the reading covers the stem shared by
|
||||
// the dictionary form and the matched text; the inflected ending has none.
|
||||
function furiganaSegments(expression, reading, matched) {
|
||||
const { segmentFurigana } = globalThis.HDGlossary;
|
||||
if (matched === expression) return segmentFurigana(expression, reading);
|
||||
let stem = 0;
|
||||
while (stem < expression.length && stem < matched.length && expression[stem] === matched[stem]) stem += 1;
|
||||
const ending = expression.slice(stem);
|
||||
if (stem === 0 || !reading.endsWith(ending)) return [{ text: matched, reading }];
|
||||
return [...segmentFurigana(expression.slice(0, stem), reading.slice(0, reading.length - ending.length)),
|
||||
{ text: matched.slice(stem), reading: "" }];
|
||||
}
|
||||
|
||||
function fileName(title) {
|
||||
const safe = String(title).replaceAll(/[\\/:*?"<>|\u0000-\u001f]/gu, "_").trim();
|
||||
return `${safe || "dictionary"}.hachidori.zip`;
|
||||
}
|
||||
|
||||
function requireText(value, name) {
|
||||
if (typeof value !== "string") throw new Error(`${name} must be a string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireStrings(value, name) {
|
||||
if (!Array.isArray(value) || value.some(item => typeof item !== "string")) throw new Error(`${name} must be an array of strings`);
|
||||
return value;
|
||||
}
|
||||
|
||||
// `engine(fields)` answers a "hoshidicts-offscreen" request, `render(fields)`
|
||||
// a "hachidori-anki-render" one; both resolve to the reply envelope or throw
|
||||
// its error. `readDictionaries()` is the stored dictionary list and
|
||||
// `readAudioSources()` the enabled pronunciation sources.
|
||||
export function createApiHost({ engine, render, readDictionaries, readAudioSources, version }) {
|
||||
async function whereabouts() {
|
||||
const dictionaries = await readDictionaries();
|
||||
const titles = dictionaries.map(item => item.title);
|
||||
return {
|
||||
dictionaries,
|
||||
index: title => Math.max(0, titles.indexOf(title)),
|
||||
alias: title => dictionaries.find(item => item.title === title)?.displayName || title,
|
||||
};
|
||||
}
|
||||
|
||||
async function lookup(text, fields = {}) {
|
||||
const reply = await engine({ type: "hd_lookup", text, ...fields });
|
||||
return { results: reply.results, generation: reply.generation };
|
||||
}
|
||||
|
||||
async function ankiTermFields(text, markers, maxEntries, includeMedia) {
|
||||
const where = await whereabouts();
|
||||
const { results, generation } = await lookup(text, maxEntries > 0 ? { maxResults: maxEntries } : {});
|
||||
const enabled = where.dictionaries.filter(item => item.enabled !== false);
|
||||
const dictionaryPaths = Object.fromEntries(enabled.map(item => [item.title, item.path]));
|
||||
const frequencyModes = new Map(where.dictionaries.map(item => [item.title, item.frequencyMode]));
|
||||
const templates = Object.fromEntries(markers.map(marker => [marker, { value: `{${marker}}`, overwriteMode: "coalesce" }]));
|
||||
const fields = [], dictionaryMedia = [], audioMedia = [];
|
||||
const seenMedia = new Set();
|
||||
for (const result of maxEntries > 0 ? results.slice(0, maxEntries) : results) {
|
||||
const term = { ...result.term, frequencies: result.term.frequencies.map(group =>
|
||||
({ ...group, frequencyMode: frequencyModes.get(group.dictionary) })) };
|
||||
const request = { ...result, term, generation, sentence: text, matchOffset: 0, matched: result.matched,
|
||||
searchQuery: text, popupSelectionText: "", documentTitle: "",
|
||||
dictionaryAliases: Object.fromEntries(where.dictionaries.filter(item => item.displayName).map(item => [item.title, item.displayName])),
|
||||
dictionaryIds: Object.fromEntries(where.dictionaries.map(item => [item.title, item.id])),
|
||||
frequencyDictionaries: where.dictionaries.filter(item => item.enabled && item.frequencyCount > 0).map(item => item.title) };
|
||||
let audio = "";
|
||||
if (markers.includes("audio") && includeMedia) {
|
||||
const sources = await readAudioSources();
|
||||
const prepared = sources.length
|
||||
? await render({ type: "hd_anki_audio", term: result.term, sources, recordSpeech: false }).catch(() => null)
|
||||
: null;
|
||||
if (typeof prepared?.filename === "string" && typeof prepared.data === "string") {
|
||||
audio = `[sound:${prepared.filename}]`;
|
||||
const extension = prepared.filename.split(".").at(-1).toLowerCase();
|
||||
audioMedia.push({ term: term.expression, reading: term.reading,
|
||||
mediaType: AUDIO_TYPES[extension] ?? "application/octet-stream", content: prepared.data, ankiFilename: prepared.filename });
|
||||
}
|
||||
}
|
||||
const built = await render({ type: "hd_anki_fields", request, templates, audio, dictionaryPaths });
|
||||
fields.push(Object.fromEntries(markers.map(marker => [marker, built.fields[marker] ?? ""])));
|
||||
if (!includeMedia) continue;
|
||||
for (const item of built.media) {
|
||||
if (seenMedia.has(item.filename)) continue;
|
||||
seenMedia.add(item.filename);
|
||||
const reply = await engine({ type: "hd_media", dictionary: item.dictionary, path: item.path, generation });
|
||||
const match = typeof reply.dataUrl === "string" ? /^data:([^;,]+);base64,([A-Za-z0-9+/]*={0,2})$/u.exec(reply.dataUrl) : null;
|
||||
if (!match) continue;
|
||||
dictionaryMedia.push({ dictionary: item.dictionary, path: item.path, mediaType: match[1], content: match[2], ankiFilename: item.filename });
|
||||
}
|
||||
}
|
||||
return { fields, dictionaryMedia, audioMedia };
|
||||
}
|
||||
|
||||
async function ankiKanjiFields(text, markers, maxEntries) {
|
||||
const where = await whereabouts();
|
||||
const character = [...text][0] ?? "";
|
||||
const reply = await engine({ type: "hd_kanji", character });
|
||||
const entries = reply.kanji?.entries ?? [];
|
||||
return { fields: (maxEntries > 0 ? entries.slice(0, maxEntries) : entries).map(entry => kanjiFields(character, entry, markers, where)),
|
||||
dictionaryMedia: [], audioMedia: [] };
|
||||
}
|
||||
|
||||
async function tokenize(text, index, scanLength) {
|
||||
const lines = [];
|
||||
for (const line of text.split("\n")) {
|
||||
const segments = [];
|
||||
const plain = (value) => {
|
||||
if (value === "") return;
|
||||
const last = segments.at(-1);
|
||||
if (last && last.reading === "") last.text += value;
|
||||
else segments.push({ text: value, reading: "" });
|
||||
};
|
||||
let position = 0;
|
||||
while (position < line.length) {
|
||||
const rest = line.slice(position);
|
||||
const { results } = await lookup(rest, { maxResults: 1, ...(scanLength ? { scanLength } : {}) });
|
||||
const best = results[0];
|
||||
if (!best || !best.matched || !rest.startsWith(best.matched)) {
|
||||
const step = String.fromCodePoint(rest.codePointAt(0));
|
||||
plain(step);
|
||||
position += step.length;
|
||||
continue;
|
||||
}
|
||||
for (const segment of furiganaSegments(best.term.expression, best.term.reading, best.matched)) {
|
||||
if (segment.reading === "") plain(segment.text);
|
||||
else segments.push({ text: segment.text, reading: segment.reading });
|
||||
}
|
||||
position += best.matched.length;
|
||||
}
|
||||
lines.push(segments);
|
||||
}
|
||||
return { id: "scan", source: "scanning-parser", dictionary: null, index, content: lines };
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
hd_api_version: () => ({ version }),
|
||||
|
||||
async hd_api_term_entries(message) {
|
||||
const terms = requireStrings(message.terms, "terms");
|
||||
const where = await whereabouts();
|
||||
const results = [];
|
||||
for (const [index, text] of terms.entries()) {
|
||||
const found = text === "" ? [] : (await lookup(text)).results;
|
||||
results.push({ index, dictionaryEntries: found.map(result => termEntry(result, where)),
|
||||
originalTextLength: found[0]?.matched.length ?? 0 });
|
||||
}
|
||||
return { results };
|
||||
},
|
||||
|
||||
async hd_api_kanji_entries(message) {
|
||||
const characters = requireStrings(message.characters, "characters");
|
||||
const where = await whereabouts();
|
||||
const results = [];
|
||||
for (const [index, text] of characters.entries()) {
|
||||
const entries = [];
|
||||
for (const character of [...text]) {
|
||||
const reply = await engine({ type: "hd_kanji", character });
|
||||
for (const entry of reply.kanji?.entries ?? []) entries.push(kanjiEntry(character, entry, where));
|
||||
}
|
||||
results.push({ index, dictionaryEntries: entries });
|
||||
}
|
||||
return { results };
|
||||
},
|
||||
|
||||
async hd_api_anki_fields(message) {
|
||||
const text = requireText(message.text, "text");
|
||||
const markers = requireStrings(message.markers, "markers").map(marker => marker.toLowerCase());
|
||||
const maxEntries = Number.isSafeInteger(message.maxEntries) && message.maxEntries > 0 ? message.maxEntries : 0;
|
||||
const includeMedia = message.includeMedia === true;
|
||||
if (message.entryType === "kanji") return ankiKanjiFields(text, markers, maxEntries);
|
||||
if (message.entryType !== "term") throw new Error(`unsupported entry type ${JSON.stringify(message.entryType)}`);
|
||||
return ankiTermFields(text, markers, maxEntries, includeMedia);
|
||||
},
|
||||
|
||||
async hd_api_tokenize(message) {
|
||||
const texts = requireStrings(message.texts, "texts");
|
||||
const scanLength = Number.isSafeInteger(message.scanLength) && message.scanLength > 0 ? message.scanLength : 0;
|
||||
const results = [];
|
||||
for (const [index, text] of texts.entries()) results.push(await tokenize(text, index, scanLength));
|
||||
return { results };
|
||||
},
|
||||
|
||||
async hd_api_dictionaries() {
|
||||
const dictionaries = await readDictionaries();
|
||||
return { dictionaries: dictionaries.map(item => ({ id: item.id, title: item.title, revision: item.revision,
|
||||
fileName: fileName(item.title) })) };
|
||||
},
|
||||
|
||||
async hd_api_dictionary_open(message) {
|
||||
const id = requireText(message.id, "id");
|
||||
const dictionary = (await readDictionaries()).find(item => item.id === id);
|
||||
if (!dictionary) return { error: "unknown dictionary", notFound: true };
|
||||
const reply = await engine({ type: "hd_api_dictionary_open", id });
|
||||
return { token: reply.token, size: reply.size, fileName: fileName(dictionary.title) };
|
||||
},
|
||||
|
||||
async hd_api_dictionary_read(message) {
|
||||
const reply = await engine({ type: "hd_api_dictionary_read", token: message.token, offset: message.offset, length: message.length });
|
||||
return { data: reply.data, eof: reply.eof };
|
||||
},
|
||||
|
||||
async hd_api_dictionary_close(message) {
|
||||
await engine({ type: "hd_api_dictionary_close", token: message.token });
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
return async function answerApiRequest(message) {
|
||||
if (!Object.hasOwn(handlers, message.type)) throw new Error(`unsupported API request ${JSON.stringify(message.type)}`);
|
||||
return handlers[message.type](message);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Visual novel backgrounds
|
||||
|
||||
Copyright © 2026 bee-san.
|
||||
|
||||
`preview-background.webp` is the repository owner's supplied artwork from
|
||||
`ChatGPT Image Sep 8, 2026, 06_29_20 AM.png`. On September 8, 2026, the owner
|
||||
confirmed that they own its copyright and requested its use in Hachidori, with
|
||||
ownership documented for Chrome publishing.
|
||||
|
||||
`preview-background-2.webp` through `preview-background-6.webp` are additional
|
||||
owner-supplied scenes, from the September 8, 2026 images made at 07:16:27,
|
||||
07:16:31, 07:16:38, 07:16:43 and 07:18:10, respectively. The owner requested
|
||||
their inclusion in the first-run and Design scene rotation.
|
||||
|
||||
Each packaged file is that artwork cropped to the 1672 × 672 panel the scene
|
||||
displays, encoded as WebP.
|
||||
|
||||
See the [asset ownership and publishing record](../../docs/asset-rights.md) for
|
||||
the source identities, checksums, logo inventory and authorized project use.
|
||||
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 144 KiB |
@@ -0,0 +1,36 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Shared retention policy for candidate metadata and offscreen-owned media.
|
||||
// A value too large to retain is still usable by its caller, uncached.
|
||||
export function createAudioCache({ maxEntries, maxBytes, ttlMs, onEvict, now = () => performance.now() }) {
|
||||
const entries = new Map();
|
||||
let retainedBytes = 0;
|
||||
|
||||
function remove(key) {
|
||||
const entry = entries.get(key);
|
||||
if (!entry) return;
|
||||
entries.delete(key);
|
||||
retainedBytes -= entry.bytes;
|
||||
onEvict?.(entry.value);
|
||||
}
|
||||
|
||||
return {
|
||||
delete: remove,
|
||||
get(key) {
|
||||
const entry = entries.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (entry.expiresAt <= now()) { remove(key); return undefined; }
|
||||
entries.delete(key);
|
||||
entries.set(key, entry);
|
||||
return entry.value;
|
||||
},
|
||||
set(key, value, bytes) {
|
||||
remove(key);
|
||||
if (bytes > maxBytes) return false;
|
||||
entries.set(key, { value, bytes, expiresAt: now() + ttlMs });
|
||||
retainedBytes += bytes;
|
||||
while (entries.size > maxEntries || retainedBytes > maxBytes) remove(entries.keys().next().value);
|
||||
return true;
|
||||
},
|
||||
clear() { for (const key of entries.keys()) remove(key); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function current(record) {
|
||||
return record.button.isConnected && !record.popup.hidden && record.isCurrent();
|
||||
}
|
||||
|
||||
function setBusy(record, busy) {
|
||||
record.button.setAttribute("aria-busy", String(busy));
|
||||
if (busy) record.button.dataset.state = "loading";
|
||||
else if (record.button.dataset.state !== "error") delete record.button.dataset.state;
|
||||
record.button.setAttribute("aria-label", `${busy ? "Stop" : "Play"} pronunciation for ${record.term.expression}`);
|
||||
record.button.title = `${busy ? "Stop" : "Play"} pronunciation; Shift-click, right-click or press Down for choices`;
|
||||
}
|
||||
|
||||
function createAudioController({ window, send, onMenuChange, onSelectionChange = () => {} }) {
|
||||
const document = window.document;
|
||||
const bound = new WeakMap(), visited = new WeakMap(), feedback = new WeakMap();
|
||||
const controls = new Set();
|
||||
let selections = new WeakMap();
|
||||
let options = window.HDReaderOptions.DEFAULT_OPTIONS;
|
||||
let sourceKey = JSON.stringify(options.audioSources);
|
||||
let active = null, menu = null;
|
||||
let optionsReady = false;
|
||||
// One first result per owner may wait for options or until the owner's
|
||||
// blurred definitions are revealed; its first-visit key stays unconsumed meanwhile.
|
||||
const pendingAutoplay = new Map();
|
||||
|
||||
function audioAvailable() {
|
||||
return options.audioSources.some(source => source.enabled
|
||||
&& (source.type.startsWith("text-to-speech") || source.url.trim()));
|
||||
}
|
||||
|
||||
function updateControls() {
|
||||
const available = audioAvailable();
|
||||
for (const record of controls) {
|
||||
if (!record.button.isConnected) { controls.delete(record); continue; }
|
||||
record.button.hidden = !available;
|
||||
const control = record.button.closest(".gsm-hoshidicts-audio-control");
|
||||
if (control) control.hidden = !available;
|
||||
}
|
||||
}
|
||||
|
||||
function firstVisit(record) {
|
||||
if (!record.request) return false;
|
||||
let keys = visited.get(record.request);
|
||||
if (!keys) { keys = new Set(); visited.set(record.request, keys); }
|
||||
if (keys.has(record.autoplayKey)) return false;
|
||||
keys.add(record.autoplayKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
// A retired hold keeps its visit: the same request may bind again and its
|
||||
// reveal still settles it, while a request that is gone has no key to
|
||||
// spend. A manual play consumes every waiting result.
|
||||
function cancelAutoplay(owner, consumeHeld = true) {
|
||||
for (const [key, record] of pendingAutoplay) {
|
||||
if (owner !== undefined && key !== owner) continue;
|
||||
if (consumeHeld || !record.autoplayHeld?.()) firstVisit(record);
|
||||
pendingAutoplay.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function autoplay(record) {
|
||||
if (!current(record)) return;
|
||||
if (!optionsReady || record.autoplayHeld?.()) {
|
||||
const previous = pendingAutoplay.get(record.owner);
|
||||
if (previous && previous.autoplayKey !== record.autoplayKey) firstVisit(previous);
|
||||
pendingAutoplay.set(record.owner, record);
|
||||
return;
|
||||
}
|
||||
if (firstVisit(record) && options.audioAutoplay && audioAvailable()) void play(record);
|
||||
}
|
||||
|
||||
function owns(operation) {
|
||||
return active === operation && current(operation.record);
|
||||
}
|
||||
|
||||
function setStatus(record, text) {
|
||||
if (text) record.button.dataset.state = "error";
|
||||
let output = feedback.get(record.button) || record.status;
|
||||
if (!output && !text) return;
|
||||
if (!output) {
|
||||
output = document.createElement("output");
|
||||
output.className = "gsm-hoshidicts-audio-status";
|
||||
output.setAttribute("aria-live", "polite");
|
||||
record.button.after(output);
|
||||
}
|
||||
feedback.set(record.button, output);
|
||||
output.textContent = text;
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (!active) return;
|
||||
const previous = active;
|
||||
active = null;
|
||||
setBusy(previous.record, false);
|
||||
setStatus(previous.record, "");
|
||||
void send("hd_audio_stop", { playRequestId: previous.requestId }).catch(() => {});
|
||||
}
|
||||
|
||||
function closeMenu(restoreFocus = true) {
|
||||
if (!menu) return false;
|
||||
const previous = menu;
|
||||
menu = null;
|
||||
if (active?.type === "hd_audio_candidates" && active.record === previous.record) stop();
|
||||
previous.element.remove();
|
||||
previous.record.button.setAttribute("aria-expanded", "false");
|
||||
if (restoreFocus && current(previous.record)) previous.record.button.focus({ preventScroll: true });
|
||||
onMenuChange(previous.record.owner);
|
||||
return true;
|
||||
}
|
||||
|
||||
function retire(owner) {
|
||||
cancelAutoplay(owner, false);
|
||||
if (menu && (owner === undefined || menu.record.owner === owner)) closeMenu(false);
|
||||
if (active && (owner === undefined || active.record.owner === owner)) stop();
|
||||
}
|
||||
|
||||
async function request(record, type, fields, accept) {
|
||||
cancelAutoplay();
|
||||
firstVisit(record);
|
||||
stop();
|
||||
if (!current(record) || !audioAvailable()) return;
|
||||
const operation = { record, type, requestId: window.crypto.randomUUID() };
|
||||
active = operation;
|
||||
setBusy(record, true);
|
||||
setStatus(record, "");
|
||||
try {
|
||||
const reply = await send(type, { term: record.term, requestId: operation.requestId, ...fields });
|
||||
if (!owns(operation)) return;
|
||||
if (!reply.ok) throw new Error(reply.error);
|
||||
accept(reply);
|
||||
} catch (error) {
|
||||
if (owns(operation)) {
|
||||
if (type === "hd_audio_play" && selections.get(record.result) === fields.selection) {
|
||||
selections.delete(record.result);
|
||||
onSelectionChange(record.owner);
|
||||
}
|
||||
setStatus(record, `Could not play: ${error.message}`);
|
||||
if (menu?.record === record) menu.output.textContent = error.message;
|
||||
}
|
||||
} finally {
|
||||
if (active === operation) { active = null; setBusy(record, false); }
|
||||
}
|
||||
}
|
||||
|
||||
function play(record, selection = selections.get(record.result)) {
|
||||
closeMenu();
|
||||
return request(record, "hd_audio_play", selection ? { selection } : {}, reply => {
|
||||
setStatus(record, reply.status === "no-result" ? "No pronunciation was returned. Check Audio Settings." : "");
|
||||
// The pronunciation the user just heard is the one Add to Anki should
|
||||
// attach, so a downloadable recording becomes the selection exactly as
|
||||
// a menu choice would. Browser speech has no recording to pin.
|
||||
if (!selection && reply.status === "success" && typeof reply.sourceKey === "string"
|
||||
&& typeof reply.candidate?.url === "string") {
|
||||
selections.set(record.result, { sourceId: reply.sourceId, sourceKey: reply.sourceKey, ...record.term,
|
||||
index: reply.candidate.index, url: reply.candidate.url, name: reply.candidate.name });
|
||||
onSelectionChange(record.owner);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function choices(record) {
|
||||
closeMenu(false);
|
||||
if (!current(record) || !audioAvailable()) return;
|
||||
const element = document.createElement("section");
|
||||
element.className = "gsm-hoshidicts-audio-menu gsm-hoshidicts-audio-choices";
|
||||
element.setAttribute("role", "dialog");
|
||||
element.setAttribute("aria-label", `Pronunciation for ${record.term.expression}`);
|
||||
const heading = document.createElement("strong");
|
||||
heading.className = "gsm-hoshidicts-audio-menu-heading";
|
||||
heading.textContent = `Pronunciation · ${record.term.expression}`;
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "gsm-hoshidicts-audio-menu-item gsm-hoshidicts-audio-menu-close";
|
||||
close.textContent = "Close";
|
||||
close.addEventListener("click", () => closeMenu());
|
||||
const output = document.createElement("p");
|
||||
output.className = "gsm-hoshidicts-audio-menu-status";
|
||||
output.setAttribute("role", "status");
|
||||
output.textContent = "Finding choices…";
|
||||
element.append(heading, close, output);
|
||||
record.popup.append(element);
|
||||
menu = { element, output, record };
|
||||
record.button.setAttribute("aria-expanded", "true");
|
||||
onMenuChange(record.owner);
|
||||
close.focus({ preventScroll: true });
|
||||
void request(record, "hd_audio_candidates", {}, reply => {
|
||||
let count = 0;
|
||||
for (const [sourceIndex, group] of reply.groups.entries()) {
|
||||
const section = document.createElement("div");
|
||||
const title = document.createElement("h4");
|
||||
title.className = "gsm-hoshidicts-audio-menu-heading";
|
||||
title.textContent = `${sourceIndex + 1}. ${window.HDReaderOptions.AUDIO_SOURCE_LABELS[group.type]}`;
|
||||
section.append(title);
|
||||
if (group.error) {
|
||||
const error = document.createElement("p");
|
||||
error.textContent = group.error;
|
||||
section.append(error);
|
||||
}
|
||||
for (const [index, candidate] of (group.candidates || []).entries()) {
|
||||
count += 1;
|
||||
const button = document.createElement("button");
|
||||
button.className = "gsm-hoshidicts-audio-menu-item";
|
||||
button.type = "button";
|
||||
button.textContent = candidate.name || `Pronunciation ${index + 1}`;
|
||||
button.addEventListener("click", () => {
|
||||
const selection = { sourceId: group.sourceId, sourceKey: group.sourceKey, ...record.term,
|
||||
index, url: candidate.url ?? null, name: candidate.name };
|
||||
selections.set(record.result, selection);
|
||||
onSelectionChange(record.owner);
|
||||
void play(record, selection);
|
||||
});
|
||||
section.append(button);
|
||||
}
|
||||
element.append(section);
|
||||
}
|
||||
setStatus(record, "");
|
||||
output.textContent = count ? "Choose a pronunciation to play." : "No pronunciations found. Check Audio Settings.";
|
||||
onMenuChange(record.owner);
|
||||
});
|
||||
}
|
||||
|
||||
function bind(items, context) {
|
||||
if (active?.record.owner === context.owner && !current(active.record)) stop();
|
||||
if (menu?.record.owner === context.owner && !current(menu.record)) closeMenu(false);
|
||||
const first = items[0];
|
||||
if (!first) return;
|
||||
const autoplayKey = JSON.stringify([context.request?.selectedDictionaryTab, first.result.term.expression, first.result.term.reading]);
|
||||
for (const item of items) {
|
||||
if (bound.has(item.button)) { bound.get(item.button).autoplayKey = autoplayKey; continue; }
|
||||
const record = { ...item, ...context, autoplayKey,
|
||||
term: { expression: item.result.term.expression, reading: item.result.term.reading || "" } };
|
||||
bound.set(item.button, record);
|
||||
controls.add(record);
|
||||
item.button.addEventListener("click", event => {
|
||||
if (event.shiftKey) choices(record);
|
||||
else if (active?.record.button === item.button) stop();
|
||||
else void play(record);
|
||||
});
|
||||
item.button.addEventListener("contextmenu", event => { event.preventDefault(); choices(record); });
|
||||
item.button.addEventListener("keydown", event => {
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); choices(record); }
|
||||
});
|
||||
}
|
||||
updateControls();
|
||||
autoplay(bound.get(first.button));
|
||||
}
|
||||
|
||||
// Keybinds play the entry's pronunciation even while it is already playing,
|
||||
// which a click would stop. A source ID plays that source's first choice.
|
||||
function playButton(button, sourceId = "") {
|
||||
const record = bound.get(button);
|
||||
if (!record || !current(record) || !audioAvailable()) return false;
|
||||
if (!sourceId) {
|
||||
void play(record);
|
||||
return true;
|
||||
}
|
||||
void request(record, "hd_audio_candidates", {}, reply => {
|
||||
const group = reply.groups.find(item => item.sourceId === sourceId);
|
||||
const candidate = group?.candidates?.[0];
|
||||
if (!candidate) {
|
||||
setStatus(record, "No pronunciation was returned. Check Audio Settings.");
|
||||
return;
|
||||
}
|
||||
void play(record, { sourceId: group.sourceId, sourceKey: group.sourceKey, ...record.term,
|
||||
index: 0, url: candidate.url ?? null, name: candidate.name });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const listener = message => {
|
||||
if (!active || message?.target !== "hachidori-audio-content" || message.type !== "hd_audio_playing"
|
||||
|| message.requestId !== active?.requestId || !current(active.record)) return;
|
||||
active.record.button.dataset.state = "playing";
|
||||
};
|
||||
window.chrome.runtime.onMessage.addListener(listener);
|
||||
// Chrome 128 can tear down the content owner before content.js receives
|
||||
// pagehide. Listen here too so the offscreen player is stopped while this
|
||||
// document can still identify its owned request.
|
||||
const onPageHide = () => retire();
|
||||
window.addEventListener("pagehide", onPageHide);
|
||||
return {
|
||||
bind, retire, closeMenu, playButton,
|
||||
hasMenu: owner => Boolean(menu && (owner === undefined || menu.record.owner === owner)),
|
||||
selectionFor: result => selections.get(result) ?? null,
|
||||
// Releases the owner's held first result for exactly this request once its
|
||||
// definitions are revealed. A stale request's late reveal leaves a newer view alone.
|
||||
settleAutoplay(owner, request) {
|
||||
const record = pendingAutoplay.get(owner);
|
||||
if (!record || record.request !== request) return;
|
||||
pendingAutoplay.delete(owner);
|
||||
autoplay(record);
|
||||
},
|
||||
update(next, ready = true) {
|
||||
const waiting = !optionsReady && ready ? [...pendingAutoplay] : [];
|
||||
for (const [owner] of waiting) pendingAutoplay.delete(owner);
|
||||
const nextKey = JSON.stringify(next.audioSources);
|
||||
if (nextKey !== sourceKey) { retire(); selections = new WeakMap(); }
|
||||
else if (options.audioAutoplay && !next.audioAutoplay) retire();
|
||||
sourceKey = nextKey;
|
||||
options = next;
|
||||
optionsReady = ready;
|
||||
updateControls();
|
||||
for (const [owner, record] of waiting) {
|
||||
pendingAutoplay.set(owner, record);
|
||||
// Adopt the complete storage event, including lookup invalidation,
|
||||
// before retrying a first result that rendered with unknown options.
|
||||
window.queueMicrotask(() => {
|
||||
if (pendingAutoplay.get(owner) !== record) return;
|
||||
pendingAutoplay.delete(owner);
|
||||
autoplay(record);
|
||||
});
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
retire();
|
||||
controls.clear();
|
||||
window.removeEventListener("pagehide", onPageHide);
|
||||
window.chrome.runtime.onMessage.removeListener(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
globalThis.HDAudio = { createAudioController };
|
||||
}());
|
||||
@@ -0,0 +1,114 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { selectExtensionApi } from "./browser-api.js";
|
||||
import { createAudioPlayer } from "./audio-player.js";
|
||||
import { createAudioRepository, selectedAudioPlan } from "./audio-repository.js";
|
||||
|
||||
const TEST_TERM = { expression: "聞く", reading: "きく" };
|
||||
// Matches the reference Settings Test deadline; ordinary dictionary work never
|
||||
// waits on this timer or the pronunciation's network/audio callbacks.
|
||||
const TEST_TIMEOUT_MS = 15_000;
|
||||
const FALLBACK_TIMEOUT_MS = 12_000;
|
||||
|
||||
// Identify the played source the way the candidate menu does, so the reader
|
||||
// can pin the pronunciation it just played as its Anki selection.
|
||||
function withSourceKey(played, sources) {
|
||||
if (played.status !== "success") return played;
|
||||
const source = sources.find(candidate => candidate.id === played.sourceId);
|
||||
return { ...played, sourceKey: JSON.stringify(source) };
|
||||
}
|
||||
|
||||
export function createAudioService(window, repository = createAudioRepository({ window, fetch: window.fetch.bind(window), now: () => window.performance.now() })) {
|
||||
const extensionApi = selectExtensionApi(window);
|
||||
const player = createAudioPlayer({ window, repository });
|
||||
let active = null;
|
||||
let watchingVoices = false;
|
||||
|
||||
function stop(reason = new DOMException("Playback stopped.", "AbortError")) {
|
||||
active?.controller.abort(reason);
|
||||
player.stop(reason);
|
||||
}
|
||||
window.addEventListener("pagehide", () => { stop(); player.dispose(); }, { once: true });
|
||||
|
||||
function voices() {
|
||||
if (!watchingVoices) {
|
||||
window.speechSynthesis.addEventListener("voiceschanged", () => {
|
||||
extensionApi.runtime.sendMessage({ target: "hachidori-audio-ui", type: "hd_audio_voices_changed", voices: voices() })
|
||||
.catch(() => {}); // The Settings page may already have closed.
|
||||
});
|
||||
watchingVoices = true;
|
||||
}
|
||||
return window.speechSynthesis.getVoices().map(({ voiceURI, name, lang, localService, default: isDefault }) =>
|
||||
({ voiceURI, name, lang, localService, default: isDefault }));
|
||||
}
|
||||
|
||||
async function candidateGroups(sources, term, signal) {
|
||||
const groups = [];
|
||||
for (const source of sources) {
|
||||
const group = { sourceId: source.id, sourceKey: JSON.stringify(source), type: source.type };
|
||||
try {
|
||||
group.candidates = await repository.candidates(source, term, signal);
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
group.error = error.message;
|
||||
}
|
||||
groups.push(group);
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
return { groups };
|
||||
}
|
||||
|
||||
return async message => {
|
||||
if (message.type === "hd_audio_voices") return { voices: voices() };
|
||||
if (message.type === "hd_audio_stop") {
|
||||
if (active && active.owner === message.owner && active.requestId === message.playRequestId) stop();
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
if (!["hd_audio_test", "hd_audio_play", "hd_audio_candidates"].includes(message.type)) throw new Error("Unknown audio request.");
|
||||
stop();
|
||||
const operation = { owner: message.owner, requestId: message.requestId, controller: new AbortController() };
|
||||
active = operation;
|
||||
const isTest = message.type === "hd_audio_test";
|
||||
let remaining = isTest ? TEST_TIMEOUT_MS : FALLBACK_TIMEOUT_MS;
|
||||
let timer = null, armedAt;
|
||||
function resumeDeadline() {
|
||||
if (timer !== null) return;
|
||||
armedAt = window.performance.now();
|
||||
timer = window.setTimeout(() => {
|
||||
if (active === operation) stop(new Error(isTest ? "Audio Test timed out after 15 seconds." : "Pronunciation discovery timed out after 12 seconds."));
|
||||
}, remaining);
|
||||
}
|
||||
function pauseDeadline() {
|
||||
if (timer === null) return;
|
||||
window.clearTimeout(timer);
|
||||
timer = null;
|
||||
remaining = Math.max(0, remaining - (window.performance.now() - armedAt));
|
||||
}
|
||||
resumeDeadline();
|
||||
try {
|
||||
if (isTest) return await player.play(message.source, TEST_TERM);
|
||||
const { signal } = operation.controller;
|
||||
if (message.type === "hd_audio_candidates") return await candidateGroups(message.sources, message.term, signal);
|
||||
const plan = message.selection
|
||||
? await selectedAudioPlan(repository, message.sources, message.term, message.selection, signal) : { sources: message.sources };
|
||||
signal.throwIfAborted();
|
||||
const played = await player.playSources(plan.sources, message.term, { candidate: plan.candidate,
|
||||
onResolving: resumeDeadline,
|
||||
onPlaying(value) {
|
||||
if (active !== operation) return;
|
||||
// This bounds discovery/fallback, not the duration of a playable file.
|
||||
pauseDeadline();
|
||||
extensionApi.runtime.sendMessage({ target: "hachidori-audio-events", type: "hd_audio_playing",
|
||||
owner: operation.owner, requestId: operation.requestId, ...value }).catch(() => {});
|
||||
},
|
||||
});
|
||||
return withSourceKey(played, plan.sources);
|
||||
} catch (error) {
|
||||
if (operation.controller.signal.aborted && error?.name === "AbortError") return { status: "cancelled" };
|
||||
throw error;
|
||||
}
|
||||
finally {
|
||||
window.clearTimeout(timer);
|
||||
if (active === operation) active = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { createAudioRepository } from "./audio-repository.js";
|
||||
import { resolveSpeech } from "./speech.js";
|
||||
|
||||
// One pronunciation owner; playback leases and native speech callbacks belong
|
||||
// to that operation. The shared offscreen repository owns warm media, not WASM.
|
||||
export function createAudioPlayer({ window, fetch, repository = createAudioRepository({ window, fetch }) }) {
|
||||
let current = null;
|
||||
|
||||
function stop(reason = new DOMException("Playback stopped.", "AbortError")) {
|
||||
current?.abort(reason);
|
||||
current = null;
|
||||
}
|
||||
|
||||
async function playUrl(candidate, signal, onPlaying) {
|
||||
const lease = await repository.acquire(candidate, signal);
|
||||
let audio;
|
||||
let abort;
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
audio = new window.Audio(lease.url);
|
||||
const ended = new Promise((resolve, reject) => {
|
||||
audio.onended = resolve;
|
||||
audio.onplaying = () => { if (!signal.aborted) onPlaying?.(candidate); };
|
||||
audio.onerror = () => {
|
||||
lease.invalidate();
|
||||
reject(new Error("The pronunciation could not be decoded or played."));
|
||||
};
|
||||
abort = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
});
|
||||
await Promise.all([Promise.resolve().then(() => audio.play()), ended]);
|
||||
} finally {
|
||||
if (audio) {
|
||||
signal.removeEventListener("abort", abort);
|
||||
audio.onended = audio.onerror = audio.onplaying = null;
|
||||
audio.pause();
|
||||
audio.removeAttribute("src");
|
||||
audio.load();
|
||||
}
|
||||
lease.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function playSpeech(source, term, signal, onPlaying) {
|
||||
const { speech, utterance, candidate } = await resolveSpeech(window, source, term, signal);
|
||||
signal.throwIfAborted();
|
||||
let abort;
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
utterance.onend = resolve;
|
||||
utterance.onstart = () => { if (!signal.aborted) onPlaying?.(candidate); };
|
||||
utterance.onerror = event => {
|
||||
const detail = event.error ? ` (${event.error})` : "";
|
||||
reject(new Error(`Text-to-speech could not be played${detail}.`));
|
||||
};
|
||||
abort = () => {
|
||||
// Cancel synchronously, before a newer operation can speak. A late
|
||||
// finally calling global speech.cancel() would stop that new voice.
|
||||
utterance.onend = utterance.onerror = utterance.onstart = null;
|
||||
speech.cancel();
|
||||
reject(signal.reason);
|
||||
};
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
speech.speak(utterance);
|
||||
});
|
||||
return candidate;
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abort);
|
||||
utterance.onend = utterance.onerror = utterance.onstart = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function firstPlayable(found, signal, onPlaying, onResolving) {
|
||||
let failure;
|
||||
for (const [index, entry] of found.entries()) {
|
||||
try {
|
||||
onResolving?.();
|
||||
const candidate = { ...entry, index: entry.index ?? index };
|
||||
await playUrl(candidate, signal, onPlaying);
|
||||
return candidate;
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
failure = error;
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
|
||||
async function playSources(sources, term, { onPlaying, onResolving, candidate: selectedCandidate } = {}) {
|
||||
stop();
|
||||
const controller = new AbortController();
|
||||
current = controller;
|
||||
const { signal } = controller;
|
||||
try {
|
||||
let failure;
|
||||
for (const source of sources) {
|
||||
try {
|
||||
onResolving?.();
|
||||
const playing = candidate => onPlaying?.({ sourceId: source.id, candidate });
|
||||
const candidate = await playSource(source, term, signal, playing, onResolving, selectedCandidate);
|
||||
signal.throwIfAborted();
|
||||
if (candidate) return { status: "success", sourceId: source.id, candidate };
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
failure = error;
|
||||
}
|
||||
}
|
||||
if (failure) throw failure;
|
||||
return { status: "no-result" };
|
||||
} catch (error) {
|
||||
if (signal.aborted && signal.reason?.name === "AbortError") return { status: "cancelled" };
|
||||
throw signal.aborted ? signal.reason : error;
|
||||
} finally {
|
||||
if (current === controller) current = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function playSource(source, term, signal, onPlaying, onResolving, selectedCandidate) {
|
||||
if (source.type.startsWith("text-to-speech")) return playSpeech(source, term, signal, onPlaying);
|
||||
const found = selectedCandidate ? [selectedCandidate] : await repository.candidates(source, term, signal);
|
||||
signal.throwIfAborted();
|
||||
return found.length ? firstPlayable(found, signal, onPlaying, onResolving) : null;
|
||||
}
|
||||
|
||||
return {
|
||||
stop, playSources,
|
||||
play: (source, term) => playSources([source], term),
|
||||
dispose() { stop(); repository.clear(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { createAudioCache } from "./audio-cache.js";
|
||||
import { audioSourceUrl, parseAudioSourceList } from "./audio-sources.js";
|
||||
|
||||
export async function selectedAudioPlan(repository, sources, term, selection, signal) {
|
||||
const source = sources.find(source => source.id === selection.sourceId && JSON.stringify(source) === selection.sourceKey);
|
||||
if (!source || selection.expression !== term.expression || selection.reading !== term.reading) {
|
||||
throw new Error("This pronunciation selection is no longer current. Choose it again.");
|
||||
}
|
||||
const candidates = await repository.candidates(source, term, signal);
|
||||
const candidate = candidates[selection.index];
|
||||
if (!candidate || (candidate.url ?? null) !== selection.url || candidate.name !== selection.name) {
|
||||
throw new Error("The provider's pronunciation choices changed. Choose again.");
|
||||
}
|
||||
return { sources: [source], candidate: { ...candidate, index: selection.index } };
|
||||
}
|
||||
|
||||
export function createAudioRepository({ window, fetch, now = () => performance.now() }) {
|
||||
// GSM PR #549's retention budgets, not input or playback size limits.
|
||||
const candidates = createAudioCache({ maxEntries: 256, maxBytes: 2 * 1024 * 1024, ttlMs: 5 * 60_000, now });
|
||||
const media = createAudioCache({ maxEntries: 64, maxBytes: 64 * 1024 * 1024, ttlMs: 30 * 60_000, now,
|
||||
onEvict(entry) { entry.retained = false; releaseUnused(entry); } });
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function releaseUnused(entry) {
|
||||
if (!entry.retained && entry.users === 0) window.URL.revokeObjectURL(entry.url);
|
||||
}
|
||||
|
||||
async function response(url, signal) {
|
||||
const result = await fetch(url, { credentials: "omit", signal });
|
||||
if (!result.ok) throw new Error(`Audio provider returned HTTP ${result.status}.`);
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
async candidates(source, term, signal) {
|
||||
signal.throwIfAborted();
|
||||
const key = JSON.stringify([source, term.expression, term.reading]);
|
||||
const cached = candidates.get(key);
|
||||
if (cached) return cached;
|
||||
let found = [];
|
||||
if (source.type.startsWith("text-to-speech")) found = [{ name: source.voice || "Automatic Japanese" }];
|
||||
else if (source.url) {
|
||||
const url = audioSourceUrl(source.url, term);
|
||||
if (source.type === "custom") found = [{ url, name: "" }];
|
||||
else found = parseAudioSourceList(await (await response(url, signal)).json());
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
candidates.set(key, found, encoder.encode(key).byteLength + encoder.encode(JSON.stringify(found)).byteLength);
|
||||
return found;
|
||||
},
|
||||
async acquire(candidate, signal) {
|
||||
signal.throwIfAborted();
|
||||
let entry = media.get(candidate.url);
|
||||
if (entry) entry.users += 1;
|
||||
else {
|
||||
const blob = await (await response(candidate.url, signal)).blob();
|
||||
signal.throwIfAborted();
|
||||
entry = { blob, url: window.URL.createObjectURL(blob), users: 1, retained: false };
|
||||
entry.retained = media.set(candidate.url, entry, blob.size + encoder.encode(candidate.url).byteLength);
|
||||
}
|
||||
return {
|
||||
url: entry.url,
|
||||
blob: entry.blob,
|
||||
release() { entry.users -= 1; releaseUnused(entry); },
|
||||
invalidate() { if (media.get(candidate.url) === entry) media.delete(candidate.url); },
|
||||
};
|
||||
},
|
||||
clear() { candidates.clear(); media.clear(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { selectExtensionApi } from "./browser-api.js";
|
||||
import { reorderSettingsRows } from "./settings-dom.js";
|
||||
function labelControl(control, label) {
|
||||
if (control.getAttribute("aria-label") !== label) control.setAttribute("aria-label", label);
|
||||
}
|
||||
|
||||
function setTesting(row, testing) {
|
||||
const text = testing ? "Stop" : "Test";
|
||||
if (row.test.textContent !== text) row.test.textContent = text;
|
||||
labelControl(row.test, `${testing ? "Stop Test" : "Test 聞く / きく"}: ${row.number.textContent.toLowerCase()}`);
|
||||
}
|
||||
|
||||
export function createAudioSettingsController({ document, readSources, editSources, send }) {
|
||||
const window = document.defaultView;
|
||||
const extensionApi = selectExtensionApi(window);
|
||||
const list = document.getElementById("audio-source-list");
|
||||
const rows = new Map();
|
||||
const labels = window.HDReaderOptions.AUDIO_SOURCE_LABELS;
|
||||
let voices = [];
|
||||
let voiceVersion = 0;
|
||||
let active = null;
|
||||
|
||||
function stop() {
|
||||
if (!active) return;
|
||||
const previous = active;
|
||||
active = null;
|
||||
previous.row.status.textContent = "";
|
||||
setTesting(previous.row, false);
|
||||
void send("hd_audio_stop", { playRequestId: previous.requestId }).catch(() => {});
|
||||
}
|
||||
|
||||
function change(id, patch) {
|
||||
editSources(readSources().map(source => source.id === id ? { ...source, ...patch } : source));
|
||||
render();
|
||||
}
|
||||
|
||||
async function testSource(id, row) {
|
||||
if (active?.id === id) { stop(); return; }
|
||||
stop();
|
||||
const source = readSources().find(source => source.id === id);
|
||||
const operation = { id, row, source: JSON.stringify(source), requestId: window.crypto.randomUUID() };
|
||||
row.testedSource = operation.source;
|
||||
active = operation;
|
||||
row.status.textContent = "";
|
||||
setTesting(row, true);
|
||||
try {
|
||||
const reply = await send("hd_audio_test", { source, requestId: operation.requestId });
|
||||
if (active !== operation) return;
|
||||
if (!reply.ok) throw new Error(reply.error);
|
||||
row.status.textContent = reply.status === "no-result" ? "No pronunciation was returned." : "";
|
||||
} catch (error) {
|
||||
if (active === operation) row.status.textContent = `Could not play: ${error.message}`;
|
||||
} finally {
|
||||
if (active === operation) { active = null; setTesting(row, false); }
|
||||
}
|
||||
}
|
||||
|
||||
function move(id, offset) {
|
||||
const sources = [...readSources()];
|
||||
const index = sources.findIndex(source => source.id === id);
|
||||
[sources[index], sources[index + offset]] = [sources[index + offset], sources[index]];
|
||||
editSources(sources);
|
||||
render();
|
||||
}
|
||||
|
||||
function createRow(source) {
|
||||
const element = document.createElement("li");
|
||||
element.className = "audio-source-row";
|
||||
// Only static markup. Provider names and user text are assigned as text or
|
||||
// control values below, never interpolated into HTML.
|
||||
element.innerHTML = `<div class="audio-source-heading">
|
||||
<label class="audio-source-enabled"><input class="audio-enabled" type="checkbox"><span class="audio-number"></span></label>
|
||||
<div class="audio-source-actions"><button type="button" class="ghost audio-up"><span class="hd-icon" data-icon="arrow-up" aria-hidden="true"></span></button><button type="button" class="ghost audio-down"><span class="hd-icon" data-icon="arrow-down" aria-hidden="true"></span></button>
|
||||
<button type="button" class="ghost audio-remove">Remove</button></div></div>
|
||||
<label class="field"><span class="field-label">Source type</span><select class="audio-type"></select></label>
|
||||
<label class="field audio-url-field"><span class="field-label">URL template</span><input class="audio-url" type="text" spellcheck="false" autocomplete="off"></label>
|
||||
<label class="field audio-voice-field"><span class="field-label">Voice</span><select class="audio-voice"></select></label>
|
||||
<div class="audio-test-line"><button type="button" class="ghost audio-test">Test</button><output class="audio-test-status" aria-live="polite"></output></div>`;
|
||||
const row = { element, voiceVersion: -1 };
|
||||
for (const name of ["enabled", "number", "type", "url", "voice", "up", "down", "remove", "test"]) {
|
||||
row[name] = element.querySelector(`.audio-${name}`);
|
||||
}
|
||||
row.status = element.querySelector(".audio-test-status");
|
||||
row.urlField = element.querySelector(".audio-url-field");
|
||||
row.voiceField = element.querySelector(".audio-voice-field");
|
||||
for (const type of window.HDReaderOptions.AUDIO_SOURCE_TYPES) row.type.add(new window.Option(labels[type], type));
|
||||
for (const name of ["enabled", "type", "url", "voice"]) row[name].id = `opt-audio-${name}-${source.id}`;
|
||||
row.enabled.addEventListener("change", () => change(source.id, { enabled: row.enabled.checked }));
|
||||
row.type.addEventListener("change", () => change(source.id, { type: row.type.value, url: "", voice: "" }));
|
||||
row.url.addEventListener("input", () => change(source.id, { url: row.url.value }));
|
||||
row.voice.addEventListener("change", () => change(source.id, { voice: row.voice.value }));
|
||||
row.up.addEventListener("click", () => move(source.id, -1));
|
||||
row.down.addEventListener("click", () => move(source.id, 1));
|
||||
row.remove.addEventListener("click", () => {
|
||||
editSources(readSources().filter(item => item.id !== source.id));
|
||||
render();
|
||||
document.getElementById("audio-source-add").focus();
|
||||
});
|
||||
row.test.addEventListener("click", () => { void testSource(source.id, row); });
|
||||
element.addEventListener("focusout", () => { window.queueMicrotask(render); });
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderVoice(row, source) {
|
||||
if (row.voice === document.activeElement) return;
|
||||
if (row.voiceVersion !== voiceVersion || row.voice.value !== source.voice) {
|
||||
row.voice.replaceChildren(new window.Option("Automatic Japanese", ""));
|
||||
for (const voice of voices) row.voice.add(new window.Option(
|
||||
`${voice.name} (${voice.lang})${voice.localService ? "" : " — online"}`, voice.voiceURI));
|
||||
if (source.voice && !voices.some(voice => voice.voiceURI === source.voice)) {
|
||||
row.voice.add(new window.Option(`${source.voice} — unavailable`, source.voice));
|
||||
}
|
||||
row.voice.value = source.voice;
|
||||
row.voiceVersion = voiceVersion;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const sources = readSources();
|
||||
if (active && active.source !== JSON.stringify(sources.find(source => source.id === active.id))) stop();
|
||||
const ids = new Set(sources.map(source => source.id));
|
||||
for (const [id, row] of rows) {
|
||||
if (!ids.has(id)) { row.element.remove(); rows.delete(id); }
|
||||
}
|
||||
const ordered = sources.map((source, index) => {
|
||||
if (!rows.has(source.id)) rows.set(source.id, createRow(source));
|
||||
const row = rows.get(source.id);
|
||||
if (row.testedSource && row.testedSource !== JSON.stringify(source)) {
|
||||
row.status.textContent = "";
|
||||
row.testedSource = null;
|
||||
}
|
||||
if (row.enabled.checked !== source.enabled) row.enabled.checked = source.enabled;
|
||||
const number = `Source ${index + 1}`;
|
||||
if (row.number.textContent !== number) row.number.textContent = number;
|
||||
for (const key of ["type", "url"]) {
|
||||
if (row[key] !== document.activeElement && row[key].value !== source[key]) row[key].value = source[key];
|
||||
}
|
||||
const speech = source.type.startsWith("text-to-speech");
|
||||
if (row.urlField.hidden !== speech) row.urlField.hidden = speech;
|
||||
if (row.voiceField.hidden === speech) row.voiceField.hidden = !speech;
|
||||
if (speech) renderVoice(row, source);
|
||||
if (row.up.disabled !== (index === 0)) row.up.disabled = index === 0;
|
||||
if (row.down.disabled !== (index === sources.length - 1)) row.down.disabled = index === sources.length - 1;
|
||||
for (const [key, label] of [["up", "Move up"], ["down", "Move down"], ["remove", "Remove"]]) {
|
||||
labelControl(row[key], `${label}: source ${index + 1}`);
|
||||
}
|
||||
setTesting(row, active?.id === source.id);
|
||||
return row.element;
|
||||
});
|
||||
reorderSettingsRows(list, ordered);
|
||||
const empty = document.getElementById("audio-source-empty");
|
||||
if (empty.hidden !== (sources.length > 0)) empty.hidden = sources.length > 0;
|
||||
}
|
||||
|
||||
function adoptVoices(value) {
|
||||
voices = [...value].sort((a, b) => Number(/^ja(?:[-_]|$)/i.test(b.lang)) - Number(/^ja(?:[-_]|$)/i.test(a.lang)));
|
||||
voiceVersion += 1;
|
||||
render();
|
||||
}
|
||||
const voiceListener = message => {
|
||||
if (message?.target === "hachidori-audio-ui" && message.type === "hd_audio_voices_changed") adoptVoices(message.voices);
|
||||
};
|
||||
extensionApi.runtime.onMessage.addListener(voiceListener);
|
||||
const initialVersion = voiceVersion;
|
||||
void send("hd_audio_voices").then(reply => {
|
||||
if (reply.ok && voiceVersion === initialVersion) adoptVoices(reply.voices);
|
||||
}).catch(() => {}); // Test reports any current playback/voice failure itself.
|
||||
document.getElementById("audio-source-add").addEventListener("click", () => {
|
||||
const source = { id: window.crypto.randomUUID(), type: "custom", enabled: true, url: "", voice: "" };
|
||||
editSources([...readSources(), source]);
|
||||
render();
|
||||
rows.get(source.id).url.focus();
|
||||
});
|
||||
window.addEventListener("pagehide", () => { stop(); extensionApi.runtime.onMessage.removeListener(voiceListener); }, { once: true });
|
||||
return { render, stop };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Browser-native source rules adapted from GSM PR #549's hoshidicts_audio.py
|
||||
// and hoshidicts_audio_profile.py. Configuration remains global reader options.
|
||||
|
||||
function httpUrl(value) {
|
||||
if (typeof value !== "string" || /[\u0000-\u001f]/u.test(value)) {
|
||||
throw new Error("Audio URLs must be absolute HTTP(S) URLs.");
|
||||
}
|
||||
const url = new URL(value);
|
||||
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.port === "0") {
|
||||
throw new Error("Audio URLs must use HTTP(S) without a username or password.");
|
||||
}
|
||||
if (/[{}]/u.test(url.host)) throw new Error("Audio URL placeholders cannot appear in the host.");
|
||||
return url.href;
|
||||
}
|
||||
|
||||
function encodeValue(value) {
|
||||
return encodeURIComponent(value).replace(/[!'()*]/gu, char => `%${char.codePointAt(0).toString(16).toUpperCase()}`);
|
||||
}
|
||||
|
||||
export function audioSourceUrl(template, { expression, reading }) {
|
||||
httpUrl(template);
|
||||
const encodedTerm = encodeValue(expression);
|
||||
const values = { term: encodedTerm, expression: encodedTerm, reading: encodeValue(reading), language: "ja" };
|
||||
return httpUrl(template.replace(/\{([^{}]*)\}/gu, (match, key) => Object.hasOwn(values, key) ? values[key] : match));
|
||||
}
|
||||
|
||||
export function parseAudioSourceList(value) {
|
||||
if (value?.type !== "audioSourceList" || !Array.isArray(value.audioSources)
|
||||
|| Object.keys(value).some(key => !["type", "audioSources"].includes(key))) {
|
||||
throw new Error("The audio provider returned an invalid Yomitan audioSourceList.");
|
||||
}
|
||||
return value.audioSources.map(candidate => {
|
||||
if (!candidate || typeof candidate.url !== "string" || (candidate.name !== undefined && typeof candidate.name !== "string")
|
||||
|| Object.keys(candidate).some(key => !["url", "name"].includes(key))) {
|
||||
throw new Error("The audio provider returned an invalid pronunciation candidate.");
|
||||
}
|
||||
return { url: httpUrl(candidate.url), name: candidate.name ?? "" };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import {
|
||||
CAPTURE_SAMPLE_RATE,
|
||||
MAX_ANIMATED_AVIF_BYTES,
|
||||
} from "./media-limits.js";
|
||||
|
||||
export { MAX_ANIMATED_AVIF_BYTES };
|
||||
export const AVIF_TIMESCALE = CAPTURE_SAMPLE_RATE;
|
||||
|
||||
function encoderError(module, handle) {
|
||||
return module.UTF8ToString(module._hda_last_error(handle)) || "Animated AVIF encoding failed.";
|
||||
}
|
||||
|
||||
export function frameDurations(frames, endMs, timescale = AVIF_TIMESCALE) {
|
||||
if (!Array.isArray(frames) || !frames.length || !Number.isFinite(endMs)
|
||||
|| !Number.isSafeInteger(timescale) || timescale < 1) {
|
||||
throw new Error("AVIF frame timing is invalid");
|
||||
}
|
||||
for (let index = 0; index < frames.length; index += 1) {
|
||||
const frame = frames[index];
|
||||
if (!Number.isFinite(frame.timestampMs)
|
||||
|| (index > 0 && frame.timestampMs <= frames[index - 1].timestampMs)) {
|
||||
throw new Error("AVIF frame timestamps must increase");
|
||||
}
|
||||
}
|
||||
const startMs = frames[0].timestampMs;
|
||||
let previousTick = 0;
|
||||
return frames.map((frame, index) => {
|
||||
const nextMs = index + 1 < frames.length ? frames[index + 1].timestampMs : endMs;
|
||||
if (nextMs <= frame.timestampMs) throw new Error("AVIF frame duration is empty");
|
||||
// Quantize cumulative boundaries, so rounding never drifts across frames.
|
||||
// The final ceil matches the WAV's exact number of PCM samples.
|
||||
const nextTick = index + 1 === frames.length
|
||||
? Math.ceil((nextMs - startMs) * timescale / 1000)
|
||||
: Math.round((nextMs - startMs) * timescale / 1000);
|
||||
const duration = nextTick - previousTick;
|
||||
if (duration < 1) throw new Error("AVIF frame duration is below the timebase precision");
|
||||
previousTick = nextTick;
|
||||
return duration;
|
||||
});
|
||||
}
|
||||
|
||||
export function createAvifSequenceEncoder(module, {
|
||||
width,
|
||||
height,
|
||||
timescale = AVIF_TIMESCALE,
|
||||
quality = 55,
|
||||
speed = 8,
|
||||
} = {}) {
|
||||
const handle = module._hda_create(width, height, timescale, quality, speed);
|
||||
if (!handle) throw new Error(encoderError(module, 0));
|
||||
let finished = false;
|
||||
return {
|
||||
add(rgba, duration) {
|
||||
if (finished) throw new Error("AVIF sequence is already finished");
|
||||
if (!(rgba instanceof Uint8Array) || rgba.byteLength !== width * height * 4) {
|
||||
throw new Error("AVIF RGBA frame has the wrong size");
|
||||
}
|
||||
const pointer = module._malloc(rgba.byteLength);
|
||||
if (!pointer) throw new Error("Could not allocate AVIF frame memory.");
|
||||
try {
|
||||
module.HEAPU8.set(rgba, pointer);
|
||||
if (!module._hda_add_rgba(handle, pointer, rgba.byteLength, duration)) {
|
||||
throw new Error(encoderError(module, handle));
|
||||
}
|
||||
} finally {
|
||||
module._free(pointer);
|
||||
}
|
||||
},
|
||||
finish() {
|
||||
if (finished) throw new Error("AVIF sequence is already finished");
|
||||
finished = true;
|
||||
if (!module._hda_finish(handle)) throw new Error(encoderError(module, handle));
|
||||
const size = module._hda_output_size(handle);
|
||||
if (!size || size > MAX_ANIMATED_AVIF_BYTES) throw new Error("Animated AVIF exceeds its output limit.");
|
||||
const pointer = module._hda_output(handle);
|
||||
return module.HEAPU8.slice(pointer, pointer + size);
|
||||
},
|
||||
destroy() {
|
||||
module._hda_destroy(handle);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function encodeJpegSequence(module, frames, {
|
||||
endMs,
|
||||
quality = 55,
|
||||
speed = 8,
|
||||
createBitmap = globalThis.createImageBitmap?.bind(globalThis),
|
||||
createCanvas = (width, height) => new OffscreenCanvas(width, height),
|
||||
onProgress = () => {},
|
||||
} = {}) {
|
||||
if (!Array.isArray(frames) || !frames.length || typeof createBitmap !== "function") {
|
||||
throw new Error("Captured video frames cannot be decoded.");
|
||||
}
|
||||
const [{ width, height }] = frames;
|
||||
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height)) throw new Error("Captured frame dimensions are invalid.");
|
||||
if (frames.some(frame => frame.width !== width || frame.height !== height)) {
|
||||
throw new Error("Captured frame dimensions changed during the selected interval.");
|
||||
}
|
||||
const durations = frameDurations(frames, endMs);
|
||||
if (frames.length === 1 && durations[0] < 2) {
|
||||
throw new Error("AVIF sequence duration is below the timebase precision");
|
||||
}
|
||||
const canvas = createCanvas(width, height);
|
||||
const context = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
|
||||
if (!context) throw new Error("Could not create the AVIF frame decoder canvas.");
|
||||
const encoder = createAvifSequenceEncoder(module, { width, height, quality, speed });
|
||||
try {
|
||||
for (let index = 0; index < frames.length; index += 1) {
|
||||
const frame = frames[index];
|
||||
const bitmap = await createBitmap(new Blob([frame.data], { type: "image/jpeg" }));
|
||||
try {
|
||||
context.drawImage(bitmap, 0, 0, width, height);
|
||||
const rgba = context.getImageData(0, 0, width, height).data;
|
||||
const pixels = new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength);
|
||||
if (frames.length === 1) {
|
||||
// libavif writes one image as a still AVIF without sequence timing.
|
||||
// Repeat its pixels while preserving the exact selected sample count.
|
||||
const firstDuration = Math.floor(durations[index] / 2);
|
||||
encoder.add(pixels, firstDuration);
|
||||
encoder.add(pixels, durations[index] - firstDuration);
|
||||
} else {
|
||||
encoder.add(pixels, durations[index]);
|
||||
}
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
onProgress(index + 1, frames.length);
|
||||
}
|
||||
return encoder.finish();
|
||||
} finally {
|
||||
encoder.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Hachidori's own stored ZIP64 format; loaded only for explicit backup work.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { BlobReader, BlobWriter, ZipReader, ZipWriter } from "./vendor/zip.js";
|
||||
import { assertLookupStatsRows, emptyLookupStats } from "./lookup-stats.js";
|
||||
|
||||
const MANIFEST = "hachidori-backup.json";
|
||||
const ZIP_OPTIONS = {
|
||||
useWebWorkers: false,
|
||||
level: 0,
|
||||
zip64: true,
|
||||
extendedTimestamp: false,
|
||||
lastModDate: new Date(1980, 0, 1),
|
||||
};
|
||||
|
||||
export function assertBackupPath(path) {
|
||||
if (typeof path !== "string" || /[\\\u0000-\u001f\u007f]/u.test(path)
|
||||
|| path.split("/").some(part => part === "" || part === "." || part === "..")) {
|
||||
throw new Error(`Invalid backup file path: ${String(path)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPayloadPath(path) {
|
||||
assertBackupPath(path);
|
||||
if (!/^dictionaries\/(?:0|[1-9]\d*)\/.+/u.test(path)) {
|
||||
throw new Error(`Unexpected backup payload path: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertFileList(files) {
|
||||
if (!Array.isArray(files)) throw new Error("The backup has no file list.");
|
||||
const paths = new Set();
|
||||
for (const file of files) {
|
||||
assertPayloadPath(file?.path);
|
||||
if (!Number.isSafeInteger(file.size) || file.size < 0) throw new Error("Invalid backup file size.");
|
||||
if (paths.has(file.path)) throw new Error(`Duplicate backup file: ${file.path}`);
|
||||
paths.add(file.path);
|
||||
}
|
||||
for (const path of paths) {
|
||||
const components = path.split("/");
|
||||
components.pop();
|
||||
while (components.length > 0) {
|
||||
if (paths.has(components.join("/"))) throw new Error(`Backup file is also a directory: ${path}`);
|
||||
components.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBackupArchive(snapshot, files, lookupStatsRows, createdAt = new Date().toISOString()) {
|
||||
const entries = files.map(({ path, data }) => ({ path, size: data.size }));
|
||||
assertFileList(entries);
|
||||
assertLookupStatsRows(snapshot?.lookupStats, lookupStatsRows);
|
||||
const manifest = { format: "hachidori-backup", version: 2, createdAt, snapshot, lookupStatsRows, files: entries };
|
||||
/** @type {{add(name: string, reader: object): Promise<unknown>, close(): Promise<Blob>}} */
|
||||
const writer = new ZipWriter(new BlobWriter("application/zip"), ZIP_OPTIONS);
|
||||
await writer.add(MANIFEST, new BlobReader(new Blob([JSON.stringify(manifest)])));
|
||||
for (const file of files) await writer.add(file.path, new BlobReader(file.data));
|
||||
return writer.close();
|
||||
}
|
||||
|
||||
function assertRegularEntry(entry) {
|
||||
assertBackupPath(entry.filename);
|
||||
const kind = entry.unixMode === undefined ? 0 : entry.unixMode & 0o170000;
|
||||
if (entry.directory || entry.symlink || (kind !== 0 && kind !== 0o100000)) {
|
||||
throw new Error(`The backup entry is not a regular file: ${entry.filename}`);
|
||||
}
|
||||
if (entry.encrypted || entry.compressionMethod !== 0) {
|
||||
throw new Error("Hachidori backups contain only unencrypted, stored ZIP entries.");
|
||||
}
|
||||
}
|
||||
|
||||
function readEntry(entry) {
|
||||
return entry.getData(new BlobWriter(), {
|
||||
useWebWorkers: false, checkSignature: true, checkOverlappingEntry: true, checkAmbiguity: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openBackupArchive(blob) {
|
||||
const reader = new ZipReader(new BlobReader(blob), { useWebWorkers: false, checkAmbiguity: true });
|
||||
try {
|
||||
const entries = await reader.getEntries();
|
||||
const byPath = new Map();
|
||||
for (const entry of entries) {
|
||||
assertRegularEntry(entry);
|
||||
if (byPath.has(entry.filename)) throw new Error(`Duplicate backup file: ${entry.filename}`);
|
||||
byPath.set(entry.filename, entry);
|
||||
}
|
||||
const manifestEntry = byPath.get(MANIFEST);
|
||||
if (!manifestEntry) throw new Error("The selected archive is not a Hachidori backup.");
|
||||
const manifest = JSON.parse(await (await readEntry(manifestEntry)).text());
|
||||
if (manifest?.format !== "hachidori-backup" || ![1, 2].includes(manifest.version)
|
||||
|| typeof manifest.createdAt !== "string" || Number.isNaN(Date.parse(manifest.createdAt))) {
|
||||
throw new Error("The selected archive is not a supported Hachidori backup.");
|
||||
}
|
||||
const snapshot = manifest.version === 1 ? { ...manifest.snapshot, lookupStats: emptyLookupStats() } : manifest.snapshot;
|
||||
// Older backups include the retired external corpus integration. Drop only
|
||||
// those fields before the complete snapshot contract validates the restore.
|
||||
delete snapshot?.options?.corpusSeenEnabled;
|
||||
delete snapshot?.options?.corpusSeenUrl;
|
||||
const lookupStatsRows = manifest.version === 1 ? [] : manifest.lookupStatsRows;
|
||||
assertLookupStatsRows(snapshot?.lookupStats, lookupStatsRows);
|
||||
assertFileList(manifest.files);
|
||||
if (entries.length !== manifest.files.length + 1) throw new Error("The backup contains unlisted or missing files.");
|
||||
const files = [];
|
||||
for (const file of manifest.files) {
|
||||
const entry = byPath.get(file.path);
|
||||
if (!entry) throw new Error(`The backup is missing ${file.path}`);
|
||||
if (entry.uncompressedSize !== file.size) throw new Error(`Incorrect backup file size: ${file.path}`);
|
||||
const data = await readEntry(entry);
|
||||
if (data.size !== file.size) throw new Error(`Incorrect extracted backup file size: ${file.path}`);
|
||||
files.push({ path: file.path, data });
|
||||
}
|
||||
return { snapshot, lookupStatsRows, createdAt: manifest.createdAt, files };
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Local daily automatic snapshots using the complete manual-backup payload.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { assertLookupStatsRows } from "./lookup-stats.js";
|
||||
import { assertBackupSnapshot } from "./backup-state.js";
|
||||
|
||||
export const AUTOMATIC_BACKUPS_KEY = "automaticBackups";
|
||||
export const AUTOMATIC_BACKUP_ALARM = "hachidori-automatic-backup";
|
||||
export const AUTOMATIC_BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
export const AUTOMATIC_BACKUP_SCHEMA_VERSION = 1;
|
||||
|
||||
export function emptyAutomaticBackupStore() {
|
||||
return { schemaVersion: AUTOMATIC_BACKUP_SCHEMA_VERSION, backups: [] };
|
||||
}
|
||||
|
||||
export function automaticBackupStore(value) {
|
||||
if (value === undefined) return emptyAutomaticBackupStore();
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)
|
||||
|| value.schemaVersion !== AUTOMATIC_BACKUP_SCHEMA_VERSION
|
||||
|| !Array.isArray(value.backups)) {
|
||||
throw new Error("The automatic backup index has an unsupported schema.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function automaticBackupTime(record) {
|
||||
const timestamp = typeof record?.createdAt === "string" ? Date.parse(record.createdAt) : Number.NaN;
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
export function newestAutomaticBackupTime(value) {
|
||||
const store = automaticBackupStore(value);
|
||||
let newest = null;
|
||||
for (const record of store.backups) {
|
||||
const timestamp = automaticBackupTime(record);
|
||||
if (timestamp !== null && (newest === null || timestamp > newest)) newest = timestamp;
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
export function automaticBackupDue(value, now = Date.now()) {
|
||||
const newest = newestAutomaticBackupTime(value);
|
||||
return newest === null || now - newest >= AUTOMATIC_BACKUP_INTERVAL_MS;
|
||||
}
|
||||
|
||||
export function nextAutomaticBackupTime(value, now = Date.now()) {
|
||||
const newest = newestAutomaticBackupTime(value);
|
||||
return newest === null ? now : newest + AUTOMATIC_BACKUP_INTERVAL_MS;
|
||||
}
|
||||
|
||||
export async function assertAutomaticBackupRecord(record) {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)
|
||||
|| typeof record.id !== "string" || record.id === ""
|
||||
|| automaticBackupTime(record) === null
|
||||
|| !Array.isArray(record.lookupStatsRows)) {
|
||||
throw new Error("The automatic backup record is invalid.");
|
||||
}
|
||||
await assertBackupSnapshot(record.snapshot);
|
||||
assertLookupStatsRows(record.snapshot.lookupStats, record.lookupStatsRows);
|
||||
return record;
|
||||
}
|
||||
|
||||
function newestFirst(left, right) {
|
||||
return automaticBackupTime(right) - automaticBackupTime(left)
|
||||
|| String(right.id).localeCompare(String(left.id));
|
||||
}
|
||||
|
||||
export async function validAutomaticBackups(value) {
|
||||
const store = automaticBackupStore(value);
|
||||
const valid = [];
|
||||
let corruptCount = 0;
|
||||
for (const record of store.backups) {
|
||||
try {
|
||||
await assertAutomaticBackupRecord(record);
|
||||
valid.push(record);
|
||||
} catch {
|
||||
corruptCount += 1;
|
||||
}
|
||||
}
|
||||
valid.sort(newestFirst);
|
||||
const ids = new Set();
|
||||
const backups = [];
|
||||
for (const record of valid) {
|
||||
if (ids.has(record.id)) {
|
||||
corruptCount += 1;
|
||||
continue;
|
||||
}
|
||||
ids.add(record.id);
|
||||
backups.push(record);
|
||||
}
|
||||
return { backups, corruptCount };
|
||||
}
|
||||
|
||||
// `limit` is the user's `automaticBackupDays` option: one snapshot per day, so
|
||||
// the retained count is the number of days kept. A lowered limit prunes when
|
||||
// the next snapshot is written, not when the option changes.
|
||||
export async function replaceAutomaticBackup(value, record, limit) {
|
||||
await assertAutomaticBackupRecord(record);
|
||||
const { backups } = await validAutomaticBackups(value);
|
||||
const retained = backups.filter(candidate => candidate.id !== record.id);
|
||||
retained.push(record);
|
||||
retained.sort(newestFirst);
|
||||
return {
|
||||
schemaVersion: AUTOMATIC_BACKUP_SCHEMA_VERSION,
|
||||
backups: retained.slice(0, limit),
|
||||
};
|
||||
}
|
||||
|
||||
export function automaticBackupJsonBytes(value) {
|
||||
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
||||
}
|
||||
|
||||
export function formatAutomaticBackupAge(createdAt, now = Date.now(), locale = undefined) {
|
||||
const timestamp = Date.parse(createdAt);
|
||||
if (!Number.isFinite(timestamp)) return "unknown time";
|
||||
const delta = timestamp - now;
|
||||
const absolute = Math.abs(delta);
|
||||
if (absolute < 60_000) return delta > 0 ? "in less than a minute" : "less than a minute ago";
|
||||
let unit = "day";
|
||||
let unitMilliseconds = 24 * 60 * 60_000;
|
||||
if (absolute < 60 * 60_000) {
|
||||
unit = "minute";
|
||||
unitMilliseconds = 60_000;
|
||||
} else if (absolute < 24 * 60 * 60_000) {
|
||||
unit = "hour";
|
||||
unitMilliseconds = 60 * 60_000;
|
||||
}
|
||||
const magnitude = Math.max(1, Math.floor(absolute / unitMilliseconds));
|
||||
return new Intl.RelativeTimeFormat(locale, { numeric: "always" })
|
||||
.format(delta < 0 ? -magnitude : magnitude, unit);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Keep engine-owned backup URLs alive until Chrome finishes saving the file.
|
||||
// Session storage survives service-worker restarts, but is not backup content.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
const KEY = "backupDownloads";
|
||||
const TARGET = "hoshidicts-offscreen";
|
||||
|
||||
export function createBackupDownloads(chrome, relay) {
|
||||
let tail = Promise.resolve();
|
||||
const serialise = job => {
|
||||
const result = tail.then(job, job);
|
||||
tail = result.catch(() => {});
|
||||
return result;
|
||||
};
|
||||
const release = blobUrl => relay({ target: TARGET, type: "hd_backup_release", blobUrl });
|
||||
const read = async () => (await chrome.storage.session.get(KEY))[KEY] ?? {};
|
||||
|
||||
async function finish(id) {
|
||||
const tracked = await read();
|
||||
if (!Object.hasOwn(tracked, id)) return;
|
||||
const [download] = await chrome.downloads.search({ id: Number(id) });
|
||||
if (download?.state === "in_progress") return;
|
||||
const reply = await release(tracked[id]);
|
||||
if (!reply?.ok) throw new Error(reply?.error || "Could not release the saved backup archive.");
|
||||
delete tracked[id];
|
||||
await chrome.storage.session.set({ [KEY]: tracked });
|
||||
}
|
||||
|
||||
return {
|
||||
async download() {
|
||||
const exported = await relay({ target: TARGET, type: "hd_backup_export" });
|
||||
if (!exported?.ok) throw new Error(exported?.error || "Could not create a backup archive.");
|
||||
let id;
|
||||
try {
|
||||
id = await chrome.downloads.download({ url: exported.blobUrl, saveAs: true,
|
||||
filename: `hachidori-backup-${new Date().toISOString().slice(0, 10)}.zip`, conflictAction: "uniquify" });
|
||||
} catch (error) {
|
||||
await serialise(() => release(exported.blobUrl));
|
||||
throw error;
|
||||
}
|
||||
let warning = null;
|
||||
try {
|
||||
await serialise(async () => {
|
||||
await chrome.storage.session.set({ [KEY]: { ...await read(), [id]: exported.blobUrl } });
|
||||
// A small download can complete before its ownership record is written.
|
||||
await finish(id);
|
||||
});
|
||||
} catch (error) {
|
||||
warning = `Download started, but its temporary archive could not be tracked: ${error.message}`;
|
||||
}
|
||||
return { downloadId: id, warning };
|
||||
},
|
||||
changed(id) { return serialise(() => finish(id)); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// Explicit complete backup/restore controls; the engine owns preparation tokens.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { formatAutomaticBackupAge } from "./backup-automatic.js";
|
||||
import { downloadBlob } from "./blob-download.js";
|
||||
|
||||
export function createBackupSettingsController({
|
||||
document, send, download, listAutomatic = null, checkReady, setBusy, status, refresh,
|
||||
trackPreparation = () => {}, cancelPreparation = () => {}, browserName = "Chrome",
|
||||
}) {
|
||||
const element = id => document.getElementById(id);
|
||||
const window = document.defaultView;
|
||||
let busy = false, prepared = null;
|
||||
let preparingToken = null;
|
||||
let exportedUrl = null;
|
||||
let pageEpoch = 0;
|
||||
let automaticBackups = [];
|
||||
let automaticTimer = null;
|
||||
let automaticLoadEpoch = 0;
|
||||
|
||||
function render() {
|
||||
element("backup-export").disabled = busy;
|
||||
element("backup-file").disabled = busy;
|
||||
element("backup-cancel").disabled = busy;
|
||||
element("backup-restore").disabled = busy || !prepared || !element("backup-confirm").checked;
|
||||
element("backup-confirm").disabled = busy;
|
||||
element("backup-preview").hidden = prepared === null;
|
||||
for (const button of element("automatic-backup-list").querySelectorAll("button")) {
|
||||
button.disabled = busy;
|
||||
}
|
||||
}
|
||||
|
||||
async function run(message, operation, requireReady = true) {
|
||||
if (busy) return;
|
||||
try {
|
||||
if (requireReady) checkReady();
|
||||
busy = true;
|
||||
setBusy(true);
|
||||
render();
|
||||
status(message, "");
|
||||
await operation();
|
||||
} catch (error) {
|
||||
status(error.message || String(error), "error");
|
||||
} finally {
|
||||
busy = false;
|
||||
setBusy(false);
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelPrepared() {
|
||||
if (!prepared) return;
|
||||
const token = prepared.token;
|
||||
const reply = await send("hd_backup_cancel", { token });
|
||||
if (!reply.ok) throw new Error(reply.error || "Could not discard the prepared restore.");
|
||||
trackPreparation(token, false);
|
||||
prepared = null;
|
||||
element("backup-confirm").checked = false;
|
||||
render();
|
||||
}
|
||||
|
||||
async function releaseExport() {
|
||||
if (!exportedUrl) return;
|
||||
const reply = await send("hd_backup_release", { blobUrl: exportedUrl });
|
||||
if (!reply?.ok) throw new Error(reply?.error || "Could not release the temporary backup archive. Try exporting again.");
|
||||
exportedUrl = null;
|
||||
}
|
||||
|
||||
function renderAutomaticAges() {
|
||||
for (const row of element("automatic-backup-list").children) {
|
||||
const backup = automaticBackups.find(candidate => candidate.id === row.dataset.backupId);
|
||||
if (!backup) continue;
|
||||
const age = formatAutomaticBackupAge(backup.createdAt);
|
||||
row.querySelector(".automatic-backup-age").textContent = age;
|
||||
row.querySelector(".automatic-backup-restore").textContent = `Restore from ${age}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAutomaticBackups() {
|
||||
const rows = document.createDocumentFragment();
|
||||
for (const backup of automaticBackups) {
|
||||
const row = element("automatic-backup-template").content.firstElementChild.cloneNode(true);
|
||||
row.dataset.backupId = backup.id;
|
||||
row.querySelector(".automatic-backup-detail").textContent =
|
||||
`${backup.dictionaries.length} dictionaries · ${backup.customEntryCount} personal entries`;
|
||||
const created = row.querySelector(".automatic-backup-created");
|
||||
created.dateTime = backup.createdAt;
|
||||
created.textContent = new Date(backup.createdAt).toLocaleString();
|
||||
row.querySelector(".automatic-backup-restore").addEventListener("click", () => {
|
||||
const age = formatAutomaticBackupAge(backup.createdAt);
|
||||
void run("Checking the automatic backup and validating its dictionary files…", () =>
|
||||
prepareRestore("hd_backup_auto_prepare", { id: backup.id }, `Automatic backup from ${age}`));
|
||||
});
|
||||
rows.append(row);
|
||||
}
|
||||
element("automatic-backup-list").replaceChildren(rows);
|
||||
renderAutomaticAges();
|
||||
render();
|
||||
}
|
||||
|
||||
async function loadAutomaticBackups() {
|
||||
if (!listAutomatic) return;
|
||||
const epoch = ++automaticLoadEpoch;
|
||||
try {
|
||||
const reply = await listAutomatic();
|
||||
if (epoch !== automaticLoadEpoch) return;
|
||||
if (!reply?.ok) throw new Error(reply?.error || "Could not read automatic backups.");
|
||||
automaticBackups = Array.isArray(reply.backups) ? reply.backups : [];
|
||||
renderAutomaticBackups();
|
||||
if (reply.corruptCount > 0) {
|
||||
const count = reply.corruptCount === 1 ? "One automatic backup is" : `${reply.corruptCount} automatic backups are`;
|
||||
element("automatic-backup-status").textContent =
|
||||
`${count} damaged. Any valid older backup remains available below.`;
|
||||
} else if (automaticBackups.length === 0) {
|
||||
element("automatic-backup-status").textContent =
|
||||
"No automatic backup has been created yet.";
|
||||
} else {
|
||||
element("automatic-backup-status").textContent = "";
|
||||
}
|
||||
} catch (error) {
|
||||
if (epoch !== automaticLoadEpoch) return;
|
||||
automaticBackups = [];
|
||||
renderAutomaticBackups();
|
||||
element("automatic-backup-status").textContent =
|
||||
error.message || String(error);
|
||||
}
|
||||
}
|
||||
|
||||
function startAutomaticBackups() {
|
||||
if (!listAutomatic) return;
|
||||
void loadAutomaticBackups();
|
||||
if (automaticTimer === null) {
|
||||
automaticTimer = window.setInterval(renderAutomaticAges, 60_000);
|
||||
}
|
||||
}
|
||||
|
||||
function showPrepared(reply, label) {
|
||||
prepared = reply;
|
||||
element("backup-confirm").checked = false;
|
||||
element("backup-file-name").textContent = label;
|
||||
const created = element("backup-created");
|
||||
created.dateTime = reply.createdAt;
|
||||
created.textContent = new Date(reply.createdAt).toLocaleString();
|
||||
element("backup-count").textContent = `${reply.dictionaries.length} dictionaries · ${reply.customEntryCount} personal entries`;
|
||||
const list = document.createDocumentFragment();
|
||||
for (const dictionary of reply.dictionaries) {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = `${dictionary.title}${dictionary.enabled ? "" : " (disabled)"}`;
|
||||
list.append(item);
|
||||
}
|
||||
element("backup-dictionaries").replaceChildren(list);
|
||||
status(reply.warning || "Backup checked. Nothing has been replaced.", reply.warning ? "" : "ready");
|
||||
render();
|
||||
element("backup-preview-heading").focus();
|
||||
}
|
||||
|
||||
async function prepareRestore(type, fields, label) {
|
||||
const epoch = pageEpoch;
|
||||
await cancelPrepared();
|
||||
if (epoch !== pageEpoch) return;
|
||||
const token = window.crypto.randomUUID();
|
||||
preparingToken = token;
|
||||
trackPreparation(token, true);
|
||||
let reply;
|
||||
try {
|
||||
reply = await send(type, { ...fields, token });
|
||||
} catch (error) {
|
||||
// The engine may have prepared successfully before its reply was lost.
|
||||
try {
|
||||
const cancelled = await send("hd_backup_cancel", { token });
|
||||
if (cancelled.ok) trackPreparation(token, false);
|
||||
} catch { /* Keep the original failure. */ }
|
||||
throw error;
|
||||
} finally {
|
||||
preparingToken = null;
|
||||
}
|
||||
if (!reply.ok) {
|
||||
trackPreparation(token, false);
|
||||
throw new Error(reply.error || "This backup could not be prepared.");
|
||||
}
|
||||
if (epoch !== pageEpoch) return;
|
||||
showPrepared(reply, label);
|
||||
}
|
||||
|
||||
element("backup-export").addEventListener("click", () => {
|
||||
void run("Creating the backup archive…", async () => {
|
||||
if (!download) {
|
||||
await releaseExport();
|
||||
const exported = await send("hd_backup_export");
|
||||
if (!exported.ok) throw new Error(exported.error || "Could not create the backup.");
|
||||
exportedUrl = exported.blobUrl;
|
||||
let blob;
|
||||
// A failure here leaves exportedUrl set; the next export click retries the release.
|
||||
const response = await window.fetch(exported.blobUrl);
|
||||
if (!response.ok) throw new Error("Could not read the backup archive.");
|
||||
blob = await response.blob();
|
||||
await releaseExport();
|
||||
downloadBlob(document, blob, `hachidori-backup-${new Date().toISOString().slice(0, 10)}.zip`);
|
||||
status("Save requested. Choose where to save the backup in your app’s save dialog.", "ready", true);
|
||||
return;
|
||||
}
|
||||
const reply = await download();
|
||||
if (!reply.ok) throw new Error(reply.error || "Could not create the backup.");
|
||||
status(reply.warning || `Download started. Check ${browserName}’s downloads for progress.`, reply.warning ? "" : "ready", true);
|
||||
});
|
||||
});
|
||||
|
||||
element("backup-file").addEventListener("change", () => {
|
||||
const file = element("backup-file").files?.[0];
|
||||
element("backup-file").value = "";
|
||||
if (!file) return;
|
||||
void run("Checking the archive and preparing fresh dictionary files…", async () => {
|
||||
const blobUrl = window.URL.createObjectURL(file);
|
||||
try {
|
||||
await prepareRestore("hd_backup_prepare", { blobUrl }, file.name);
|
||||
} finally {
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
element("backup-confirm").addEventListener("change", render);
|
||||
element("backup-cancel").addEventListener("click", () => run("Discarding the prepared restore…", async () => {
|
||||
await cancelPrepared();
|
||||
status("Restore cancelled. Your data has not changed.", "");
|
||||
element("backup-file").focus();
|
||||
}, false));
|
||||
element("backup-restore").addEventListener("click", () => {
|
||||
if (!prepared || !element("backup-confirm").checked) return;
|
||||
void run("Restoring dictionaries and settings…", async () => {
|
||||
const token = prepared.token;
|
||||
// A restore attempt consumes its token, including an uncertain reply.
|
||||
prepared = null;
|
||||
trackPreparation(token, false);
|
||||
element("backup-confirm").checked = false;
|
||||
const reply = await send("hd_backup_restore", { token });
|
||||
if (!reply.ok) throw new Error(reply.error || "The restore could not be confirmed. Check the current library before trying again.");
|
||||
status(reply.warning || "Restored successfully.", reply.warning ? "" : "ready", true);
|
||||
try { await refresh(); }
|
||||
catch { status("Restored successfully. Reopen Settings to refresh this page.", "ready", true); }
|
||||
await loadAutomaticBackups();
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("pagehide", () => {
|
||||
pageEpoch += 1;
|
||||
automaticLoadEpoch += 1;
|
||||
if (automaticTimer !== null) {
|
||||
window.clearInterval(automaticTimer);
|
||||
automaticTimer = null;
|
||||
}
|
||||
const token = preparingToken ?? prepared?.token;
|
||||
preparingToken = null;
|
||||
prepared = null;
|
||||
element("backup-confirm").checked = false;
|
||||
render();
|
||||
if (token) status("Restore cancelled. Choose the backup again to prepare it.", "");
|
||||
if (token) {
|
||||
// Port delivery is synchronous and its disconnect is a second cleanup
|
||||
// signal when an older Chrome drops this page's final runtime message.
|
||||
cancelPreparation(token);
|
||||
void send("hd_backup_cancel", { token })
|
||||
.then(reply => { if (reply.ok) trackPreparation(token, false); })
|
||||
.catch(() => {});
|
||||
}
|
||||
if (exportedUrl) {
|
||||
const blobUrl = exportedUrl;
|
||||
exportedUrl = null;
|
||||
void send("hd_backup_release", { blobUrl }).catch(() => {});
|
||||
}
|
||||
});
|
||||
window.addEventListener("pageshow", event => {
|
||||
if (event.persisted) startAutomaticBackups();
|
||||
});
|
||||
render();
|
||||
startAutomaticBackups();
|
||||
return { render, refreshAutomaticBackups: loadAutomaticBackups };
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Complete persisted-state contract shared by the engine and storage owner.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import "./reader-options.js";
|
||||
import "./dictionary-group-state.js";
|
||||
import {
|
||||
assertCustomSourceState, customDictionarySemanticRevision,
|
||||
normaliseCustomDictionaryDocument, parseCustomDictionary,
|
||||
} from "./custom-dictionary.js";
|
||||
import { assertDictionaryUpdateSchedule, assertRecommendedDictionary, normaliseUpdateSettings, recommendedDictionarySource } from "./managed-dictionary-source.js";
|
||||
import { sameJsonValue } from "./json-value.js";
|
||||
import { assertLookupStatsDescriptor } from "./lookup-stats.js";
|
||||
|
||||
export function backupRevisions(snapshot) {
|
||||
return Object.fromEntries(["state", "options", "document", "updates", "lookupStats"].map(key => {
|
||||
const revision = snapshot[key]?.revision;
|
||||
return [key, Number.isSafeInteger(revision) && revision >= 0 ? revision : 0];
|
||||
}));
|
||||
}
|
||||
|
||||
export function restoredBackupSnapshot(current, archived, dictionaries) {
|
||||
return Object.fromEntries(Object.entries(backupRevisions(current)).map(([key, revision]) => [key, {
|
||||
...(key === "options"
|
||||
? globalThis.HDReaderOptions.projectStoredOptions(archived[key])
|
||||
: archived[key]),
|
||||
...(key === "state" ? { dictionaries } : {}),
|
||||
...(key === "lookupStats" ? { generation: crypto.randomUUID() } : {}),
|
||||
revision: revision + 1,
|
||||
}]));
|
||||
}
|
||||
|
||||
function assertDictionaryList(dictionaries) {
|
||||
const ids = new Set(), titles = new Set();
|
||||
for (const entry of dictionaries) {
|
||||
assertDictionaryUpdateSchedule(entry);
|
||||
if (typeof entry?.id !== "string" || entry.id === "" || ids.has(entry.id)
|
||||
|| typeof entry.title !== "string" || entry.title === "" || titles.has(entry.title)
|
||||
|| /[\\/]/u.test(entry.title) || entry.title.includes("\0") || [".", ".."].includes(entry.title)
|
||||
|| typeof entry.revision !== "string"
|
||||
|| typeof entry.enabled !== "boolean" || typeof entry.favorite !== "boolean"
|
||||
|| (entry.displayName !== null && typeof entry.displayName !== "string")
|
||||
|| ["termCount", "frequencyCount", "pitchCount", "kanjiCount", "mediaCount"].some(key =>
|
||||
!Number.isSafeInteger(entry[key]) || entry[key] < 0)) {
|
||||
throw new Error("The backup contains invalid or duplicate dictionary packages.");
|
||||
}
|
||||
const recommended = recommendedDictionarySource(entry.sourceId);
|
||||
if (recommended) {
|
||||
assertRecommendedDictionary(recommended, entry);
|
||||
if (entry.downloadUrl !== recommended.downloadUrl) {
|
||||
throw new Error("The backup dictionary does not match its recommended source.");
|
||||
}
|
||||
}
|
||||
ids.add(entry.id);
|
||||
titles.add(entry.title);
|
||||
}
|
||||
}
|
||||
|
||||
function assertGroups(groups, dictionaries) {
|
||||
const { normaliseDictionaryGroups, groupNameKey } = globalThis.HDDictionaryGroups;
|
||||
if (!Array.isArray(groups) || !sameJsonValue(groups, normaliseDictionaryGroups(groups, dictionaries))) {
|
||||
throw new Error("The backup contains invalid dictionary groups.");
|
||||
}
|
||||
const ids = new Set(), names = new Set(["all"]);
|
||||
for (const group of groups) {
|
||||
const key = groupNameKey(group.name);
|
||||
if (ids.has(group.id) || names.has(key)) throw new Error("The backup contains duplicate dictionary groups.");
|
||||
ids.add(group.id);
|
||||
names.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
function validBackupReaderOptions(options) {
|
||||
if (!options || typeof options !== "object" || Array.isArray(options)) return false;
|
||||
const allowed = new Set([...Object.keys(globalThis.HDReaderOptions.DEFAULT_OPTIONS), "modifier"]);
|
||||
if (Object.keys(options).some(key => !allowed.has(key))) return false;
|
||||
let projected;
|
||||
try {
|
||||
projected = globalThis.HDReaderOptions.validateOptionsPatch(options);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// A legacy backup may have only `customLinks`, and its singleton Anki
|
||||
// object has no `templates`. If the richer fields are present, however, they
|
||||
// must already be canonical instead of relying on migration to resolve two
|
||||
// conflicting representations.
|
||||
if (Object.hasOwn(options, "customButtons") && Object.hasOwn(options, "customLinks")
|
||||
&& !sameJsonValue(options.customLinks, projected.customLinks)) return false;
|
||||
if (options.anki && Object.hasOwn(options.anki, "templates")
|
||||
&& !sameJsonValue(options.anki, projected.anki)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function assertBackupSnapshot(snapshot) {
|
||||
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)
|
||||
|| ["state", "options", "document", "updates"].some(key =>
|
||||
!Number.isSafeInteger(snapshot[key]?.revision) || snapshot[key].revision < 0)
|
||||
|| snapshot.state?.schemaVersion !== 1 || !Array.isArray(snapshot.state.dictionaries)) {
|
||||
throw new Error("The backup contains invalid dictionary state.");
|
||||
}
|
||||
assertDictionaryList(snapshot.state.dictionaries);
|
||||
assertLookupStatsDescriptor(snapshot.lookupStats);
|
||||
assertGroups(snapshot.state.groups, snapshot.state.dictionaries);
|
||||
const document = normaliseCustomDictionaryDocument(snapshot.document);
|
||||
const entries = parseCustomDictionary(document.text).entries;
|
||||
const semanticRevision = await customDictionarySemanticRevision(entries);
|
||||
if (document.semanticRevision !== semanticRevision) throw new Error("The backup custom source has invalid semantics.");
|
||||
assertCustomSourceState(snapshot.state.dictionaries, semanticRevision, entries.length);
|
||||
const { revision, ...options } = snapshot.options ?? {};
|
||||
if (!Number.isSafeInteger(revision) || revision < 0
|
||||
|| !validBackupReaderOptions(options)) {
|
||||
throw new Error("The backup contains invalid reader settings.");
|
||||
}
|
||||
if (!sameJsonValue(snapshot.updates, normaliseUpdateSettings(snapshot.updates))) {
|
||||
throw new Error("The backup contains invalid update settings.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Base64 for media, captured audio and screenshots. Uint8Array.prototype.toBase64
|
||||
// and Uint8Array.fromBase64 (Chrome 143+, Firefox 133+) do in about 0.5 ms per
|
||||
// megabyte what the String.fromCodePoint/btoa and atob/Uint8Array.from loops
|
||||
// take 40–55 ms for, with identical results; both remain as the fallback.
|
||||
|
||||
const CHUNK = 0x8000;
|
||||
|
||||
export function encodeBase64(data, { btoa: toAscii = globalThis.btoa } = {}) {
|
||||
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
||||
if (typeof bytes.toBase64 === "function") {
|
||||
try {
|
||||
return bytes.toBase64();
|
||||
} catch {
|
||||
// Fall through to the portable path.
|
||||
}
|
||||
}
|
||||
let binary = "";
|
||||
for (let offset = 0; offset < bytes.length; offset += CHUNK) {
|
||||
binary += String.fromCharCode.apply(null, bytes.subarray(offset, offset + CHUNK));
|
||||
}
|
||||
return toAscii(binary);
|
||||
}
|
||||
|
||||
export function decodeBase64(text, { atob: fromAscii = globalThis.atob } = {}) {
|
||||
if (typeof Uint8Array.fromBase64 === "function") {
|
||||
try {
|
||||
return Uint8Array.fromBase64(text);
|
||||
} catch {
|
||||
// Let the portable path produce its own result or error.
|
||||
}
|
||||
}
|
||||
const binary = fromAscii(text);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
export function downloadBlob(document, blob, filename) {
|
||||
const window = document.defaultView;
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
try {
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
} catch (error) {
|
||||
window.URL.revokeObjectURL(url);
|
||||
throw error;
|
||||
}
|
||||
window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Browser API and extension-origin facts shared by module contexts.
|
||||
// Content scripts intentionally keep their callback-style `chrome` calls.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
export function selectExtensionApi(scope = globalThis) {
|
||||
return scope.browser ?? scope.chrome ?? null;
|
||||
}
|
||||
|
||||
export const extensionApi = selectExtensionApi();
|
||||
|
||||
export function extensionProtocol(api = extensionApi) {
|
||||
const url = api?.runtime?.getURL?.("");
|
||||
if (typeof url !== "string" || url === "") return "";
|
||||
try {
|
||||
return new URL(url).protocol;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function browserKind(api = extensionApi) {
|
||||
return extensionProtocol(api) === "moz-extension:" ? "firefox" : "chrome";
|
||||
}
|
||||
|
||||
export const BROWSER_KIND = browserKind();
|
||||
export const IS_FIREFOX = BROWSER_KIND === "firefox";
|
||||
|
||||
export function extensionDocumentUrl(path, api = extensionApi) {
|
||||
return api?.runtime?.getURL?.(path) ?? path;
|
||||
}
|
||||
|
||||
export function expectedBackgroundUrl(api = extensionApi) {
|
||||
return extensionDocumentUrl(
|
||||
browserKind(api) === "firefox" ? "firefox-background.html" : "background.js",
|
||||
api,
|
||||
);
|
||||
}
|
||||
|
||||
export function isExactExtensionSender(sender, path, api = extensionApi, { tab = null } = {}) {
|
||||
if (sender?.id !== api?.runtime?.id || sender?.url !== extensionDocumentUrl(path, api)) return false;
|
||||
if (tab === false && sender.tab !== undefined) return false;
|
||||
if (tab === true && sender.tab === undefined) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
class HachidoriCaptureProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.batch = new Float32Array(2048);
|
||||
this.length = 0;
|
||||
this.startFrame = 0;
|
||||
this.nextFrame = null;
|
||||
}
|
||||
|
||||
inputInterrupted(frameCount) {
|
||||
if (this.nextFrame !== null && (frameCount === 0 || currentFrame !== this.nextFrame)) {
|
||||
const expectedFrame = this.nextFrame;
|
||||
this.length = 0;
|
||||
this.nextFrame = frameCount ? currentFrame + frameCount : null;
|
||||
this.port.postMessage({
|
||||
discontinuity: {
|
||||
expectedFrame,
|
||||
actualFrame: frameCount ? currentFrame : null,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
this.nextFrame = frameCount ? currentFrame + frameCount : null;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Empty input can precede the first active quantum. Returning false allows
|
||||
// Chrome to permanently retire this processor before the source is ready.
|
||||
process(inputs) { // NOSONAR -- S3516: the AudioWorklet lifetime contract requires true.
|
||||
const channels = inputs[0];
|
||||
const frameCount = channels?.[0]?.length ?? 0;
|
||||
this.inputInterrupted(frameCount);
|
||||
if (frameCount === 0) return true;
|
||||
for (let frame = 0; frame < frameCount; frame += 1) {
|
||||
if (this.length === 0) this.startFrame = currentFrame + frame;
|
||||
let sample = 0;
|
||||
for (const channel of channels) sample += channel[frame] ?? 0;
|
||||
this.batch[this.length] = sample / channels.length;
|
||||
this.length += 1;
|
||||
if (this.length === this.batch.length) {
|
||||
const samples = this.batch;
|
||||
this.port.postMessage({ startFrame: this.startFrame, samples: samples.buffer }, [samples.buffer]);
|
||||
this.batch = new Float32Array(2048);
|
||||
this.length = 0;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("hachidori-capture-audio", HachidoriCaptureProcessor);
|
||||
@@ -0,0 +1,225 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import {
|
||||
CAPTURE_SAMPLE_RATE,
|
||||
MAX_FRAME_BYTES,
|
||||
MAX_LIVE_FRAME_BYTES,
|
||||
MAX_PINNED_FRAME_BYTES,
|
||||
MAX_WAV_BYTES,
|
||||
} from "./media-limits.js";
|
||||
|
||||
export {
|
||||
CAPTURE_SAMPLE_RATE,
|
||||
MAX_FRAME_BYTES,
|
||||
MAX_LIVE_FRAME_BYTES,
|
||||
MAX_PINNED_FRAME_BYTES,
|
||||
MAX_WAV_BYTES,
|
||||
};
|
||||
|
||||
function finiteTime(value, label) {
|
||||
if (!Number.isFinite(value)) throw new Error(`${label} must be finite`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function bytes(value) {
|
||||
if (value instanceof Uint8Array) return value;
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
||||
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
||||
throw new Error("capture frame data must be bytes");
|
||||
}
|
||||
|
||||
export function createFrameRing({
|
||||
maxBytes = MAX_LIVE_FRAME_BYTES,
|
||||
maxAgeMs,
|
||||
maxFrameBytes = MAX_FRAME_BYTES,
|
||||
} = {}) {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1
|
||||
|| !Number.isFinite(maxAgeMs) || maxAgeMs <= 0
|
||||
|| !Number.isSafeInteger(maxFrameBytes) || maxFrameBytes < 1) {
|
||||
throw new Error("frame ring limits are invalid");
|
||||
}
|
||||
const frames = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
function evict(nowMs) {
|
||||
// Keep the predecessor that is still displayed at the retention boundary.
|
||||
while (frames.length && (totalBytes > maxBytes
|
||||
|| (frames.length > 1 && frames[1].timestampMs <= nowMs - maxAgeMs))) {
|
||||
totalBytes -= frames.shift().data.byteLength;
|
||||
}
|
||||
}
|
||||
|
||||
function append(value) {
|
||||
const data = bytes(value?.data);
|
||||
const timestampMs = finiteTime(value?.timestampMs, "frame timestamp");
|
||||
if (!Number.isSafeInteger(value?.width) || value.width < 1
|
||||
|| !Number.isSafeInteger(value?.height) || value.height < 1) {
|
||||
throw new Error("capture frame dimensions are invalid");
|
||||
}
|
||||
if (data.byteLength === 0 || data.byteLength > maxFrameBytes) {
|
||||
throw new Error(`capture frame exceeds the ${maxFrameBytes}-byte limit`);
|
||||
}
|
||||
if (frames.length && timestampMs <= frames.at(-1).timestampMs) {
|
||||
throw new Error("capture frame timestamps must increase");
|
||||
}
|
||||
const frame = { timestampMs, width: value.width, height: value.height, data: data.slice() };
|
||||
frames.push(frame);
|
||||
totalBytes += frame.data.byteLength;
|
||||
evict(timestampMs);
|
||||
return { ...frame, data: frame.data.slice() };
|
||||
}
|
||||
|
||||
function select(startMs, endMs, pinnedLimit = MAX_PINNED_FRAME_BYTES) {
|
||||
finiteTime(startMs, "pin start");
|
||||
finiteTime(endMs, "pin end");
|
||||
if (endMs <= startMs) throw new Error("capture pin interval is empty");
|
||||
const first = frames.findLastIndex(frame => frame.timestampMs <= startMs);
|
||||
if (first < 0) throw new Error("No retained video frame covers the start of this lookup.");
|
||||
const selected = frames.slice(first).filter(frame => frame.timestampMs < endMs);
|
||||
const size = selected.reduce((sum, frame) => sum + frame.data.byteLength, 0);
|
||||
if (size > pinnedLimit) throw new Error("The selected video exceeds the pinned-frame memory limit.");
|
||||
return selected.map((frame, index) => ({ ...frame,
|
||||
timestampMs: index === 0 ? startMs : frame.timestampMs, data: frame.data.slice() }));
|
||||
}
|
||||
|
||||
return {
|
||||
append,
|
||||
select,
|
||||
clear() { frames.length = 0; totalBytes = 0; },
|
||||
oldestTimestamp: () => frames.length
|
||||
? Math.max(frames[0].timestampMs, frames.at(-1).timestampMs - maxAgeMs) : null,
|
||||
newestTimestamp: () => frames.at(-1)?.timestampMs ?? null,
|
||||
size: () => ({ count: frames.length, bytes: totalBytes }),
|
||||
};
|
||||
}
|
||||
|
||||
function sampleRange(block, startMs, endMs, length, rate) {
|
||||
return {
|
||||
first: Math.max(0, Math.round((block.startMs - startMs) * rate / 1000)),
|
||||
last: block.endMs >= endMs ? length
|
||||
: Math.min(length, Math.round((block.endMs - startMs) * rate / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAudioRing({ maxAgeMs } = {}) {
|
||||
if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) throw new Error("audio ring age is invalid");
|
||||
const blocks = [];
|
||||
|
||||
function append(value) {
|
||||
const startMs = finiteTime(value?.startMs, "audio timestamp");
|
||||
const sampleRate = Number(value?.sampleRate);
|
||||
if (!Number.isSafeInteger(sampleRate) || sampleRate < 8_000 || sampleRate > 192_000) {
|
||||
throw new Error("capture audio sample rate is invalid");
|
||||
}
|
||||
const samples = value?.samples instanceof Float32Array
|
||||
? value.samples : new Float32Array(value?.samples ?? []);
|
||||
if (!samples.length) return null;
|
||||
const endMs = startMs + samples.length * 1000 / sampleRate;
|
||||
const block = { startMs, endMs, sampleRate, samples: samples.slice() };
|
||||
blocks.push(block);
|
||||
const cutoff = endMs - maxAgeMs;
|
||||
while (blocks.length && blocks[0].endMs < cutoff) blocks.shift();
|
||||
return { startMs, endMs, sampleRate, sampleCount: samples.length };
|
||||
}
|
||||
|
||||
function covers(startMs, endMs, rate = CAPTURE_SAMPLE_RATE) {
|
||||
const length = Math.ceil((endMs - startMs) * rate / 1000);
|
||||
const ranges = blocks.map(block => sampleRange(block, startMs, endMs, length, rate))
|
||||
.filter(range => range.last > range.first).sort((a, b) => a.first - b.first);
|
||||
let coveredUntil = 0;
|
||||
for (const range of ranges) {
|
||||
if (range.first > coveredUntil) return false;
|
||||
coveredUntil = Math.max(coveredUntil, range.last);
|
||||
}
|
||||
return coveredUntil >= length;
|
||||
}
|
||||
|
||||
function select(startMs, endMs, outputRate = CAPTURE_SAMPLE_RATE) {
|
||||
finiteTime(startMs, "audio pin start");
|
||||
finiteTime(endMs, "audio pin end");
|
||||
if (endMs <= startMs) throw new Error("capture audio interval is empty");
|
||||
const rate = Math.min(CAPTURE_SAMPLE_RATE, Math.trunc(outputRate));
|
||||
const length = Math.ceil((endMs - startMs) * rate / 1000);
|
||||
const output = new Float32Array(length);
|
||||
for (const block of blocks) {
|
||||
const { first, last } = sampleRange(block, startMs, endMs, length, rate);
|
||||
for (let index = first; index < last; index += 1) {
|
||||
const sourceTime = startMs + index * 1000 / rate;
|
||||
const sourceIndex = Math.min(block.samples.length - 1,
|
||||
Math.max(0, Math.floor((sourceTime - block.startMs) * block.sampleRate / 1000)));
|
||||
output[index] = block.samples[sourceIndex];
|
||||
}
|
||||
}
|
||||
return { samples: output, sampleRate: rate, partial: !covers(startMs, endMs, rate) };
|
||||
}
|
||||
|
||||
return {
|
||||
append,
|
||||
covers,
|
||||
select,
|
||||
clear() { blocks.length = 0; },
|
||||
oldestTimestamp: () => blocks[0]?.startMs ?? null,
|
||||
newestTimestamp: () => blocks.at(-1)?.endMs ?? null,
|
||||
size: () => ({ blocks: blocks.length, samples: blocks.reduce((sum, block) => sum + block.samples.length, 0) }),
|
||||
};
|
||||
}
|
||||
|
||||
function setAscii(view, offset, value) {
|
||||
for (let index = 0; index < value.length; index += 1) view.setUint8(offset + index, value.codePointAt(index));
|
||||
}
|
||||
|
||||
export function encodeMonoWav(samples, sampleRate, maxBytes = MAX_WAV_BYTES) {
|
||||
if (!(samples instanceof Float32Array)) throw new Error("WAV input must be mono Float32 samples");
|
||||
if (!Number.isSafeInteger(sampleRate) || sampleRate < 8_000 || sampleRate > CAPTURE_SAMPLE_RATE) {
|
||||
throw new Error("WAV sample rate is invalid");
|
||||
}
|
||||
const byteLength = 44 + samples.length * 2;
|
||||
if (byteLength > maxBytes) throw new Error("Captured audio exceeds the 1 MiB WAV limit.");
|
||||
const output = new ArrayBuffer(byteLength);
|
||||
const view = new DataView(output);
|
||||
setAscii(view, 0, "RIFF");
|
||||
view.setUint32(4, byteLength - 8, true);
|
||||
setAscii(view, 8, "WAVE");
|
||||
setAscii(view, 12, "fmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 2, true);
|
||||
view.setUint16(32, 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
setAscii(view, 36, "data");
|
||||
view.setUint32(40, samples.length * 2, true);
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
const sample = Math.max(-1, Math.min(1, samples[index]));
|
||||
view.setInt16(44 + index * 2, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
|
||||
}
|
||||
return new Uint8Array(output);
|
||||
}
|
||||
|
||||
export function createCapturePinStore({ now = Date.now, lifetimeMs = 2 * 60 * 1000 } = {}) {
|
||||
let pin = null;
|
||||
function releaseExpired() {
|
||||
if (pin && now() >= pin.expiresAt) pin = null;
|
||||
}
|
||||
return {
|
||||
create(value) {
|
||||
releaseExpired();
|
||||
if (pin) throw new Error("Another lookup already owns the capture pin.");
|
||||
const token = crypto.randomUUID();
|
||||
pin = { ...value, token, expiresAt: now() + lifetimeMs };
|
||||
return { ...pin };
|
||||
},
|
||||
get(token) {
|
||||
releaseExpired();
|
||||
return pin?.token === token ? pin : null;
|
||||
},
|
||||
release(token) {
|
||||
if (pin?.token !== token) return false;
|
||||
pin = null;
|
||||
return true;
|
||||
},
|
||||
clear() { pin = null; },
|
||||
active() { releaseExpired(); return pin !== null; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const CAPTURE_TARGET = "hachidori-capture";
|
||||
const CONTENT_TARGET = "hachidori-capture-content";
|
||||
const MAX_TEXT_LENGTH = 4096;
|
||||
const MAX_LINES = 1000;
|
||||
const TYPEWRITER_GAP_MS = 750;
|
||||
const TYPEWRITER_GROWTH_LIMIT = 12;
|
||||
const INLINE_DISPLAY_PATTERN = /^(?:inline|ruby|contents)/u;
|
||||
const OMIT_TEXT_SELECTOR = [
|
||||
"button", "input", "select", "textarea", "[contenteditable]",
|
||||
"rt", "rp", "script", "style", "noscript", "hachidori-host",
|
||||
].join(",");
|
||||
const videoIds = new WeakMap();
|
||||
const trackIds = new WeakMap();
|
||||
const cueIds = new WeakMap();
|
||||
let nextVideoId = 0;
|
||||
let nextTrackId = 0;
|
||||
let nextCueId = 0;
|
||||
let nextRequestId = 0;
|
||||
let nextLineId = 0;
|
||||
let linked = false;
|
||||
let linkedDocumentId = null;
|
||||
let options = null;
|
||||
let documentEpoch = crypto.randomUUID();
|
||||
let selectedVideoCleanup = null;
|
||||
let trackedElement = null;
|
||||
let trackedEpoch = "";
|
||||
let trackedLines = [];
|
||||
let trackedObserver = null;
|
||||
let trackedVisibilityObserver = null;
|
||||
let trackedMountObserver = null;
|
||||
let trackedQueued = false;
|
||||
let pickerCleanup = null;
|
||||
let rootPin = null;
|
||||
let rootPinTail = Promise.resolve();
|
||||
|
||||
const now = () => performance.timeOrigin + performance.now();
|
||||
const normalize = value => typeof value === "string"
|
||||
? value.normalize("NFC").replace(/\s+/gu, " ").trim() : "";
|
||||
|
||||
async function send(type, fields = {}) {
|
||||
const reply = await chrome.runtime.sendMessage({
|
||||
target: CAPTURE_TARGET,
|
||||
type,
|
||||
requestId: `capture-content-${++nextRequestId}`,
|
||||
...fields,
|
||||
});
|
||||
if (!reply?.ok) throw new Error(reply?.error || "The capture service did not reply.");
|
||||
return reply;
|
||||
}
|
||||
|
||||
async function identify(captureSessionId) {
|
||||
return send("hd_capture_content_identify", { captureSessionId });
|
||||
}
|
||||
|
||||
function report(message, fields = {}) {
|
||||
void send("hd_capture_page_status", { message, ...fields }).catch(() => {});
|
||||
}
|
||||
|
||||
function videoId(video) {
|
||||
let id = videoIds.get(video);
|
||||
if (!id) {
|
||||
id = `video-${++nextVideoId}`;
|
||||
videoIds.set(video, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function videoTracks(video) {
|
||||
const tracks = [];
|
||||
try {
|
||||
for (const track of video.textTracks ?? []) {
|
||||
if (track.mode !== "disabled") tracks.push(track);
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
function activeTrackCues(track) {
|
||||
try {
|
||||
return [...(track.activeCues ?? [])];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function trackId(track) {
|
||||
let id = trackIds.get(track);
|
||||
if (!id) {
|
||||
id = `track-${++nextTrackId}`;
|
||||
trackIds.set(track, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function videos() {
|
||||
return [...document.querySelectorAll("video")].map((video, index) => ({
|
||||
id: videoId(video),
|
||||
label: String(video.getAttribute("aria-label") || video.title
|
||||
|| `Video ${index + 1} (${video.videoWidth || "?"}×${video.videoHeight || "?"})`).slice(0, 200),
|
||||
trackCount: videoTracks(video).length,
|
||||
}));
|
||||
}
|
||||
|
||||
function cueId(cue) {
|
||||
if (cue.id) return String(cue.id).slice(0, 160);
|
||||
let id = cueIds.get(cue);
|
||||
if (!id) {
|
||||
id = `cue-${++nextCueId}`;
|
||||
cueIds.set(cue, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function emitBegin(record) {
|
||||
void send("hd_capture_text_begin", { record }).catch(() => {});
|
||||
}
|
||||
|
||||
function emitClose(identity, endMs = now()) {
|
||||
void send("hd_capture_text_close", { identity, endMs }).catch(() => {});
|
||||
}
|
||||
|
||||
function attachVideo(video) {
|
||||
selectedVideoCleanup?.();
|
||||
selectedVideoCleanup = null;
|
||||
if (!video) return;
|
||||
const attachmentEpoch = crypto.randomUUID();
|
||||
let epochCounter = 0;
|
||||
let sourceEpoch = "";
|
||||
let interrupted = true;
|
||||
const active = new Map();
|
||||
const cleanups = [];
|
||||
const trackCleanups = new Map();
|
||||
|
||||
function identity(id) {
|
||||
return { sourceKind: "cue", sourceEpoch, occurrenceId: id };
|
||||
}
|
||||
|
||||
function closeAll(at = now()) {
|
||||
for (const id of active.keys()) emitClose(identity(id), at);
|
||||
active.clear();
|
||||
}
|
||||
|
||||
function resetEpoch(at = now()) {
|
||||
closeAll(at);
|
||||
sourceEpoch = `video:${videoId(video)}:${attachmentEpoch}:${++epochCounter}`;
|
||||
}
|
||||
|
||||
function sync(onsetKnown) {
|
||||
const at = now();
|
||||
if (video.paused || video.seeking || video.ended || !video.isConnected) {
|
||||
closeAll(at);
|
||||
return;
|
||||
}
|
||||
const next = new Map();
|
||||
for (const track of videoTracks(video)) {
|
||||
for (const cue of activeTrackCues(track)) {
|
||||
const text = String(cue.text ?? "");
|
||||
if (!normalize(text) || text.length > MAX_TEXT_LENGTH) continue;
|
||||
const id = `${trackId(track)}:${cueId(cue)}`;
|
||||
next.set(id, text);
|
||||
if (active.get(id) !== text) {
|
||||
emitBegin({
|
||||
sourceKind: "cue",
|
||||
sourceEpoch,
|
||||
occurrenceId: id,
|
||||
text,
|
||||
startMs: at,
|
||||
onsetKnown,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const id of active.keys()) if (!next.has(id)) emitClose(identity(id), at);
|
||||
active.clear();
|
||||
for (const entry of next) active.set(...entry);
|
||||
}
|
||||
|
||||
function listen(target, type, listener) {
|
||||
target.addEventListener(type, listener);
|
||||
cleanups.push(() => target.removeEventListener(type, listener));
|
||||
}
|
||||
|
||||
function bindTracks() {
|
||||
const available = new Set(videoTracks(video));
|
||||
for (const [track, cleanup] of trackCleanups) {
|
||||
if (available.has(track)) continue;
|
||||
cleanup();
|
||||
trackCleanups.delete(track);
|
||||
}
|
||||
for (const track of available) {
|
||||
if (trackCleanups.has(track)) continue;
|
||||
const listener = () => sync(true);
|
||||
track.addEventListener("cuechange", listener);
|
||||
trackCleanups.set(track, () => track.removeEventListener("cuechange", listener));
|
||||
}
|
||||
}
|
||||
|
||||
function interrupt() {
|
||||
closeAll(now());
|
||||
interrupted = true;
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (video.paused || video.seeking || video.ended || !video.isConnected) return;
|
||||
if (interrupted) resetEpoch();
|
||||
interrupted = false;
|
||||
sync(false);
|
||||
}
|
||||
|
||||
function resetAndSync() {
|
||||
resetEpoch();
|
||||
bindTracks();
|
||||
interrupted = video.paused || video.seeking || video.ended;
|
||||
if (!interrupted) sync(false);
|
||||
}
|
||||
|
||||
resetEpoch();
|
||||
bindTracks();
|
||||
listen(video, "play", resume);
|
||||
listen(video, "pause", interrupt);
|
||||
listen(video, "seeking", interrupt);
|
||||
listen(video, "seeked", resetAndSync);
|
||||
listen(video, "ended", interrupt);
|
||||
listen(video, "emptied", () => { resetEpoch(); interrupted = true; });
|
||||
listen(video, "loadedmetadata", resetAndSync);
|
||||
if (video.textTracks?.addEventListener) {
|
||||
listen(video.textTracks, "addtrack", resetAndSync);
|
||||
listen(video.textTracks, "removetrack", resetAndSync);
|
||||
listen(video.textTracks, "change", resetAndSync);
|
||||
}
|
||||
resume();
|
||||
selectedVideoCleanup = () => {
|
||||
closeAll();
|
||||
for (const cleanup of trackCleanups.values()) cleanup();
|
||||
trackCleanups.clear();
|
||||
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
|
||||
};
|
||||
}
|
||||
|
||||
function renderedElement(element, requireLayout = false, style = null) {
|
||||
if (!element?.isConnected || element.hidden || element.getAttribute("aria-hidden") === "true"
|
||||
|| element.closest("hachidori-host")) return false;
|
||||
style ??= getComputedStyle(element);
|
||||
return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse"
|
||||
&& Number(style.opacity) !== 0 && (!requireLayout || element.getClientRects().length > 0);
|
||||
}
|
||||
|
||||
function visible(element) {
|
||||
if (!renderedElement(element, true)) return false;
|
||||
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
|
||||
if (!renderedElement(ancestor)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validTrackedElement(element) {
|
||||
return element instanceof Element && !["HTML", "BODY"].includes(element.tagName)
|
||||
&& !element.closest("input, textarea, select, button, [contenteditable], hachidori-host");
|
||||
}
|
||||
|
||||
function domRangeFor(boundaries, previous) {
|
||||
if (previous?.startContainer === boundaries.startContainer && previous.startOffset === boundaries.startOffset
|
||||
&& previous.endContainer === boundaries.endContainer && previous.endOffset === boundaries.endOffset) {
|
||||
return previous;
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.setStart(boundaries.startContainer, boundaries.startOffset);
|
||||
range.setEnd(boundaries.endContainer, boundaries.endOffset);
|
||||
return range;
|
||||
}
|
||||
|
||||
function extractLines(element) {
|
||||
if (!visible(element)) return [];
|
||||
const lines = [];
|
||||
let text = "";
|
||||
let range = null;
|
||||
const lineBreak = () => {
|
||||
const normalized = normalize(text);
|
||||
if (normalized && lines.length < MAX_LINES) {
|
||||
lines.push({ text: normalized.slice(0, MAX_TEXT_LENGTH),
|
||||
range: domRangeFor(range, trackedLines[lines.length]?.range) });
|
||||
}
|
||||
text = "";
|
||||
range = null;
|
||||
};
|
||||
function appendPart(node, value, offset) {
|
||||
range ??= { startContainer: node, startOffset: offset };
|
||||
range.endContainer = node;
|
||||
range.endOffset = offset + value.length;
|
||||
text += value;
|
||||
}
|
||||
function appendText(node) {
|
||||
const value = node.nodeValue || "";
|
||||
if (!/[\r\n]/u.test(value)) {
|
||||
appendPart(node, value, 0);
|
||||
return;
|
||||
}
|
||||
for (const part of value.matchAll(/[^\r\n]+|[\r\n]+/gu)) {
|
||||
if (/^[\r\n]/u.test(part[0])) {
|
||||
lineBreak();
|
||||
continue;
|
||||
}
|
||||
appendPart(node, part[0], part.index);
|
||||
}
|
||||
}
|
||||
function visit(node, root = false) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
appendText(node);
|
||||
return;
|
||||
}
|
||||
if (!(node instanceof Element) || (!root && node.matches(OMIT_TEXT_SELECTOR))) return;
|
||||
const style = getComputedStyle(node);
|
||||
if (!renderedElement(node, false, style)) return;
|
||||
if (node.tagName === "BR") {
|
||||
lineBreak();
|
||||
return;
|
||||
}
|
||||
const block = !root && !INLINE_DISPLAY_PATTERN.test(style.display);
|
||||
if (block) lineBreak();
|
||||
for (const child of node.childNodes) visit(child);
|
||||
if (block) lineBreak();
|
||||
}
|
||||
visit(element, true);
|
||||
lineBreak();
|
||||
return lines;
|
||||
}
|
||||
|
||||
function domIdentity(line) {
|
||||
return { sourceKind: "dom", sourceEpoch: trackedEpoch, occurrenceId: line.id };
|
||||
}
|
||||
|
||||
function closeTrackedLines(at = now()) {
|
||||
for (const line of trackedLines) emitClose(domIdentity(line), at);
|
||||
trackedLines = [];
|
||||
}
|
||||
|
||||
function isTypewriterContinuation(previous, text, at) {
|
||||
if (!previous || !text || text === previous.text || !text.startsWith(previous.text)) return false;
|
||||
return Array.from(text.slice(previous.text.length)).length <= TYPEWRITER_GROWTH_LIMIT
|
||||
&& at - previous.updatedMs <= TYPEWRITER_GAP_MS;
|
||||
}
|
||||
|
||||
function lineIndexes(values) {
|
||||
const indexesByText = new Map();
|
||||
for (const [index, text] of values.entries()) {
|
||||
const indexes = indexesByText.get(text) ?? [];
|
||||
indexes.push(index);
|
||||
indexesByText.set(text, indexes);
|
||||
}
|
||||
return indexesByText;
|
||||
}
|
||||
|
||||
function retainedLinesFor(values) {
|
||||
const previousByText = lineIndexes(trackedLines.map(line => line.text));
|
||||
const nextByText = lineIndexes(values);
|
||||
const retained = new Map();
|
||||
const usedPrevious = new Set();
|
||||
for (const [text, previousIndexes] of previousByText) {
|
||||
const nextIndexes = nextByText.get(text);
|
||||
if (previousIndexes.length !== 1 || nextIndexes?.length !== 1) continue;
|
||||
retained.set(nextIndexes[0], trackedLines[previousIndexes[0]]);
|
||||
usedPrevious.add(previousIndexes[0]);
|
||||
}
|
||||
return { previousByText, nextByText, retained, usedPrevious };
|
||||
}
|
||||
|
||||
function refreshUnchangedLineRanges(extracted) {
|
||||
if (extracted.length !== trackedLines.length
|
||||
|| !extracted.every((line, index) => line.text === trackedLines[index].text)) return false;
|
||||
for (const [index, line] of trackedLines.entries()) line.range = extracted[index].range;
|
||||
return true;
|
||||
}
|
||||
|
||||
function reconcileTracked(initial = false) {
|
||||
trackedQueued = false;
|
||||
if (!trackedElement) return;
|
||||
if (!trackedElement.isConnected) {
|
||||
clearTrackedArea(false);
|
||||
report("The tracked text area was replaced. Select it again or use the next lookup to relearn it.",
|
||||
{ tracked: false });
|
||||
return;
|
||||
}
|
||||
const at = now();
|
||||
const extracted = extractLines(trackedElement);
|
||||
if (refreshUnchangedLineRanges(extracted)) return;
|
||||
const values = extracted.map(line => line.text);
|
||||
const { previousByText, nextByText, retained, usedPrevious } = retainedLinesFor(values);
|
||||
const previousLastIndex = trackedLines.length - 1;
|
||||
const nextLastIndex = values.length - 1;
|
||||
const previousLast = trackedLines[previousLastIndex];
|
||||
const nextLast = values[nextLastIndex];
|
||||
let typewriter = null;
|
||||
if (isTypewriterContinuation(previousLast, nextLast, at)
|
||||
&& previousByText.get(previousLast.text)?.length === 1
|
||||
&& nextByText.get(nextLast)?.length === 1
|
||||
&& !usedPrevious.has(previousLastIndex) && !retained.has(nextLastIndex)) {
|
||||
typewriter = { index: nextLastIndex, line: previousLast };
|
||||
usedPrevious.add(previousLastIndex);
|
||||
}
|
||||
for (let index = 0; index < trackedLines.length; index += 1) {
|
||||
if (!usedPrevious.has(index)) emitClose(domIdentity(trackedLines[index]), at);
|
||||
}
|
||||
const next = [];
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const text = values[index];
|
||||
const range = extracted[index].range;
|
||||
const previous = retained.get(index);
|
||||
if (previous) {
|
||||
next.push({ ...previous, range });
|
||||
continue;
|
||||
}
|
||||
if (typewriter?.index === index) {
|
||||
emitBegin({ sourceKind: "dom", sourceEpoch: trackedEpoch, occurrenceId: typewriter.line.id,
|
||||
text, startMs: at, onsetKnown: !initial });
|
||||
next.push({ ...typewriter.line, text, range, updatedMs: at });
|
||||
continue;
|
||||
}
|
||||
const line = { id: `line-${++nextLineId}`, text, range, updatedMs: at };
|
||||
emitBegin({ sourceKind: "dom", sourceEpoch: trackedEpoch, occurrenceId: line.id,
|
||||
text, startMs: at, onsetKnown: !initial && !previousByText.has(text) });
|
||||
next.push(line);
|
||||
}
|
||||
trackedLines = next;
|
||||
}
|
||||
|
||||
function queueTrackedReconcile() {
|
||||
if (trackedQueued) return;
|
||||
trackedQueued = true;
|
||||
queueMicrotask(() => reconcileTracked(false));
|
||||
}
|
||||
|
||||
function clearTrackedArea(reportChange = true) {
|
||||
trackedObserver?.disconnect();
|
||||
trackedObserver = null;
|
||||
trackedVisibilityObserver?.disconnect();
|
||||
trackedVisibilityObserver = null;
|
||||
trackedMountObserver?.disconnect();
|
||||
trackedMountObserver = null;
|
||||
closeTrackedLines();
|
||||
trackedElement = null;
|
||||
trackedEpoch = "";
|
||||
if (reportChange) report("Tracked text area cleared.", { tracked: false });
|
||||
}
|
||||
|
||||
function trackArea(element, manual = false) {
|
||||
if (!validTrackedElement(element)) throw new Error("Choose a bounded, non-editable text area.");
|
||||
clearTrackedArea(false);
|
||||
trackedElement = element;
|
||||
trackedEpoch = `dom:${documentEpoch}:${crypto.randomUUID()}`;
|
||||
reconcileTracked(true);
|
||||
trackedObserver = new MutationObserver(queueTrackedReconcile);
|
||||
trackedObserver.observe(element, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style", "hidden", "aria-hidden"],
|
||||
});
|
||||
trackedVisibilityObserver = new MutationObserver(queueTrackedReconcile);
|
||||
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
|
||||
trackedVisibilityObserver.observe(ancestor, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style", "hidden", "aria-hidden"],
|
||||
});
|
||||
}
|
||||
trackedMountObserver = new MutationObserver(queueTrackedReconcile);
|
||||
for (let node = element; node.parentElement; node = node.parentElement) {
|
||||
trackedMountObserver.observe(node.parentElement, { childList: true });
|
||||
}
|
||||
report(manual ? "Text area selected." : "Text area learned from the first lookup.", { tracked: true });
|
||||
}
|
||||
|
||||
function learnArea(candidate) {
|
||||
if (trackedElement || !linked || !options?.mediaCapture.page.domText
|
||||
|| !options.mediaCapture.page.autoLearnArea) return;
|
||||
let element = candidate?.anchor instanceof Element ? candidate.anchor : candidate?.anchor?.parentElement;
|
||||
if (!validTrackedElement(element)) return;
|
||||
let selected = element;
|
||||
for (let depth = 0; depth < 3; depth += 1) {
|
||||
const parent = selected.parentElement;
|
||||
if (!validTrackedElement(parent)) break;
|
||||
const textLength = (parent.textContent || "").length;
|
||||
if (textLength > MAX_TEXT_LENGTH || parent.querySelectorAll("*").length > 100) break;
|
||||
selected = parent;
|
||||
}
|
||||
try { trackArea(selected, false); } catch { /* Conservative auto-learning may decline the page. */ }
|
||||
}
|
||||
|
||||
function occurrenceFor(candidate) {
|
||||
if (!trackedElement || !candidate?.anchor || !trackedElement.contains(candidate.anchor)) return null;
|
||||
// Keep the reader's DOM evidence local: a sentence or selection may occupy
|
||||
// only part of a timed paragraph, and identical lines need their own identity.
|
||||
const range = candidate.anchorRange ?? document.createRange();
|
||||
if (!candidate.anchorRange) range.selectNodeContents(candidate.anchor);
|
||||
const matches = trackedLines.filter(line => line.range?.comparePoint(range.startContainer, range.startOffset) === 0
|
||||
&& line.range.comparePoint(range.endContainer, range.endOffset) === 0);
|
||||
return matches.length === 1 ? { occurrenceId: matches[0].id, occurrenceSourceKind: "dom" } : null;
|
||||
}
|
||||
|
||||
function blockPickerInput(event) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
function picker() {
|
||||
pickerCleanup?.();
|
||||
let candidate = null;
|
||||
const outline = document.createElement("div");
|
||||
outline.setAttribute("aria-hidden", "true");
|
||||
outline.style.cssText = [
|
||||
"all: initial !important", "position: fixed !important", "pointer-events: none !important",
|
||||
"z-index: 2147483647 !important", "border: 3px solid #36d399 !important",
|
||||
"background: rgba(54,211,153,.12) !important", "box-sizing: border-box !important",
|
||||
].join(";");
|
||||
document.documentElement.append(outline);
|
||||
|
||||
function paint(element) {
|
||||
candidate = validTrackedElement(element) ? element : null;
|
||||
if (!candidate) {
|
||||
outline.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const rect = candidate.getBoundingClientRect();
|
||||
outline.style.display = "block";
|
||||
outline.style.left = `${rect.left}px`;
|
||||
outline.style.top = `${rect.top}px`;
|
||||
outline.style.width = `${rect.width}px`;
|
||||
outline.style.height = `${rect.height}px`;
|
||||
}
|
||||
|
||||
function finish(element = null) {
|
||||
pickerCleanup?.();
|
||||
if (element) {
|
||||
try { trackArea(element, true); }
|
||||
catch (error) { report(error.message, { tracked: false }); }
|
||||
} else {
|
||||
report("Text area selection cancelled.", { tracked: Boolean(trackedElement) });
|
||||
}
|
||||
}
|
||||
|
||||
const listeners = [
|
||||
["pointermove", event => {
|
||||
blockPickerInput(event);
|
||||
paint(document.elementFromPoint(event.clientX, event.clientY));
|
||||
}, true],
|
||||
["pointerdown", blockPickerInput, true],
|
||||
["pointerup", blockPickerInput, true],
|
||||
["mousedown", blockPickerInput, true],
|
||||
["mouseup", blockPickerInput, true],
|
||||
["auxclick", blockPickerInput, true],
|
||||
["dblclick", blockPickerInput, true],
|
||||
["contextmenu", blockPickerInput, true],
|
||||
["click", event => { blockPickerInput(event); finish(candidate); }, true],
|
||||
["keydown", event => {
|
||||
blockPickerInput(event);
|
||||
if (event.key === "Escape") finish();
|
||||
else if (event.key === "ArrowUp" && candidate?.parentElement) {
|
||||
paint(candidate.parentElement);
|
||||
}
|
||||
}, true],
|
||||
["keypress", blockPickerInput, true],
|
||||
["keyup", blockPickerInput, true],
|
||||
];
|
||||
for (const [type, listener, capture] of listeners) window.addEventListener(type, listener, capture);
|
||||
pickerCleanup = () => {
|
||||
for (const [type, listener, capture] of listeners) window.removeEventListener(type, listener, capture);
|
||||
outline.remove();
|
||||
pickerCleanup = null;
|
||||
};
|
||||
report("Choose a bounded text area. Arrow Up selects its parent; Escape cancels.", { picking: true });
|
||||
}
|
||||
|
||||
async function link(mediaCapture, captureSessionId) {
|
||||
const epoch = crypto.randomUUID();
|
||||
documentEpoch = epoch;
|
||||
linked = false;
|
||||
const identity = await identify(captureSessionId);
|
||||
if (documentEpoch !== epoch) throw new Error("The reading page link changed before its identity arrived.");
|
||||
if (!mediaCapture || typeof mediaCapture !== "object") {
|
||||
throw new Error("The capture service did not provide page timing settings.");
|
||||
}
|
||||
options = {
|
||||
mediaCapture: {
|
||||
...mediaCapture,
|
||||
texthooker: { ...mediaCapture.texthooker },
|
||||
page: { ...mediaCapture.page },
|
||||
},
|
||||
};
|
||||
linked = true;
|
||||
linkedDocumentId = identity.documentId;
|
||||
const available = videos();
|
||||
if (options.mediaCapture.timingMode !== "recent"
|
||||
&& available.length === 1 && available[0].trackCount > 0
|
||||
&& options.mediaCapture.page.nativeCues) {
|
||||
attachVideo([...document.querySelectorAll("video")][0]);
|
||||
} else {
|
||||
attachVideo(null);
|
||||
}
|
||||
let message = "Reading page linked.";
|
||||
if (available.length > 1) message = "Choose which video supplies native subtitle cues.";
|
||||
else if (available.length === 0) message = "No native video cues found; page text and recent timing remain available.";
|
||||
return { videos: available, message };
|
||||
}
|
||||
|
||||
function lookupSnapshot(candidate, lookupTimeMs = now()) {
|
||||
if (!linked || !options?.mediaCapture.enabled) return null;
|
||||
const pageTiming = options.mediaCapture.timingMode !== "recent";
|
||||
if (pageTiming) learnArea(candidate);
|
||||
const occurrence = pageTiming
|
||||
? occurrenceFor(candidate) ?? { occurrenceId: "", occurrenceSourceKind: "" }
|
||||
: { occurrenceId: "", occurrenceSourceKind: "" };
|
||||
return {
|
||||
documentEpoch,
|
||||
lookup: {
|
||||
lookupText: String(candidate.sentence || candidate.query || "").slice(0, MAX_TEXT_LENGTH),
|
||||
lookupTimeMs,
|
||||
...occurrence,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function pinLookup(snapshot) {
|
||||
if (!snapshot || !linked || snapshot.documentEpoch !== documentEpoch) return null;
|
||||
try {
|
||||
return await send("hd_capture_pin", { lookup: snapshot.lookup });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function releaseToken(pin) {
|
||||
if (pin?.token) {
|
||||
try { await send("hd_capture_release", { token: pin.token }); } catch { /* Expired pins need no cleanup. */ }
|
||||
}
|
||||
}
|
||||
|
||||
function rootLookup(candidate) {
|
||||
const snapshot = lookupSnapshot(candidate);
|
||||
const operation = rootPinTail.then(async () => {
|
||||
const previous = rootPin;
|
||||
rootPin = null;
|
||||
await releaseToken(previous);
|
||||
const pin = await pinLookup(snapshot);
|
||||
rootPin = pin;
|
||||
return pin;
|
||||
});
|
||||
rootPinTail = operation.catch(() => {});
|
||||
return operation;
|
||||
}
|
||||
|
||||
function release(pin) {
|
||||
const operation = rootPinTail.then(async () => {
|
||||
const owned = pin === undefined ? rootPin : pin;
|
||||
if (!owned?.token || rootPin?.token !== owned.token) return;
|
||||
rootPin = null;
|
||||
await releaseToken(owned);
|
||||
});
|
||||
rootPinTail = operation.catch(() => {});
|
||||
return operation;
|
||||
}
|
||||
|
||||
async function unlink() {
|
||||
const epoch = crypto.randomUUID();
|
||||
documentEpoch = epoch;
|
||||
linked = false;
|
||||
await release();
|
||||
if (documentEpoch !== epoch) return { linked };
|
||||
pickerCleanup?.();
|
||||
selectedVideoCleanup?.();
|
||||
clearTrackedArea(false);
|
||||
linked = false;
|
||||
linkedDocumentId = null;
|
||||
options = null;
|
||||
return { linked: false };
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message?.target !== CONTENT_TARGET) return false;
|
||||
Promise.resolve().then(async () => {
|
||||
switch (message.type) {
|
||||
case "hd_capture_link": return link(message.mediaCapture, message.captureSessionId);
|
||||
case "hd_capture_recover": return { linked, documentId: linkedDocumentId };
|
||||
case "hd_capture_video_select": {
|
||||
if (options?.mediaCapture.timingMode === "recent"
|
||||
|| !options?.mediaCapture.page.nativeCues) {
|
||||
throw new Error("Native cue timing is disabled for this capture session.");
|
||||
}
|
||||
const video = [...document.querySelectorAll("video")].find(item => videoId(item) === message.videoId);
|
||||
if (!video) throw new Error("That video is no longer available.");
|
||||
attachVideo(video);
|
||||
return { selected: message.videoId };
|
||||
}
|
||||
case "hd_capture_track_area":
|
||||
if (options?.mediaCapture.timingMode === "recent"
|
||||
|| !options?.mediaCapture.page.domText) {
|
||||
throw new Error("Enable webpage timing and watched page text in Media capture settings first.");
|
||||
}
|
||||
picker();
|
||||
return { picking: true };
|
||||
case "hd_capture_clear_area":
|
||||
clearTrackedArea();
|
||||
return { tracked: false };
|
||||
case "hd_capture_unlink": return unlink();
|
||||
default: throw new Error("Unknown capture command.");
|
||||
}
|
||||
}).then(result => sendResponse(result), error => sendResponse({ error: error.message || String(error) }));
|
||||
return true;
|
||||
});
|
||||
|
||||
window.addEventListener("pagehide", () => {
|
||||
void unlink();
|
||||
});
|
||||
|
||||
globalThis.HDCapture = { rootLookup, release };
|
||||
}());
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
export const CAPTURE_ENCODING_TIMEOUT_MS = 30_000;
|
||||
|
||||
export function encodeCapturedAnimation(frames, options, {
|
||||
WorkerClass = globalThis.Worker,
|
||||
timeoutMs = CAPTURE_ENCODING_TIMEOUT_MS,
|
||||
onProgress = () => {},
|
||||
signal,
|
||||
} = {}) {
|
||||
if (typeof WorkerClass !== "function") return Promise.reject(new Error("Media encoder workers are unavailable."));
|
||||
const worker = new WorkerClass(new URL("./capture-encoder-worker.js", import.meta.url), {
|
||||
type: "module",
|
||||
name: "hachidori-capture-encoder",
|
||||
});
|
||||
const id = crypto.randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timer;
|
||||
const finish = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
worker.terminate();
|
||||
callback(value);
|
||||
};
|
||||
const onAbort = () => finish(reject, new Error("Media encoding was cancelled."));
|
||||
timer = setTimeout(() => {
|
||||
finish(reject, new Error("Media encoding exceeded the 30-second deadline."));
|
||||
}, timeoutMs);
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
worker.addEventListener("error", event => {
|
||||
finish(reject, event.error ?? new Error(event.message || "The media encoder worker stopped."));
|
||||
});
|
||||
worker.addEventListener("message", event => {
|
||||
const response = event.data;
|
||||
if (response?.id !== id) return;
|
||||
if (response.type === "progress") {
|
||||
onProgress(response.completed, response.total, response.heapBytes);
|
||||
return;
|
||||
}
|
||||
if (response.type !== "result") return;
|
||||
if (!response.ok) {
|
||||
finish(reject, new Error(response.error || "Media encoding failed."));
|
||||
return;
|
||||
}
|
||||
finish(resolve, new Uint8Array(response.data));
|
||||
});
|
||||
const transferable = [];
|
||||
const payloadFrames = frames.map(frame => {
|
||||
const data = frame.data instanceof Uint8Array ? frame.data.slice()
|
||||
: new Uint8Array(frame.data).slice();
|
||||
transferable.push(data.buffer);
|
||||
return { ...frame, data: data.buffer };
|
||||
});
|
||||
worker.postMessage({ type: "encode", id, frames: payloadFrames, ...options }, transferable);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import createAvifEncoderModule from "./vendor/avif-encoder.mjs";
|
||||
import { encodeJpegSequence } from "./avif-sequence.js";
|
||||
|
||||
let modulePromise;
|
||||
|
||||
function encoderModule() {
|
||||
modulePromise ??= createAvifEncoderModule({
|
||||
locateFile: name => new URL(`./vendor/${name}`, import.meta.url).href,
|
||||
});
|
||||
return modulePromise;
|
||||
}
|
||||
|
||||
// A dedicated worker receives messages only from its owning Worker. Window
|
||||
// MessageEvent.origin checks do not form a security boundary in this context.
|
||||
self.addEventListener("message", async event => { // NOSONAR -- S2819 applies to Window messaging, not this worker.
|
||||
const request = event.data;
|
||||
if (request?.type !== "encode" || typeof request.id !== "string") return;
|
||||
try {
|
||||
const module = await encoderModule();
|
||||
const data = await encodeJpegSequence(module, request.frames, {
|
||||
endMs: request.endMs,
|
||||
quality: request.videoPreset === "compact" ? 50 : 55,
|
||||
speed: 8,
|
||||
onProgress(completed, total) {
|
||||
self.postMessage({ type: "progress", id: request.id, completed, total,
|
||||
heapBytes: module.HEAPU8.byteLength });
|
||||
},
|
||||
});
|
||||
// WebAssembly memory only grows; this includes any final muxing allocation.
|
||||
self.postMessage({ type: "progress", id: request.id,
|
||||
completed: request.frames.length, total: request.frames.length,
|
||||
heapBytes: module.HEAPU8.byteLength });
|
||||
self.postMessage({ type: "result", id: request.id, ok: true, data: data.buffer }, [data.buffer]);
|
||||
} catch (error) {
|
||||
self.postMessage({ type: "result", id: request.id, ok: false,
|
||||
error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
export function createCaptureFrameEncoder({ WorkerClass = globalThis.Worker,
|
||||
createBitmap = globalThis.createImageBitmap } = {}) {
|
||||
const worker = new WorkerClass(new URL("./capture-frame-worker.js", import.meta.url), {
|
||||
type: "module", name: "hachidori-capture-frames",
|
||||
});
|
||||
let pending = null;
|
||||
let closed = false;
|
||||
const close = (error = new Error("Frame capture stopped.")) => {
|
||||
closed = true;
|
||||
worker.terminate();
|
||||
pending?.reject(error);
|
||||
pending = null;
|
||||
};
|
||||
worker.addEventListener("error", event => {
|
||||
close(event.error ?? new Error(event.message || "The frame encoder stopped."));
|
||||
});
|
||||
worker.addEventListener("message", ({ data }) => {
|
||||
const request = pending;
|
||||
pending = null;
|
||||
if (!request) return;
|
||||
if (data.error) request.reject(new Error(data.error));
|
||||
else request.resolve(data.bytes ? new Uint8Array(data.bytes) : null);
|
||||
});
|
||||
return {
|
||||
async encode(source, dimensions) {
|
||||
if (closed) throw new Error("Frame capture stopped.");
|
||||
// Clone raw VideoFrames without copying pixels; the preview fallback
|
||||
// supplies an ImageBitmap. Only one frame is submitted at a time.
|
||||
const frame = typeof source.clone === "function" ? source.clone() : await createBitmap(source);
|
||||
if (closed || pending) {
|
||||
frame.close();
|
||||
throw new Error(closed ? "Frame capture stopped." : "A capture frame is already being encoded.");
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
pending = { resolve, reject };
|
||||
try { worker.postMessage({ frame, ...dimensions }, [frame]); }
|
||||
catch (error) {
|
||||
pending = null;
|
||||
frame.close();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
close,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { MAX_FRAME_BYTES } from "./capture-buffer.js";
|
||||
|
||||
let canvas = null;
|
||||
let context = null;
|
||||
|
||||
async function encodeFrame({ frame, width, height }) {
|
||||
try {
|
||||
if (!canvas) {
|
||||
canvas = new OffscreenCanvas(width, height);
|
||||
context = canvas.getContext("2d", { alpha: false });
|
||||
if (!context) throw new Error("Could not create the capture frame canvas.");
|
||||
}
|
||||
// Keep the initial output size as the captured window changes shape.
|
||||
const sourceWidth = frame.displayWidth || frame.width;
|
||||
const sourceHeight = frame.displayHeight || frame.height;
|
||||
const scale = Math.min(1, canvas.width / sourceWidth, canvas.height / sourceHeight);
|
||||
const drawWidth = sourceWidth * scale;
|
||||
const drawHeight = sourceHeight * scale;
|
||||
context.fillStyle = "#000";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(frame, (canvas.width - drawWidth) / 2,
|
||||
(canvas.height - drawHeight) / 2, drawWidth, drawHeight);
|
||||
} finally {
|
||||
frame.close();
|
||||
}
|
||||
// Chrome uses idle tasks for main-thread JPEG encoding, delaying hidden
|
||||
// capture documents by about a second. Worker encoding runs directly.
|
||||
let blob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.72 });
|
||||
if (blob.size > MAX_FRAME_BYTES) {
|
||||
blob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.5 });
|
||||
}
|
||||
return blob.size > MAX_FRAME_BYTES ? null : blob.arrayBuffer();
|
||||
}
|
||||
|
||||
self.addEventListener("message", async ({ data }) => {
|
||||
try {
|
||||
const bytes = await encodeFrame(data);
|
||||
self.postMessage({ bytes }, bytes ? [bytes] : []);
|
||||
} catch (error) {
|
||||
self.postMessage({ error: error.message || String(error) });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,683 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { extensionApi as chrome } from "./browser-api.js";
|
||||
import { createCaptureSession } from "./capture-session.js";
|
||||
import { createCaptureFrameEncoder } from "./capture-frame-client.js";
|
||||
import { recordCapturedSpeech } from "./capture-speech.js";
|
||||
import { resolveSpeech } from "./speech.js";
|
||||
import {
|
||||
MAX_TEXTHOOKER_FRAME_LENGTH,
|
||||
MAX_TEXTHOOKER_TEXT_LENGTH,
|
||||
parseTexthookerMessage,
|
||||
} from "./texthooker-protocol.js";
|
||||
|
||||
const CAPTURE_TARGET = "hachidori-capture";
|
||||
const preview = document.createElement("video");
|
||||
preview.muted = true;
|
||||
preview.playsInline = true;
|
||||
document.body.append(preview);
|
||||
let pageStatus = "";
|
||||
let pageVideos = [];
|
||||
const session = createCaptureSession();
|
||||
let config;
|
||||
let starting = false;
|
||||
let captureVersion = 0;
|
||||
let captureDocumentId = "";
|
||||
let stream = null;
|
||||
let frameTimer = null;
|
||||
let frameCallbackId = null;
|
||||
let frameBusy = false;
|
||||
let frameEncoder = null;
|
||||
let frameClockReady = Promise.resolve(null);
|
||||
let resolveFrameClock = null;
|
||||
let mediaClockOriginMs = null;
|
||||
let frameClockOriginMs = null;
|
||||
let videoReader = null;
|
||||
let processedVideoTrack = null;
|
||||
let audioReader = null;
|
||||
let audioTrack = null;
|
||||
let audioContext = null;
|
||||
let audioNode = null;
|
||||
let texthooker = null;
|
||||
let selectedTabId = null;
|
||||
let linkedDocumentId = "";
|
||||
let requestCounter = 0;
|
||||
|
||||
const timestamp = () => performance.timeOrigin + performance.now();
|
||||
const describe = error => error instanceof Error ? error.message || String(error) : String(error);
|
||||
|
||||
async function send(type, fields = {}) {
|
||||
const reply = await chrome.runtime.sendMessage({
|
||||
target: CAPTURE_TARGET,
|
||||
type,
|
||||
requestId: `capture-${++requestCounter}`,
|
||||
...fields,
|
||||
});
|
||||
if (!reply?.ok) throw new Error(reply?.error || "The capture service did not reply.");
|
||||
return reply;
|
||||
}
|
||||
|
||||
async function register() {
|
||||
const reply = await send("hd_capture_register", { linkedPage: session.status().linkedPage });
|
||||
captureDocumentId = reply.documentId;
|
||||
configure(reply.mediaCapture);
|
||||
}
|
||||
|
||||
function captureStatus() {
|
||||
return { ...session.status(), starting, config, pageStatus, videos: pageVideos };
|
||||
}
|
||||
|
||||
function configure(next) {
|
||||
if (JSON.stringify(next) === JSON.stringify(config)) return;
|
||||
if (config) stopCapture("Capture settings changed. Start capture again to use them.");
|
||||
config = structuredClone(next);
|
||||
session.configure(config);
|
||||
}
|
||||
|
||||
function captureDimensions(videoWidth, videoHeight) {
|
||||
const [maxWidth, maxHeight] = config.videoPreset === "compact" ? [480, 270] : [640, 360];
|
||||
const scale = Math.min(1, maxWidth / videoWidth, maxHeight / videoHeight);
|
||||
const width = Math.max(2, Math.floor(videoWidth * scale / 2) * 2);
|
||||
const height = Math.max(2, Math.floor(videoHeight * scale / 2) * 2);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function resetMediaClock() {
|
||||
mediaClockOriginMs = null;
|
||||
frameClockOriginMs = null;
|
||||
frameClockReady = new Promise(resolve => { resolveFrameClock = resolve; });
|
||||
}
|
||||
|
||||
function establishMediaClock(metadata, now) {
|
||||
if (Number.isFinite(mediaClockOriginMs) || !Number.isFinite(metadata?.mediaTime)) return;
|
||||
let frameTime = now;
|
||||
if (Number.isFinite(metadata.presentationTime)) frameTime = metadata.presentationTime;
|
||||
if (Number.isFinite(metadata.captureTime)) frameTime = metadata.captureTime;
|
||||
mediaClockOriginMs = performance.timeOrigin + frameTime - metadata.mediaTime * 1000;
|
||||
}
|
||||
|
||||
function establishTrackMediaClock(mediaTimeMs, observedAtMs = timestamp()) {
|
||||
if (Number.isFinite(frameClockOriginMs)) return;
|
||||
if (!Number.isFinite(mediaTimeMs)) throw new Error("The captured video did not provide media timestamps.");
|
||||
frameClockOriginMs = observedAtMs - mediaTimeMs;
|
||||
resolveFrameClock?.(frameClockOriginMs);
|
||||
resolveFrameClock = null;
|
||||
}
|
||||
|
||||
function clearMediaClock() {
|
||||
resolveFrameClock?.(null);
|
||||
resolveFrameClock = null;
|
||||
mediaClockOriginMs = null;
|
||||
frameClockOriginMs = null;
|
||||
}
|
||||
|
||||
async function captureFrame(dimensions, source, at, ownedStream) {
|
||||
if (!stream || stream !== ownedStream) return;
|
||||
const data = await frameEncoder.encode(source, dimensions);
|
||||
if (!data || stream !== ownedStream) return;
|
||||
session.addFrame({
|
||||
timestampMs: at,
|
||||
...dimensions,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
function startTimestampedFrames(sharedStream, sourceTrack) {
|
||||
if (typeof MediaStreamTrackProcessor !== "function") return false;
|
||||
let ownedTrack;
|
||||
let reader;
|
||||
try {
|
||||
ownedTrack = sourceTrack.clone();
|
||||
reader = new MediaStreamTrackProcessor({ track: ownedTrack }).readable.getReader();
|
||||
} catch {
|
||||
ownedTrack?.stop();
|
||||
return false;
|
||||
}
|
||||
const ownedStream = sharedStream;
|
||||
let dimensions = null;
|
||||
let lastMediaTimestamp = -Infinity;
|
||||
videoReader = reader;
|
||||
processedVideoTrack = ownedTrack;
|
||||
void (async () => {
|
||||
while (stream === ownedStream && videoReader === reader) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done || !value) return;
|
||||
try {
|
||||
const mediaTimeMs = value.timestamp / 1000;
|
||||
if (!Number.isFinite(mediaTimeMs) || mediaTimeMs < lastMediaTimestamp) {
|
||||
throw new Error("The captured video clock was interrupted. Start capture again.");
|
||||
}
|
||||
lastMediaTimestamp = mediaTimeMs;
|
||||
establishTrackMediaClock(mediaTimeMs);
|
||||
const frameTimestamp = frameClockOriginMs + mediaTimeMs;
|
||||
// The source track already has the preset's frame-rate ceiling. Its
|
||||
// irregular frame timestamps must survive intact (e.g. a 30 fps video
|
||||
// delivered at 8 fps alternates 100 ms and 133 ms frame spacings).
|
||||
if (!dimensions) {
|
||||
dimensions = captureDimensions(
|
||||
value.displayWidth || value.codedWidth,
|
||||
value.displayHeight || value.codedHeight,
|
||||
);
|
||||
}
|
||||
await captureFrame(dimensions, value, frameTimestamp, ownedStream);
|
||||
if (stream === ownedStream) session.videoDelivered(frameTimestamp);
|
||||
} finally {
|
||||
value.close();
|
||||
}
|
||||
}
|
||||
})().catch(error => {
|
||||
if (stream === ownedStream && videoReader === reader) {
|
||||
stopCapture(`Video capture stopped: ${describe(error)}`);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function startFallbackFrames() {
|
||||
const video = preview;
|
||||
const dimensions = captureDimensions(video.videoWidth, video.videoHeight);
|
||||
const ownedStream = stream;
|
||||
const fps = config.videoPreset === "compact" ? 6 : 8;
|
||||
const intervalMs = 1000 / fps;
|
||||
let lastStartedAt = -Infinity;
|
||||
let lastTimestamp = -Infinity;
|
||||
const sample = (at, now) => {
|
||||
if (frameBusy || now - lastStartedAt < intervalMs * 0.9) return;
|
||||
lastStartedAt = now;
|
||||
const frameTimestamp = Math.max(at, lastTimestamp + 0.001);
|
||||
lastTimestamp = frameTimestamp;
|
||||
frameBusy = true;
|
||||
void captureFrame(dimensions, video, frameTimestamp, ownedStream)
|
||||
.then(() => { if (stream === ownedStream) session.videoDelivered(frameTimestamp); })
|
||||
.catch(error => {
|
||||
if (stream === ownedStream) stopCapture(`Video capture stopped: ${describe(error)}`);
|
||||
})
|
||||
.finally(() => { if (stream === ownedStream) frameBusy = false; });
|
||||
};
|
||||
if (typeof video.requestVideoFrameCallback === "function") {
|
||||
const onFrame = (now, metadata) => {
|
||||
frameCallbackId = video.requestVideoFrameCallback(onFrame);
|
||||
establishMediaClock(metadata, now);
|
||||
let at = performance.timeOrigin + now;
|
||||
if (Number.isFinite(metadata.captureTime)) at = performance.timeOrigin + metadata.captureTime;
|
||||
if (Number.isFinite(mediaClockOriginMs) && Number.isFinite(metadata.mediaTime)) {
|
||||
at = mediaClockOriginMs + metadata.mediaTime * 1000;
|
||||
}
|
||||
sample(at, now);
|
||||
};
|
||||
frameCallbackId = video.requestVideoFrameCallback(onFrame);
|
||||
}
|
||||
frameTimer = setInterval(() => {
|
||||
sample(timestamp(), performance.now());
|
||||
}, Math.round(1000 / fps));
|
||||
}
|
||||
|
||||
function startFrames(sharedStream, sourceTrack) {
|
||||
frameEncoder = createCaptureFrameEncoder();
|
||||
if (!startTimestampedFrames(sharedStream, sourceTrack)) startFallbackFrames();
|
||||
}
|
||||
|
||||
function audioTimestampOrigin(mediaTimeMs, videoOriginMs, observedAtMs = timestamp()) {
|
||||
// Chrome 150 exposes the same monotonic clock for both raw tracks. Chrome
|
||||
// 152 makes AudioData timestamps page-relative; video retains the raw clock.
|
||||
// Choose between those observed clock domains, never the preview's unrelated
|
||||
// playback mediaTime, and keep that origin for the entire audio stream.
|
||||
const pageOriginMs = performance.timeOrigin;
|
||||
return Math.abs(pageOriginMs + mediaTimeMs - observedAtMs)
|
||||
< Math.abs(videoOriginMs + mediaTimeMs - observedAtMs) ? pageOriginMs : videoOriginMs;
|
||||
}
|
||||
|
||||
function createAudioSampleClock(originMs, observedNow = timestamp) {
|
||||
let offsetMs = 0;
|
||||
let nextMs = null;
|
||||
let sampleRate = null;
|
||||
let previousBlockMs = null;
|
||||
return value => {
|
||||
const rawMs = originMs + value.timestamp / 1000;
|
||||
const observedMs = rawMs + offsetMs;
|
||||
const blockMs = value.numberOfFrames * 1000 / value.sampleRate;
|
||||
if (nextMs === null) {
|
||||
sampleRate = value.sampleRate;
|
||||
previousBlockMs = blockMs;
|
||||
nextMs = observedMs + blockMs;
|
||||
return observedMs;
|
||||
}
|
||||
// AudioData timestamps are privacy-rounded (100 us on observed Chrome
|
||||
// 152). Count delivered samples instead of making holes at rounded block
|
||||
// boundaries. Forward jumps remain real gaps. A reset or sample-rate
|
||||
// change starts a new local epoch after a gap so later blocks can recover.
|
||||
let startMs = nextMs;
|
||||
if (value.sampleRate !== sampleRate || Math.abs(observedMs - nextMs) >= blockMs / 2) {
|
||||
if (value.sampleRate === sampleRate && observedMs > nextMs) {
|
||||
startMs = observedMs;
|
||||
} else {
|
||||
const minimum = nextMs + Math.max(previousBlockMs, blockMs);
|
||||
startMs = Math.max(minimum, observedNow() - blockMs);
|
||||
offsetMs = startMs - rawMs;
|
||||
}
|
||||
}
|
||||
sampleRate = value.sampleRate;
|
||||
previousBlockMs = blockMs;
|
||||
nextMs = startMs + blockMs;
|
||||
return startMs;
|
||||
};
|
||||
}
|
||||
|
||||
function createMonoAudioMixer() {
|
||||
let mono = new Float32Array(0);
|
||||
let plane = new Float32Array(0);
|
||||
return value => {
|
||||
if (mono.length !== value.numberOfFrames) {
|
||||
mono = new Float32Array(value.numberOfFrames);
|
||||
plane = new Float32Array(value.numberOfFrames);
|
||||
} else {
|
||||
mono.fill(0);
|
||||
}
|
||||
for (let channel = 0; channel < value.numberOfChannels; channel += 1) {
|
||||
value.copyTo(plane, { planeIndex: channel, format: "f32-planar" });
|
||||
for (let frame = 0; frame < mono.length; frame += 1) mono[frame] += plane[frame];
|
||||
}
|
||||
if (value.numberOfChannels > 1) {
|
||||
for (let frame = 0; frame < mono.length; frame += 1) mono[frame] /= value.numberOfChannels;
|
||||
}
|
||||
return mono;
|
||||
};
|
||||
}
|
||||
|
||||
function startTimestampedAudio(sharedStream, sourceTrack) {
|
||||
let ownedTrack;
|
||||
let reader;
|
||||
try {
|
||||
ownedTrack = sourceTrack.clone();
|
||||
reader = new MediaStreamTrackProcessor({ track: ownedTrack }).readable.getReader();
|
||||
} catch {
|
||||
ownedTrack?.stop();
|
||||
return false;
|
||||
}
|
||||
const ownedStream = sharedStream;
|
||||
audioReader = reader;
|
||||
audioTrack = ownedTrack;
|
||||
void (async () => {
|
||||
const videoOriginMs = await frameClockReady;
|
||||
if (!Number.isFinite(videoOriginMs) || stream !== ownedStream || audioReader !== reader) return;
|
||||
let sampleClock = null;
|
||||
const mix = createMonoAudioMixer();
|
||||
while (stream === ownedStream && audioReader === reader) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done || !value) return;
|
||||
try {
|
||||
sampleClock ??= createAudioSampleClock(audioTimestampOrigin(value.timestamp / 1000, videoOriginMs));
|
||||
const startMs = sampleClock(value);
|
||||
session.addAudio({
|
||||
startMs,
|
||||
sampleRate: value.sampleRate,
|
||||
samples: mix(value),
|
||||
});
|
||||
} finally {
|
||||
value.close();
|
||||
}
|
||||
}
|
||||
})().catch(error => {
|
||||
if (stream === ownedStream && audioReader === reader) {
|
||||
stopCapture(`Audio capture stopped: ${describe(error)}`);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function startWorkletAudio(sharedStream, sourceTrack) {
|
||||
const ownedContext = new AudioContext({ sampleRate: 48_000, latencyHint: "interactive" });
|
||||
audioContext = ownedContext;
|
||||
await ownedContext.audioWorklet.addModule("capture-audio-worklet.js");
|
||||
if (stream !== sharedStream || audioContext !== ownedContext) return;
|
||||
const source = ownedContext.createMediaStreamSource(new MediaStream([sourceTrack]));
|
||||
const ownedNode = new AudioWorkletNode(ownedContext, "hachidori-capture-audio");
|
||||
audioNode = ownedNode;
|
||||
const silence = ownedContext.createGain();
|
||||
silence.gain.value = 0;
|
||||
let originMs = null;
|
||||
ownedNode.port.addEventListener("message", event => {
|
||||
if (stream !== sharedStream || audioContext !== ownedContext) return;
|
||||
if (event.data.discontinuity) return;
|
||||
if (!Number.isFinite(originMs)) return;
|
||||
const samples = new Float32Array(event.data.samples);
|
||||
session.addAudio({
|
||||
startMs: originMs + event.data.startFrame * 1000 / ownedContext.sampleRate,
|
||||
sampleRate: ownedContext.sampleRate,
|
||||
samples,
|
||||
});
|
||||
});
|
||||
ownedNode.port.start();
|
||||
source.connect(ownedNode).connect(silence).connect(ownedContext.destination);
|
||||
await ownedContext.resume();
|
||||
if (stream !== sharedStream || audioContext !== ownedContext) return;
|
||||
originMs = timestamp() - ownedContext.currentTime * 1000;
|
||||
}
|
||||
|
||||
async function startAudio(sharedStream) {
|
||||
const sourceTrack = sharedStream.getAudioTracks()[0];
|
||||
if (!sourceTrack) return;
|
||||
if (!globalThis.__hachidoriForceAudioWorklet
|
||||
&& config.includeAnimation && videoReader
|
||||
&& typeof MediaStreamTrackProcessor === "function"
|
||||
&& startTimestampedAudio(sharedStream, sourceTrack)) {
|
||||
return;
|
||||
}
|
||||
await startWorkletAudio(sharedStream, sourceTrack);
|
||||
}
|
||||
|
||||
function stopTexthooker() {
|
||||
texthooker?.stop();
|
||||
texthooker = null;
|
||||
}
|
||||
|
||||
function createTexthooker() {
|
||||
let socket = null;
|
||||
let retryTimer = null;
|
||||
let stopped = false;
|
||||
let attempt = 0;
|
||||
let sequence = 0;
|
||||
let connectionEpoch = "";
|
||||
let sourceEpoch = "";
|
||||
let currentSession = "";
|
||||
const open = new Map();
|
||||
|
||||
function closeOpen(at = timestamp()) {
|
||||
for (const record of open.values()) session.textClose(record, at);
|
||||
open.clear();
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (stopped) return;
|
||||
session.setTexthooker("Disconnected", false);
|
||||
const delay = Math.min(10_000, 500 * 2 ** Math.min(attempt, 5));
|
||||
attempt += 1;
|
||||
retryTimer = setTimeout(connect, delay);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (stopped) return;
|
||||
connectionEpoch = crypto.randomUUID();
|
||||
sourceEpoch = connectionEpoch;
|
||||
currentSession = "";
|
||||
sequence = 0;
|
||||
session.setTexthooker("Connecting", false);
|
||||
try {
|
||||
socket = new WebSocket(config.texthooker.url);
|
||||
} catch {
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
const owned = socket;
|
||||
owned.addEventListener("open", () => {
|
||||
if (socket !== owned) return;
|
||||
attempt = 0;
|
||||
session.setTexthooker("Connected — waiting for live text", false);
|
||||
});
|
||||
owned.addEventListener("message", event => {
|
||||
if (socket !== owned) return;
|
||||
if (typeof event.data !== "string" || event.data.length > MAX_TEXTHOOKER_FRAME_LENGTH) return;
|
||||
const parsed = parseTexthookerMessage(config.texthooker.format, event.data);
|
||||
if (!parsed || (parsed.type === "line" && parsed.text.length > MAX_TEXTHOOKER_TEXT_LENGTH)) return;
|
||||
const at = timestamp();
|
||||
if (parsed.type === "reset") {
|
||||
closeOpen(at);
|
||||
sourceEpoch = `${connectionEpoch}:reset:${++sequence}`;
|
||||
session.setTexthooker("Connected — waiting for live text", false);
|
||||
return;
|
||||
}
|
||||
if (parsed.sessionId && parsed.sessionId !== currentSession) {
|
||||
closeOpen(at);
|
||||
currentSession = parsed.sessionId;
|
||||
sourceEpoch = `${connectionEpoch}:${currentSession}`;
|
||||
}
|
||||
const occurrenceId = parsed.id || `${connectionEpoch}:${++sequence}`;
|
||||
if (!open.has(occurrenceId)) closeOpen(at);
|
||||
const record = {
|
||||
sourceKind: "texthooker",
|
||||
sourceId: "loopback-websocket",
|
||||
sourceEpoch,
|
||||
occurrenceId,
|
||||
text: parsed.text,
|
||||
startMs: at,
|
||||
};
|
||||
session.textBegin(record);
|
||||
open.set(occurrenceId, record);
|
||||
session.setTexthooker("Active", true);
|
||||
});
|
||||
owned.addEventListener("close", () => {
|
||||
if (socket !== owned) return;
|
||||
socket = null;
|
||||
closeOpen();
|
||||
schedule();
|
||||
});
|
||||
owned.addEventListener("error", () => owned.close());
|
||||
}
|
||||
|
||||
connect();
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(retryTimer);
|
||||
closeOpen();
|
||||
const active = socket;
|
||||
socket = null;
|
||||
active?.close(1000, "Capture stopped");
|
||||
session.setTexthooker("Disconnected", false);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateCaptureSource(requested) {
|
||||
const videoTrack = requested.getVideoTracks()[0];
|
||||
if (!videoTrack) {
|
||||
requested.getTracks().forEach(track => track.stop());
|
||||
throw new Error("The selected source did not provide video.");
|
||||
}
|
||||
if (!config.includeAnimation && config.includeCapturedAudio
|
||||
&& requested.getAudioTracks().length === 0) {
|
||||
requested.getTracks().forEach(track => track.stop());
|
||||
throw new Error("The selected source did not provide audio for media capture.");
|
||||
}
|
||||
return videoTrack;
|
||||
}
|
||||
|
||||
function watchCaptureSource(requested) {
|
||||
for (const track of requested.getTracks()) {
|
||||
track.addEventListener("ended", () => {
|
||||
if (stream === requested) stopCapture(`The shared ${track.kind} source ended.`);
|
||||
});
|
||||
track.addEventListener("mute", () => {
|
||||
if (stream === requested) {
|
||||
stopCapture(`The shared ${track.kind} source became unavailable. Start capture again when it is available.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function startCapture() {
|
||||
if (starting || stream) throw new Error("A capture source is already being selected or recorded.");
|
||||
if (!config?.enabled) throw new Error("Enable media capture in Settings first.");
|
||||
const version = ++captureVersion;
|
||||
const frameRate = config.videoPreset === "compact" ? 6 : 8;
|
||||
starting = true;
|
||||
try {
|
||||
const requested = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
frameRate: { ideal: frameRate, max: frameRate },
|
||||
},
|
||||
audio: config.includeCapturedAudio ? {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
} : false,
|
||||
monitorTypeSurfaces: "include",
|
||||
selfBrowserSurface: "exclude",
|
||||
surfaceSwitching: "exclude",
|
||||
});
|
||||
if (version !== captureVersion) {
|
||||
requested.getTracks().forEach(track => track.stop());
|
||||
return;
|
||||
}
|
||||
const videoTrack = validateCaptureSource(requested);
|
||||
const settings = videoTrack.getSettings();
|
||||
stream = requested;
|
||||
resetMediaClock();
|
||||
session.start({
|
||||
sourceName: videoTrack.label || "Shared media",
|
||||
displaySurface: settings.displaySurface || "browser",
|
||||
audioAvailable: requested.getAudioTracks().length > 0,
|
||||
});
|
||||
watchCaptureSource(requested);
|
||||
preview.srcObject = requested;
|
||||
preview.hidden = false;
|
||||
await preview.play();
|
||||
if (stream !== requested) return;
|
||||
if (config.includeAnimation) startFrames(requested, videoTrack);
|
||||
if (config.includeCapturedAudio) await startAudio(requested);
|
||||
if (stream !== requested) return;
|
||||
if (config.timingMode === "auto" && config.texthooker.enabled) texthooker = createTexthooker();
|
||||
} catch (error) {
|
||||
if (version === captureVersion) stopCapture(describe(error));
|
||||
} finally {
|
||||
if (version === captureVersion) starting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function stopCapture(error = "") {
|
||||
const retired = session.status();
|
||||
captureVersion += 1;
|
||||
starting = false;
|
||||
if (frameCallbackId !== null) {
|
||||
preview.cancelVideoFrameCallback(frameCallbackId);
|
||||
frameCallbackId = null;
|
||||
}
|
||||
clearInterval(frameTimer);
|
||||
frameTimer = null;
|
||||
frameBusy = false;
|
||||
frameEncoder?.close();
|
||||
frameEncoder = null;
|
||||
const frames = videoReader;
|
||||
videoReader = null;
|
||||
void frames?.cancel().catch(() => {});
|
||||
processedVideoTrack?.stop();
|
||||
processedVideoTrack = null;
|
||||
stopTexthooker();
|
||||
const reader = audioReader;
|
||||
audioReader = null;
|
||||
void reader?.cancel().catch(() => {});
|
||||
audioTrack?.stop();
|
||||
audioTrack = null;
|
||||
audioNode?.disconnect();
|
||||
audioNode = null;
|
||||
void audioContext?.close();
|
||||
audioContext = null;
|
||||
clearMediaClock();
|
||||
stream?.getTracks().forEach(track => track.stop());
|
||||
stream = null;
|
||||
preview.srcObject = null;
|
||||
preview.hidden = true;
|
||||
if (retired.linkedPage) {
|
||||
void send("hd_capture_host_stopped", { captureDocumentId,
|
||||
captureSessionId: retired.captureSessionId, linkedPage: retired.linkedPage }).catch(() => {});
|
||||
}
|
||||
selectedTabId = null;
|
||||
linkedDocumentId = "";
|
||||
pageVideos = [];
|
||||
pageStatus = "";
|
||||
session.stop(error);
|
||||
}
|
||||
|
||||
function linked(message) {
|
||||
return selectedTabId === message.tabId && linkedDocumentId === message.documentId;
|
||||
}
|
||||
|
||||
function captureJobOwner(message) {
|
||||
if (message.tabId === undefined && message.documentId === undefined) return null;
|
||||
return { tabId: message.tabId, documentId: message.documentId };
|
||||
}
|
||||
|
||||
function beginCaptureExport(message) {
|
||||
if (!linked(message)) throw new Error("This export is not from the linked reading page.");
|
||||
return session.beginExport(message.token, message.requirements, captureJobOwner(message));
|
||||
}
|
||||
|
||||
function linkCaptureReader(message) {
|
||||
const status = session.status();
|
||||
if (status.state !== "recording" || message.captureSessionId !== status.captureSessionId) {
|
||||
throw new Error("The capture session changed before the reading page was linked.");
|
||||
}
|
||||
selectedTabId = message.page.tabId;
|
||||
linkedDocumentId = message.page.documentId;
|
||||
session.setLinkedPage(message.page);
|
||||
pageStatus = message.page.message || "Reading page linked.";
|
||||
pageVideos = message.page.videos;
|
||||
return session.status();
|
||||
}
|
||||
|
||||
// Kept inline (test/capture-routing.test.mjs evaluates this file's source in
|
||||
// a vm context without its imports). Uint8Array.prototype.toBase64 does the
|
||||
// megabyte in half a millisecond where the loop takes about forty.
|
||||
function bytesToBase64(data) {
|
||||
if (typeof data.toBase64 === "function") return data.toBase64();
|
||||
let binary = "";
|
||||
for (let offset = 0; offset < data.length; offset += 0x8000) {
|
||||
binary += String.fromCodePoint(...data.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export async function recordSpeechAudio(source, term, signal, { record = true } = {}) {
|
||||
session.assertAudioCapture();
|
||||
if (!record) {
|
||||
await resolveSpeech(globalThis, source, term, signal);
|
||||
return { recordingRequired: true };
|
||||
}
|
||||
return recordCapturedSpeech(globalThis, session, source, term, signal, { now: timestamp });
|
||||
}
|
||||
|
||||
export async function handleCaptureMessage(message) {
|
||||
if (message.captureDocumentId !== captureDocumentId) throw new Error("The capture host identity changed.");
|
||||
switch (message.type) {
|
||||
case "hd_capture_configure": configure(message.mediaCapture); return captureStatus();
|
||||
case "hd_capture_status": return captureStatus();
|
||||
case "hd_capture_start": await startCapture(); return captureStatus();
|
||||
case "hd_capture_stop": stopCapture(); return captureStatus();
|
||||
case "hd_capture_linked": return linkCaptureReader(message);
|
||||
case "hd_capture_unlinked":
|
||||
if (!linked(message)) return { ignored: true };
|
||||
selectedTabId = null;
|
||||
linkedDocumentId = "";
|
||||
session.setLinkedPage(null);
|
||||
pageStatus = message.reason || "The reading page navigated. Link it again.";
|
||||
pageVideos = [];
|
||||
return session.status();
|
||||
case "hd_capture_text_begin":
|
||||
if (!linked(message)) return { ignored: true };
|
||||
return session.textBegin(message.record);
|
||||
case "hd_capture_text_close":
|
||||
if (!linked(message)) return { ignored: true };
|
||||
return session.textClose(message.identity, message.endMs);
|
||||
case "hd_capture_text_source_close":
|
||||
if (!linked(message)) return { ignored: true };
|
||||
return session.closeTextSource(message.sourceKind, message.sourceId, message.sourceEpoch, message.endMs);
|
||||
case "hd_capture_page_status":
|
||||
if (!linked(message)) return { ignored: true };
|
||||
pageStatus = String(message.message || "").slice(0, 500);
|
||||
return { displayed: true };
|
||||
case "hd_capture_pin":
|
||||
if (!linked(message)) throw new Error("This lookup is not from the linked reading page.");
|
||||
return session.pinLookup(message.lookup);
|
||||
case "hd_capture_release": return { released: session.releasePin(message.token) };
|
||||
case "hd_capture_export": return beginCaptureExport(message);
|
||||
case "hd_capture_job_status": return session.jobStatus(message.jobId, captureJobOwner(message));
|
||||
case "hd_capture_asset": {
|
||||
const asset = session.jobAsset(message.jobId, message.kind);
|
||||
return { filename: asset.filename, data: bytesToBase64(asset.data) };
|
||||
}
|
||||
case "hd_capture_complete": return { completed: session.completeExport(message.jobId) };
|
||||
case "hd_capture_cancel": return { cancelled: session.cancelExport(message.jobId, captureJobOwner(message)) };
|
||||
default: throw new Error("Unknown capture page request.");
|
||||
}
|
||||
}
|
||||
|
||||
await register();
|
||||
@@ -0,0 +1,559 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import {
|
||||
createAudioRing,
|
||||
createCapturePinStore,
|
||||
createFrameRing,
|
||||
encodeMonoWav,
|
||||
} from "./capture-buffer.js";
|
||||
import { encodeCapturedAnimation } from "./capture-encoder-client.js";
|
||||
import { MAX_ANIMATED_AVIF_BYTES } from "./avif-sequence.js";
|
||||
import { createCaptureTimeline, resolveCaptureInterval } from "./capture-timeline.js";
|
||||
|
||||
const JOB_LIFETIME_MS = 2 * 60 * 1000;
|
||||
export const MEDIA_DRAIN_MS = 250;
|
||||
|
||||
function safeAssetId(value = crypto.randomUUID()) {
|
||||
const id = value.toLowerCase().replace(/[^a-z0-9]/gu, "");
|
||||
if (!id) throw new Error("Could not allocate a media asset identity.");
|
||||
return id;
|
||||
}
|
||||
|
||||
function waitUntil(deadline, now, setTimer) {
|
||||
const remaining = Math.max(0, deadline - now());
|
||||
if (remaining === 0) return Promise.resolve();
|
||||
return new Promise(resolve => setTimer(resolve, remaining));
|
||||
}
|
||||
|
||||
function assertJobOwner(job, owner) {
|
||||
if (owner && (owner.tabId !== job.owner?.tabId || owner.documentId !== job.owner?.documentId)) {
|
||||
throw new Error("This reading document does not own the media export job.");
|
||||
}
|
||||
}
|
||||
|
||||
export function createCaptureSession({
|
||||
now = () => performance.timeOrigin + performance.now(),
|
||||
wallNow = Date.now,
|
||||
setTimer = setTimeout,
|
||||
clearTimer = clearTimeout,
|
||||
encodeAnimation = encodeCapturedAnimation,
|
||||
randomId = () => crypto.randomUUID(),
|
||||
} = {}) {
|
||||
let config = null;
|
||||
let captureSessionId = "";
|
||||
let state = "disabled";
|
||||
let statusError = "";
|
||||
let mediaSource = null;
|
||||
let capturedAudioAvailable = false;
|
||||
let linkedPage = null;
|
||||
let texthookerStatus = "Disabled";
|
||||
let texthookerActive = false;
|
||||
let texthookerSource = null;
|
||||
let videoDeliveredThroughMs = -Infinity;
|
||||
const timeline = createCaptureTimeline();
|
||||
let frameRing = null;
|
||||
let audioRing = null;
|
||||
let pins = createCapturePinStore({ now: wallNow });
|
||||
const jobs = new Map();
|
||||
let activeJobId = null;
|
||||
|
||||
function status() {
|
||||
const frameSize = frameRing?.size() ?? { count: 0, bytes: 0 };
|
||||
const audioSize = audioRing?.size() ?? { blocks: 0, samples: 0 };
|
||||
return {
|
||||
state,
|
||||
error: statusError,
|
||||
captureSessionId,
|
||||
mediaSource,
|
||||
linkedPage,
|
||||
texthookerStatus,
|
||||
texthookerActive,
|
||||
history: {
|
||||
frameCount: frameSize.count,
|
||||
frameBytes: frameSize.bytes,
|
||||
audioBlocks: audioSize.blocks,
|
||||
audioSamples: audioSize.samples,
|
||||
frameOldestMs: frameRing?.oldestTimestamp() ?? null,
|
||||
frameNewestMs: frameRing?.newestTimestamp() ?? null,
|
||||
audioOldestMs: audioRing?.oldestTimestamp() ?? null,
|
||||
audioNewestMs: audioRing?.newestTimestamp() ?? null,
|
||||
oldestMs: oldestRequiredTimestamp(),
|
||||
newestMs: (() => {
|
||||
const newest = Math.max(frameRing?.newestTimestamp() ?? -Infinity, audioRing?.newestTimestamp() ?? -Infinity);
|
||||
return Number.isFinite(newest) ? newest : null;
|
||||
})(),
|
||||
},
|
||||
pinActive: pins.active() || activeJobId !== null,
|
||||
};
|
||||
}
|
||||
|
||||
function oldestRequiredTimestamp() {
|
||||
if (!config) return null;
|
||||
const values = [];
|
||||
if (config.includeAnimation) values.push(frameRing?.oldestTimestamp());
|
||||
if (config.includeCapturedAudio && capturedAudioAvailable) values.push(audioRing?.oldestTimestamp());
|
||||
const available = values.filter(Number.isFinite);
|
||||
return available.length ? Math.max(...available) : null;
|
||||
}
|
||||
|
||||
function configure(value) {
|
||||
cancelOwnedCapture("Capture settings changed.");
|
||||
config = structuredClone(value);
|
||||
frameRing = createFrameRing({ maxAgeMs: config.historySeconds * 1000 });
|
||||
audioRing = createAudioRing({ maxAgeMs: config.historySeconds * 1000 });
|
||||
pins = createCapturePinStore({ now: wallNow });
|
||||
timeline.reset();
|
||||
jobs.clear();
|
||||
activeJobId = null;
|
||||
texthookerActive = false;
|
||||
texthookerSource = null;
|
||||
videoDeliveredThroughMs = -Infinity;
|
||||
texthookerStatus = config.timingMode === "auto" && config.texthooker.enabled
|
||||
? "Disconnected" : "Disabled";
|
||||
state = config.enabled ? "stopped" : "disabled";
|
||||
statusError = "";
|
||||
}
|
||||
|
||||
function start({ sourceName = "Shared tab", displaySurface = "browser", audioAvailable = true } = {}) {
|
||||
if (!config?.enabled) throw new Error("Enable media capture in Settings first.");
|
||||
captureSessionId = randomId();
|
||||
capturedAudioAvailable = audioAvailable === true;
|
||||
mediaSource = {
|
||||
name: String(sourceName).slice(0, 200),
|
||||
displaySurface,
|
||||
audioAvailable: capturedAudioAvailable,
|
||||
};
|
||||
state = "recording";
|
||||
statusError = "";
|
||||
return status();
|
||||
}
|
||||
|
||||
function stop(error = "") {
|
||||
cancelOwnedCapture(error || "Capture stopped.");
|
||||
state = config ? "stopped" : "disabled";
|
||||
statusError = error;
|
||||
captureSessionId = "";
|
||||
mediaSource = null;
|
||||
capturedAudioAvailable = false;
|
||||
linkedPage = null;
|
||||
texthookerActive = false;
|
||||
texthookerSource = null;
|
||||
videoDeliveredThroughMs = -Infinity;
|
||||
texthookerStatus = config?.timingMode === "auto" && config.texthooker.enabled
|
||||
? "Disconnected" : "Disabled";
|
||||
frameRing?.clear();
|
||||
audioRing?.clear();
|
||||
timeline.reset();
|
||||
pins.clear();
|
||||
jobs.clear();
|
||||
activeJobId = null;
|
||||
}
|
||||
|
||||
function requireRecording() {
|
||||
if (state !== "recording" || !captureSessionId) throw new Error("Start capture before looking up text.");
|
||||
}
|
||||
|
||||
function addFrame(frame) {
|
||||
requireRecording();
|
||||
const added = frameRing.append(frame);
|
||||
videoDelivered(added.timestampMs);
|
||||
return added;
|
||||
}
|
||||
|
||||
function videoDelivered(timestampMs) {
|
||||
requireRecording();
|
||||
videoDeliveredThroughMs = Math.max(videoDeliveredThroughMs, timestampMs);
|
||||
currentPin()?.checkDrain?.();
|
||||
}
|
||||
|
||||
function addAudio(block) {
|
||||
requireRecording();
|
||||
const added = audioRing.append(block);
|
||||
currentPin()?.checkDrain?.();
|
||||
return added;
|
||||
}
|
||||
|
||||
function assertAudioCapture() {
|
||||
if (state !== "recording" || !captureSessionId) {
|
||||
throw new Error("Start media capture with shared audio before attaching browser text-to-speech to Anki.");
|
||||
}
|
||||
if (!config.includeCapturedAudio || !capturedAudioAvailable) {
|
||||
throw new Error("The active media capture has no shared audio. Start capture again and enable audio in Chrome's share picker.");
|
||||
}
|
||||
}
|
||||
|
||||
function selectAudio(startMs, endMs) {
|
||||
assertAudioCapture();
|
||||
const selected = audioRing.select(startMs, endMs);
|
||||
if (selected.partial) {
|
||||
throw new Error("The active media capture did not record all browser text-to-speech samples. Try adding the note again after shared audio resumes.");
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function setLinkedPage(page) {
|
||||
const previous = linkedPage;
|
||||
if (previous && (previous.tabId !== page?.tabId || previous.documentId !== page?.documentId)
|
||||
&& activePin) releasePin(activePin.token);
|
||||
if (previous) {
|
||||
const sourceId = `tab:${previous.tabId}`;
|
||||
const endMs = now();
|
||||
for (const sourceKind of ["cue", "dom"]) {
|
||||
for (const record of timeline.closeSource(sourceKind, sourceId, undefined, endMs)) {
|
||||
adjustOpenPin(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
linkedPage = page ? {
|
||||
tabId: page.tabId,
|
||||
documentId: page.documentId,
|
||||
title: String(page.title || "").slice(0, 200),
|
||||
url: String(page.url || "").slice(0, 2048),
|
||||
} : null;
|
||||
}
|
||||
|
||||
function textBegin(record) {
|
||||
requireRecording();
|
||||
const begun = timeline.begin(record);
|
||||
if (record.sourceKind === "texthooker") {
|
||||
texthookerSource = { sourceId: record.sourceId, sourceEpoch: record.sourceEpoch };
|
||||
}
|
||||
return begun;
|
||||
}
|
||||
|
||||
function textClose(identity, endMs = now()) {
|
||||
const closed = timeline.close(identity, endMs);
|
||||
if (closed) adjustOpenPin(closed);
|
||||
return closed;
|
||||
}
|
||||
|
||||
function closeTextSource(sourceKind, sourceId, sourceEpoch, endMs = now()) {
|
||||
const closed = timeline.closeSource(sourceKind, sourceId, sourceEpoch, endMs);
|
||||
for (const record of closed) adjustOpenPin(record);
|
||||
return closed;
|
||||
}
|
||||
|
||||
function setTexthooker(nextStatus, active = false) {
|
||||
texthookerStatus = nextStatus;
|
||||
texthookerActive = active;
|
||||
if (!active) texthookerSource = null;
|
||||
}
|
||||
|
||||
function adjustOpenPin(closed) {
|
||||
const active = currentPin();
|
||||
if (!active || active.finalized || active.sourceKind !== closed.sourceKind
|
||||
|| active.sourceId !== closed.sourceId || active.sourceEpoch !== closed.sourceEpoch
|
||||
|| active.occurrenceId !== closed.occurrenceId) return;
|
||||
const offset = closed.sourceKind === "cue" ? 0 : config.estimatedOffsetMs;
|
||||
const closedEnd = closed.endMs + offset;
|
||||
if (closedEnd > active.startMs && closedEnd < active.endMs) {
|
||||
active.endMs = closedEnd;
|
||||
active.deadlineVersion += 1;
|
||||
active.finishDrain?.();
|
||||
void finalizeAtDeadline(active, active.deadlineVersion);
|
||||
}
|
||||
}
|
||||
|
||||
function currentPin() {
|
||||
return activePin ?? (activeJobId ? jobs.get(activeJobId)?.pin ?? null : null);
|
||||
}
|
||||
|
||||
let activePin = null;
|
||||
|
||||
function cancelOwnedCapture(message) {
|
||||
const error = new Error(message);
|
||||
if (activePin && !activePin.finalized) {
|
||||
activePin.finishDrain?.();
|
||||
activePin.rejectReady(error);
|
||||
}
|
||||
activePin = null;
|
||||
for (const job of jobs.values()) {
|
||||
job.controller.abort();
|
||||
if (!job.pin.finalized) {
|
||||
job.pin.finishDrain?.();
|
||||
job.pin.rejectReady(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mediaDelivered(pin) {
|
||||
return (!config.includeAnimation || videoDeliveredThroughMs >= pin.endMs)
|
||||
&& (!config.includeCapturedAudio || !pin.audioAvailable
|
||||
|| audioRing.covers(pin.startMs, pin.endMs));
|
||||
}
|
||||
|
||||
function drainMedia(pin) {
|
||||
if (mediaDelivered(pin)) return Promise.resolve();
|
||||
return new Promise(resolve => {
|
||||
const timer = setTimer(finish, MEDIA_DRAIN_MS);
|
||||
function finish() {
|
||||
clearTimer(timer);
|
||||
pin.finishDrain = null;
|
||||
pin.checkDrain = null;
|
||||
resolve();
|
||||
}
|
||||
pin.finishDrain = finish;
|
||||
pin.checkDrain = () => { if (mediaDelivered(pin)) finish(); };
|
||||
});
|
||||
}
|
||||
|
||||
async function finalizeAtDeadline(pin, version) {
|
||||
await waitUntil(pin.endMs, now, setTimer);
|
||||
if (currentPin() !== pin || pin.deadlineVersion !== version || pin.finalized) return;
|
||||
try {
|
||||
// Delivery can trail its timestamp (JPEG encoding and worklet batches).
|
||||
// Wait for that delivery without moving the lookup's frozen interval.
|
||||
await drainMedia(pin);
|
||||
if (currentPin() !== pin || pin.deadlineVersion !== version || pin.finalized) return;
|
||||
pin.frames = config.includeAnimation ? frameRing.select(pin.startMs, pin.endMs) : [];
|
||||
pin.audio = config.includeCapturedAudio && pin.audioAvailable
|
||||
? audioRing.select(pin.startMs, pin.endMs) : null;
|
||||
pin.mediaErrors = {};
|
||||
// A display-capture track can emit only changed frames. Once the bounded
|
||||
// drain ends, its last pixels remain valid until source mute/loss stops us.
|
||||
if (pin.audio?.partial) {
|
||||
pin.mediaErrors.audio = "Captured audio is missing samples in this clip. Look up the text again.";
|
||||
}
|
||||
if (config.includeCapturedAudio && !pin.audioAvailable) pin.partial = true;
|
||||
pin.partial ||= pin.audio?.partial === true;
|
||||
pin.finalized = true;
|
||||
pin.resolveReady(pin);
|
||||
} catch (error) {
|
||||
pin.rejectReady(error);
|
||||
pins.release(pin.token);
|
||||
activePin = null;
|
||||
}
|
||||
}
|
||||
|
||||
function pinLookup({ lookupText, occurrenceId = "", occurrenceSourceKind = "", lookupTimeMs = now() }) {
|
||||
requireRecording();
|
||||
pruneJobs();
|
||||
if (activeJobId !== null) throw new Error("Another captured clip is still exporting. Finish or cancel it first.");
|
||||
if (!config.includeAnimation && config.includeCapturedAudio && !capturedAudioAvailable) {
|
||||
throw new Error("The shared source did not provide audio for media capture.");
|
||||
}
|
||||
const availableStartMs = oldestRequiredTimestamp();
|
||||
if (!Number.isFinite(availableStartMs)) throw new Error("Capture history is still warming up.");
|
||||
const interval = resolveCaptureInterval({
|
||||
records: timeline.snapshot(),
|
||||
lookupText,
|
||||
occurrenceId,
|
||||
occurrenceSourceKind,
|
||||
lookupTimeMs,
|
||||
availableStartMs,
|
||||
timingMode: config.timingMode,
|
||||
clipSeconds: config.clipSeconds,
|
||||
estimatedOffsetMs: config.estimatedOffsetMs,
|
||||
texthookerActive,
|
||||
texthookerSource: texthookerActive ? texthookerSource : null,
|
||||
});
|
||||
if (!interval) throw new Error("No retained capture interval is available for this lookup.");
|
||||
const assetId = safeAssetId(randomId());
|
||||
let resolveReady, rejectReady;
|
||||
const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
|
||||
void ready.catch(() => {});
|
||||
const created = pins.create({
|
||||
...interval,
|
||||
captureSessionId,
|
||||
assetId,
|
||||
animationFilename: `hachidori-${assetId}.avif`,
|
||||
audioFilename: `hachidori-${assetId}.wav`,
|
||||
audioAvailable: capturedAudioAvailable,
|
||||
deadlineVersion: 1,
|
||||
finalized: false,
|
||||
frames: null,
|
||||
audio: null,
|
||||
mediaErrors: {},
|
||||
ready,
|
||||
resolveReady,
|
||||
rejectReady,
|
||||
});
|
||||
activePin = pins.get(created.token);
|
||||
void finalizeAtDeadline(activePin, activePin.deadlineVersion);
|
||||
return {
|
||||
token: created.token,
|
||||
captureSessionId,
|
||||
sourceKind: interval.sourceKind,
|
||||
sourceLabel: interval.sourceLabel,
|
||||
partial: interval.partial === true,
|
||||
animationFilename: created.animationFilename,
|
||||
audioFilename: created.audioFilename,
|
||||
readyAtMs: interval.endMs,
|
||||
};
|
||||
}
|
||||
|
||||
function releasePin(token) {
|
||||
let released = pins.release(token);
|
||||
// Status may have expired the token before the reader dismisses its clip.
|
||||
// The session still owns those media copies until this reference is retired.
|
||||
if (activePin !== null && activePin.token === token) {
|
||||
activePin.finishDrain?.();
|
||||
activePin.rejectReady(new Error("The capture pin was released."));
|
||||
activePin = null;
|
||||
released = true;
|
||||
}
|
||||
return released;
|
||||
}
|
||||
|
||||
function pruneJobs() {
|
||||
const cutoff = wallNow() - JOB_LIFETIME_MS;
|
||||
for (const [id, job] of jobs) {
|
||||
if (job.updatedAt >= cutoff) continue;
|
||||
job.controller.abort();
|
||||
if (!job.pin.finalized) {
|
||||
job.pin.finishDrain?.();
|
||||
job.pin.rejectReady(new Error("The media export job expired."));
|
||||
}
|
||||
jobs.delete(id);
|
||||
if (activeJobId === id) activeJobId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function beginExport(token, requirements, owner = linkedPage) {
|
||||
pruneJobs();
|
||||
if (activeJobId !== null) {
|
||||
const existing = jobs.get(activeJobId);
|
||||
if (existing?.token === token) {
|
||||
assertJobOwner(existing, owner);
|
||||
return { jobId: existing.id, state: existing.state,
|
||||
sourceLabel: existing.sourceLabel, partial: existing.partial };
|
||||
}
|
||||
throw new Error("Another captured clip is still exporting.");
|
||||
}
|
||||
const pin = pins.get(token);
|
||||
if (pin?.captureSessionId !== captureSessionId) throw new Error("The capture pin expired. Look up the text again.");
|
||||
const includeAnimation = requirements?.includeAnimation === true && config.includeAnimation;
|
||||
const includeAudio = requirements?.includeAudio === true && config.includeCapturedAudio && pin.audioAvailable;
|
||||
if (!includeAnimation && requirements?.includeAudio === true
|
||||
&& config.includeCapturedAudio && !pin.audioAvailable) {
|
||||
throw new Error("The shared source did not provide audio for the mapped captured-audio field.");
|
||||
}
|
||||
if (!includeAnimation && !includeAudio) throw new Error("The selected Anki fields do not reference captured media.");
|
||||
const id = randomId();
|
||||
const controller = new AbortController();
|
||||
const job = { id, token, state: "finishing", error: "", progress: 0, total: 0, encoderHeapBytes: 0,
|
||||
sourceLabel: pin.sourceLabel, partial: pin.partial === true, assets: {}, updatedAt: wallNow(),
|
||||
controller, pin, owner: owner ? { tabId: owner.tabId, documentId: owner.documentId } : null,
|
||||
warnings: requirements?.includeAudio === true && !pin.audioAvailable
|
||||
? ["The shared source did not provide audio; this note will use animation only."] : [] };
|
||||
jobs.set(id, job);
|
||||
activeJobId = id;
|
||||
pins.release(token);
|
||||
if (activePin === pin) activePin = null;
|
||||
void (async () => {
|
||||
try {
|
||||
await pin.ready;
|
||||
if (controller.signal.aborted) throw new Error("Media encoding was cancelled.");
|
||||
if (includeAudio && pin.mediaErrors.audio) throw new Error(pin.mediaErrors.audio);
|
||||
job.state = "encoding";
|
||||
job.updatedAt = wallNow();
|
||||
if (includeAnimation) {
|
||||
job.assets.animation = {
|
||||
filename: pin.animationFilename,
|
||||
data: await encodeAnimation(pin.frames, { endMs: pin.endMs, videoPreset: config.videoPreset }, {
|
||||
signal: controller.signal,
|
||||
onProgress(completed, total, heapBytes) {
|
||||
job.progress = completed;
|
||||
job.total = total;
|
||||
if (Number.isSafeInteger(heapBytes) && heapBytes > job.encoderHeapBytes) {
|
||||
job.encoderHeapBytes = heapBytes;
|
||||
}
|
||||
job.updatedAt = wallNow();
|
||||
},
|
||||
}),
|
||||
};
|
||||
if (job.assets.animation.data.byteLength > MAX_ANIMATED_AVIF_BYTES) {
|
||||
throw new Error("Animated AVIF exceeds its 4 MiB output limit.");
|
||||
}
|
||||
}
|
||||
if (includeAudio) {
|
||||
job.assets.audio = {
|
||||
filename: pin.audioFilename,
|
||||
data: encodeMonoWav(pin.audio.samples, pin.audio.sampleRate),
|
||||
};
|
||||
}
|
||||
job.partial ||= pin.partial || pin.audio?.partial === true;
|
||||
job.state = "ready";
|
||||
} catch (error) {
|
||||
job.state = "error";
|
||||
job.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
job.updatedAt = wallNow();
|
||||
})();
|
||||
return { jobId: id, state: job.state, sourceLabel: job.sourceLabel, partial: job.partial };
|
||||
}
|
||||
|
||||
function jobStatus(id, owner = null) {
|
||||
pruneJobs();
|
||||
const job = jobs.get(id);
|
||||
if (!job) throw new Error("The media export job expired.");
|
||||
assertJobOwner(job, owner);
|
||||
return {
|
||||
jobId: id,
|
||||
state: job.state,
|
||||
error: job.error,
|
||||
progress: job.progress,
|
||||
total: job.total,
|
||||
encoderHeapBytes: job.encoderHeapBytes,
|
||||
sourceLabel: job.sourceLabel,
|
||||
partial: job.partial,
|
||||
warnings: [...job.warnings],
|
||||
assets: Object.fromEntries(Object.entries(job.assets).map(([kind, asset]) =>
|
||||
[kind, { filename: asset.filename, byteLength: asset.data.byteLength }])),
|
||||
};
|
||||
}
|
||||
|
||||
function jobAsset(id, kind) {
|
||||
const job = jobs.get(id);
|
||||
if (job?.state !== "ready" || !["animation", "audio"].includes(kind) || !job.assets[kind]) {
|
||||
throw new Error("The requested captured media asset is unavailable.");
|
||||
}
|
||||
const asset = job.assets[kind];
|
||||
return { filename: asset.filename, data: asset.data.slice() };
|
||||
}
|
||||
|
||||
function completeExport(id) {
|
||||
const job = jobs.get(id);
|
||||
if (!job) return false;
|
||||
jobs.delete(id);
|
||||
if (activeJobId === id) activeJobId = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancelExport(id, owner = null) {
|
||||
const job = jobs.get(id);
|
||||
if (!job) return false;
|
||||
assertJobOwner(job, owner);
|
||||
job.controller.abort();
|
||||
if (!job.pin.finalized) {
|
||||
job.pin.finishDrain?.();
|
||||
job.pin.rejectReady(new Error("The media export job was cancelled."));
|
||||
}
|
||||
jobs.delete(id);
|
||||
if (activeJobId === id) activeJobId = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
configure,
|
||||
start,
|
||||
stop,
|
||||
status,
|
||||
addFrame,
|
||||
videoDelivered,
|
||||
addAudio,
|
||||
assertAudioCapture,
|
||||
selectAudio,
|
||||
setLinkedPage,
|
||||
textBegin,
|
||||
textClose,
|
||||
closeTextSource,
|
||||
setTexthooker,
|
||||
pinLookup,
|
||||
releasePin,
|
||||
beginExport,
|
||||
jobStatus,
|
||||
jobAsset,
|
||||
completeExport,
|
||||
cancelExport,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { encodeMonoWav } from "./capture-buffer.js";
|
||||
import { MEDIA_DRAIN_MS } from "./capture-session.js";
|
||||
import { resolveSpeech } from "./speech.js";
|
||||
|
||||
const SPEECH_PREROLL_MS = 100;
|
||||
const SPEECH_TAIL_MS = 200;
|
||||
const MIN_AUDIBLE_PEAK = 1 / 4096;
|
||||
|
||||
function waitUntil(deadline, signal, now, setTimer, clearTimer) {
|
||||
signal.throwIfAborted();
|
||||
const remaining = Math.max(0, deadline - now());
|
||||
if (remaining === 0) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimer(done, remaining);
|
||||
function clean() { clearTimer(timer); signal.removeEventListener("abort", aborted); }
|
||||
function done() { clean(); resolve(); }
|
||||
function aborted() { clean(); reject(signal.reason); }
|
||||
signal.addEventListener("abort", aborted, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordCapturedSpeech(window, session, source, term, signal, {
|
||||
now = () => performance.timeOrigin + performance.now(),
|
||||
setTimer = setTimeout,
|
||||
clearTimer = clearTimeout,
|
||||
preRollMs = SPEECH_PREROLL_MS,
|
||||
tailMs = SPEECH_TAIL_MS,
|
||||
drainMs = MEDIA_DRAIN_MS,
|
||||
} = {}) {
|
||||
session.assertAudioCapture();
|
||||
const { speech, utterance, candidate } = await resolveSpeech(window, source, term, signal);
|
||||
signal.throwIfAborted();
|
||||
let startMs = null;
|
||||
let endMs = null;
|
||||
let abort;
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
utterance.onstart = () => { startMs ??= now() - preRollMs; };
|
||||
utterance.onend = () => {
|
||||
if (!Number.isFinite(startMs)) {
|
||||
reject(new Error("Browser text-to-speech ended before audio capture started."));
|
||||
return;
|
||||
}
|
||||
endMs = now() + tailMs;
|
||||
resolve();
|
||||
};
|
||||
utterance.onerror = event => {
|
||||
const detail = event.error ? ` (${event.error})` : "";
|
||||
reject(new Error(`Text-to-speech could not be recorded${detail}.`));
|
||||
};
|
||||
abort = () => {
|
||||
// Cancel synchronously before another operation can own the global
|
||||
// speech queue; the detached callbacks cannot affect newer speech.
|
||||
utterance.onend = utterance.onerror = utterance.onstart = null;
|
||||
speech.cancel();
|
||||
reject(signal.reason);
|
||||
};
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
// A pronunciation already queued by the popup must not become part of
|
||||
// this note's recording.
|
||||
speech.cancel();
|
||||
speech.speak(utterance);
|
||||
});
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abort);
|
||||
utterance.onend = utterance.onerror = utterance.onstart = null;
|
||||
}
|
||||
await waitUntil(endMs + drainMs, signal, now, setTimer, clearTimer);
|
||||
const selected = session.selectAudio(startMs, endMs);
|
||||
let peak = 0;
|
||||
for (const sample of selected.samples) peak = Math.max(peak, Math.abs(sample));
|
||||
if (peak < MIN_AUDIBLE_PEAK) {
|
||||
throw new Error("The active capture did not hear browser text-to-speech. Share system audio or choose a downloadable audio source.");
|
||||
}
|
||||
return {
|
||||
data: encodeMonoWav(selected.samples, selected.sampleRate),
|
||||
candidate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
export const CAPTURE_RECORD_LIMIT = 1000;
|
||||
export const CAPTURE_TEXT_LIMIT = 4096;
|
||||
const TIMED_SOURCES = new Set(["texthooker", "cue", "dom"]);
|
||||
|
||||
export function normaliseCaptureText(value) {
|
||||
if (typeof value !== "string") return "";
|
||||
return value.normalize("NFC").replace(/\s+/gu, " ").trim();
|
||||
}
|
||||
|
||||
function finiteTime(value, label) {
|
||||
if (!Number.isFinite(value)) throw new Error(`${label} must be a finite timeline value`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function recordKey(record) {
|
||||
return `${record.sourceKind}\0${record.sourceId}\0${record.sourceEpoch}\0${record.occurrenceId}`;
|
||||
}
|
||||
|
||||
export function createCaptureTimeline({ limit = CAPTURE_RECORD_LIMIT, textLimit = CAPTURE_TEXT_LIMIT } = {}) {
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || !Number.isSafeInteger(textLimit) || textLimit < 1) {
|
||||
throw new Error("capture timeline limits must be positive integers");
|
||||
}
|
||||
const records = [];
|
||||
const current = new Map();
|
||||
|
||||
function begin(value) {
|
||||
if (!TIMED_SOURCES.has(value?.sourceKind)
|
||||
|| typeof value.sourceId !== "string" || !value.sourceId
|
||||
|| typeof value.sourceEpoch !== "string" || !value.sourceEpoch
|
||||
|| typeof value.occurrenceId !== "string" || !value.occurrenceId) {
|
||||
throw new Error("capture timeline record identity is incomplete");
|
||||
}
|
||||
if (typeof value.text !== "string" || value.text.length > textLimit) {
|
||||
throw new Error(`capture timeline text must be at most ${textLimit} characters`);
|
||||
}
|
||||
const text = normaliseCaptureText(value.text);
|
||||
if (!text) throw new Error("capture timeline text is empty");
|
||||
const startMs = finiteTime(value.startMs, "capture start");
|
||||
const candidate = {
|
||||
sourceKind: value.sourceKind,
|
||||
sourceId: value.sourceId,
|
||||
sourceEpoch: value.sourceEpoch,
|
||||
occurrenceId: value.occurrenceId,
|
||||
text: value.text,
|
||||
normalizedText: text,
|
||||
startMs,
|
||||
endMs: null,
|
||||
onsetKnown: value.onsetKnown !== false,
|
||||
};
|
||||
const key = recordKey(candidate);
|
||||
const existing = current.get(key);
|
||||
if (existing) {
|
||||
existing.text = candidate.text;
|
||||
existing.normalizedText = candidate.normalizedText;
|
||||
existing.onsetKnown = existing.onsetKnown && candidate.onsetKnown;
|
||||
return { ...existing };
|
||||
}
|
||||
records.push(candidate);
|
||||
current.set(key, candidate);
|
||||
while (records.length > limit) {
|
||||
const removed = records.shift();
|
||||
if (current.get(recordKey(removed)) === removed) current.delete(recordKey(removed));
|
||||
}
|
||||
return { ...candidate };
|
||||
}
|
||||
|
||||
function close(identity, endMs) {
|
||||
const key = recordKey(identity);
|
||||
const record = current.get(key);
|
||||
if (!record) return null;
|
||||
const end = finiteTime(endMs, "capture end");
|
||||
if (end < record.startMs) throw new Error("capture end precedes its start");
|
||||
record.endMs = end;
|
||||
current.delete(key);
|
||||
return { ...record };
|
||||
}
|
||||
|
||||
function closeSource(sourceKind, sourceId, sourceEpoch, endMs) {
|
||||
const closed = [];
|
||||
for (const record of current.values()) {
|
||||
if (record.sourceKind !== sourceKind || record.sourceId !== sourceId
|
||||
|| (sourceEpoch !== undefined && record.sourceEpoch !== sourceEpoch)) continue;
|
||||
const result = close(record, endMs);
|
||||
if (result) closed.push(result);
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
records.length = 0;
|
||||
current.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
begin,
|
||||
close,
|
||||
closeSource,
|
||||
reset,
|
||||
snapshot: () => records.map(record => ({ ...record })),
|
||||
};
|
||||
}
|
||||
|
||||
function temporalMatches(record, lookupTimeMs) {
|
||||
return record.startMs <= lookupTimeMs && (record.endMs == null || lookupTimeMs <= record.endMs);
|
||||
}
|
||||
|
||||
function matchingRecord(records, sourceKind, lookupText, occurrenceId, occurrenceSourceKind, lookupTimeMs,
|
||||
texthookerSource) {
|
||||
const normalized = normaliseCaptureText(lookupText);
|
||||
const matches = records.filter(record => record.sourceKind === sourceKind
|
||||
&& record.onsetKnown !== false
|
||||
&& (sourceKind === "texthooker"
|
||||
? record.sourceId === texthookerSource?.sourceId && record.sourceEpoch === texthookerSource?.sourceEpoch
|
||||
&& record.startMs <= lookupTimeMs
|
||||
: temporalMatches(record, lookupTimeMs))
|
||||
&& (occurrenceId && occurrenceSourceKind === sourceKind ? record.occurrenceId === occurrenceId
|
||||
: (record.normalizedText ?? normaliseCaptureText(record.text)) === normalized));
|
||||
return matches.length === 1 ? matches[0] : null;
|
||||
}
|
||||
|
||||
function timedInterval(record, lookupTimeMs, clipMs, offsetMs) {
|
||||
const startMs = record.startMs + offsetMs;
|
||||
const naturalEnd = record.endMs == null ? startMs + clipMs : record.endMs + offsetMs;
|
||||
return {
|
||||
startMs,
|
||||
endMs: Math.min(naturalEnd, startMs + clipMs),
|
||||
pendingTail: record.endMs == null && lookupTimeMs < naturalEnd,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one lookup to exactly one source interval. `availableStartMs` is the
|
||||
* oldest retained recorder timestamp and may shorten only the recent fallback.
|
||||
*/
|
||||
export function resolveCaptureInterval({
|
||||
records,
|
||||
lookupText,
|
||||
occurrenceId = "",
|
||||
occurrenceSourceKind = "",
|
||||
lookupTimeMs,
|
||||
availableStartMs,
|
||||
timingMode = "auto",
|
||||
clipSeconds = 10,
|
||||
estimatedOffsetMs = -500,
|
||||
texthookerActive = false,
|
||||
texthookerSource = null,
|
||||
}) {
|
||||
finiteTime(lookupTimeMs, "lookup time");
|
||||
finiteTime(availableStartMs, "available capture start");
|
||||
const clipMs = clipSeconds * 1000;
|
||||
if (![5000, 10000].includes(clipMs)) throw new Error("capture clip length is invalid");
|
||||
let priorities = [];
|
||||
if (timingMode !== "recent") {
|
||||
priorities = ["cue", "dom"];
|
||||
if (timingMode !== "page" && texthookerActive) priorities.unshift("texthooker");
|
||||
}
|
||||
const labels = {
|
||||
texthooker: "Texthooker estimate",
|
||||
cue: "Video cue",
|
||||
dom: "Page-text estimate",
|
||||
};
|
||||
for (const kind of priorities) {
|
||||
const record = matchingRecord(records, kind, lookupText, occurrenceId, occurrenceSourceKind, lookupTimeMs,
|
||||
texthookerSource);
|
||||
if (!record) continue;
|
||||
const interval = timedInterval(record, lookupTimeMs, clipMs, kind === "cue" ? 0 : estimatedOffsetMs);
|
||||
if (interval.startMs < availableStartMs || interval.endMs <= interval.startMs) continue;
|
||||
return {
|
||||
...interval,
|
||||
sourceKind: kind,
|
||||
sourceLabel: labels[kind],
|
||||
sourceId: record.sourceId,
|
||||
sourceEpoch: record.sourceEpoch,
|
||||
occurrenceId: record.occurrenceId,
|
||||
};
|
||||
}
|
||||
const startMs = Math.max(availableStartMs, lookupTimeMs - clipMs);
|
||||
if (lookupTimeMs <= startMs) return null;
|
||||
return {
|
||||
sourceKind: "recent",
|
||||
sourceLabel: "Recent clip",
|
||||
sourceId: "",
|
||||
sourceEpoch: "",
|
||||
occurrenceId: "",
|
||||
startMs,
|
||||
endMs: lookupTimeMs,
|
||||
pendingTail: false,
|
||||
partial: startMs > lookupTimeMs - clipMs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
background: #101316;
|
||||
color: #edf2f4;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 520px; background: radial-gradient(circle at top, #26343a 0, #101316 50%); }
|
||||
main { width: min(820px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 48px; }
|
||||
header, .card { border: 1px solid #ffffff1f; background: #171c20e8; box-shadow: 0 18px 50px #0006; }
|
||||
header { display: flex; align-items: start; justify-content: space-between; gap: 24px; padding: 28px; border-radius: 18px; }
|
||||
h1, h2, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 8px; font-size: 2rem; }
|
||||
h2 { margin-bottom: 6px; font-size: 1.05rem; }
|
||||
header p, .card p, .privacy { color: #b8c4c9; }
|
||||
.eyebrow { margin-bottom: 4px; color: #7ee0bd; font-size: .75rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.card { margin-top: 16px; padding: 22px; border-radius: 14px; }
|
||||
.capture-controls { display: grid; grid-template-columns: 1fr auto; gap: 14px 24px; }
|
||||
.button-row, .field-row { display: flex; align-items: end; gap: 10px; flex-wrap: wrap; }
|
||||
.field-row { margin-top: 16px; }
|
||||
label { display: grid; flex: 1; gap: 6px; color: #b8c4c9; font-size: .82rem; }
|
||||
button, select {
|
||||
min-height: 38px;
|
||||
border: 1px solid #ffffff24;
|
||||
border-radius: 9px;
|
||||
background: #252d32;
|
||||
color: inherit;
|
||||
padding: 8px 12px;
|
||||
font: inherit;
|
||||
}
|
||||
button { cursor: pointer; font-weight: 700; }
|
||||
button:hover:not(:disabled) { border-color: #7ee0bd; }
|
||||
button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
button.primary { border-color: #70d9b4; background: #18805e; }
|
||||
.status-badge { border-radius: 999px; background: #30383d; padding: 7px 12px; font-size: .82rem; font-weight: 800; }
|
||||
.status-badge.recording { background: #1d7257; }
|
||||
.error { grid-column: 1 / -1; color: #ffaaa4; }
|
||||
video { grid-column: 1 / -1; width: 100%; max-height: 360px; border-radius: 10px; background: #000; object-fit: contain; }
|
||||
.status-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.status-grid h2 { grid-column: 1 / -1; }
|
||||
.status-grid div { display: flex; justify-content: space-between; gap: 16px; border-radius: 9px; background: #ffffff09; padding: 12px; }
|
||||
.status-grid span { color: #b8c4c9; }
|
||||
.privacy { margin: 20px 8px 0; font-size: .82rem; line-height: 1.5; }
|
||||
@media (max-width: 620px) {
|
||||
.capture-controls, .status-grid { grid-template-columns: 1fr; }
|
||||
.status-grid h2, .error, video { grid-column: 1; }
|
||||
}
|
||||
/* Author display rules must not reveal controls marked unavailable. */
|
||||
[hidden] { display: none !important; }
|
||||
@@ -0,0 +1,75 @@
|
||||
<!doctype html>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Hachidori — Anki context capture</title>
|
||||
<link rel="stylesheet" href="capture.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">Hachidori</p>
|
||||
<h1>Anki context capture</h1>
|
||||
<p>Capture context from your study material to attach to Japanese vocabulary cards. Capture keeps running when you close this page — reopen it to stop or change the linked reading page.</p>
|
||||
</div>
|
||||
<span id="capture-state" class="status-badge">Stopped</span>
|
||||
</header>
|
||||
|
||||
<section class="card capture-controls" aria-labelledby="capture-heading">
|
||||
<div>
|
||||
<h2 id="capture-heading">Shared media</h2>
|
||||
<p id="media-source">No tab, window, or screen is being captured.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="capture-start" class="primary" type="button">Start capture</button>
|
||||
<button id="capture-stop" type="button" disabled>Stop</button>
|
||||
</div>
|
||||
<output id="capture-error" class="error" aria-live="polite"></output>
|
||||
</section>
|
||||
|
||||
<section class="card" aria-labelledby="reading-heading">
|
||||
<div>
|
||||
<h2 id="reading-heading">Reading page</h2>
|
||||
<p id="linked-page">No reading page is linked.</p>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>
|
||||
<span>Open page</span>
|
||||
<select id="reading-tab"></select>
|
||||
</label>
|
||||
<button id="link-page" type="button">Link this reading page</button>
|
||||
</div>
|
||||
<div class="field-row" id="video-row" hidden>
|
||||
<label>
|
||||
<span>Video source</span>
|
||||
<select id="reading-video"></select>
|
||||
</label>
|
||||
<button id="select-video" type="button">Use video</button>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="track-area" type="button">Track this text area</button>
|
||||
<button id="clear-area" type="button">Clear tracked area</button>
|
||||
</div>
|
||||
<output id="page-status" aria-live="polite"></output>
|
||||
</section>
|
||||
|
||||
<section class="card status-grid" aria-labelledby="timing-heading">
|
||||
<h2 id="timing-heading">Timing sources</h2>
|
||||
<div><span>Texthooker</span><strong id="texthooker-status">Disabled</strong></div>
|
||||
<div><span>Video history</span><strong id="video-history">0 frames</strong></div>
|
||||
<div><span>Audio history</span><strong id="audio-history">0 samples</strong></div>
|
||||
<div><span>Lookup pin</span><strong id="pin-status">None</strong></div>
|
||||
</section>
|
||||
|
||||
<p class="privacy">
|
||||
Choose a tab, window, or entire screen. Screen sharing includes everything visible on that screen.
|
||||
Audio availability and scope depend on Chrome's selected share. Hachidori does not request microphone access,
|
||||
persist capture history, or send captured media anywhere except your configured AnkiConnect server.
|
||||
</p>
|
||||
</main>
|
||||
<script type="module" src="capture.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// This page controls the offscreen recorder; closing it leaves capture running.
|
||||
import { extensionApi as chrome } from "./browser-api.js";
|
||||
|
||||
const CAPTURE_TARGET = "hachidori-capture";
|
||||
const elements = Object.fromEntries([...document.querySelectorAll("[id]")].map(node => [node.id, node]));
|
||||
let config;
|
||||
let selectedTabId = null;
|
||||
let requestCounter = 0;
|
||||
let pendingControl = false;
|
||||
let lastVideos = "";
|
||||
let lastPageStatus = "";
|
||||
const describe = error => error instanceof Error ? error.message || String(error) : String(error);
|
||||
async function send(type, fields = {}) {
|
||||
const reply = await chrome.runtime.sendMessage({
|
||||
target: CAPTURE_TARGET,
|
||||
type,
|
||||
requestId: `capture-${++requestCounter}`,
|
||||
...fields,
|
||||
});
|
||||
if (!reply?.ok) throw new Error(reply?.error || "The capture service did not reply.");
|
||||
return reply;
|
||||
}
|
||||
|
||||
function render(status) {
|
||||
config = status.config;
|
||||
selectedTabId = status.linkedPage?.tabId ?? null;
|
||||
const recording = status.state === "recording";
|
||||
const linkedPage = Boolean(status.linkedPage);
|
||||
const videos = status.videos ?? [];
|
||||
const serializedVideos = JSON.stringify(videos);
|
||||
if (serializedVideos !== lastVideos) {
|
||||
elements["reading-video"].replaceChildren(...videos.map(video => new Option(video.label, video.id)));
|
||||
elements["video-row"].hidden = videos.length < 2;
|
||||
lastVideos = serializedVideos;
|
||||
}
|
||||
if (lastPageStatus !== status.pageStatus) {
|
||||
elements["page-status"].textContent = status.pageStatus || "";
|
||||
lastPageStatus = status.pageStatus;
|
||||
}
|
||||
const duration = (oldest, newest) => Number.isFinite(oldest) && Number.isFinite(newest)
|
||||
? `${Math.max(0, (newest - oldest) / 1000).toFixed(1)} s` : "0.0 s";
|
||||
const stateLabels = { recording: "Recording", disabled: "Disabled", stopped: "Stopped" };
|
||||
elements["capture-state"].textContent = stateLabels[status.state];
|
||||
elements["capture-state"].classList.toggle("recording", recording);
|
||||
elements["capture-start"].disabled = pendingControl || status.starting || recording || !config?.enabled;
|
||||
elements["capture-stop"].disabled = !recording && !status.starting;
|
||||
elements["link-page"].disabled = !recording;
|
||||
elements["select-video"].disabled = !recording || !linkedPage
|
||||
|| config?.timingMode === "recent" || !config?.page.nativeCues;
|
||||
elements["track-area"].disabled = !recording || !linkedPage
|
||||
|| config?.timingMode === "recent" || !config?.page.domText;
|
||||
elements["clear-area"].disabled = !recording || !linkedPage;
|
||||
elements["media-source"].textContent = captureSourceLabel(status.mediaSource);
|
||||
elements["capture-error"].textContent = status.error || "";
|
||||
elements["texthooker-status"].textContent = status.texthookerStatus;
|
||||
elements["video-history"].textContent = config?.includeAnimation
|
||||
? `${duration(status.history.frameOldestMs, status.history.frameNewestMs)} · ${status.history.frameCount} frames · ${Math.round(status.history.frameBytes / 1024)} KiB`
|
||||
: "Disabled";
|
||||
let audioHistory = "Disabled";
|
||||
if (status.mediaSource && !status.mediaSource.audioAvailable) audioHistory = "Source audio unavailable";
|
||||
else if (config?.includeCapturedAudio) {
|
||||
audioHistory = `${duration(status.history.audioOldestMs, status.history.audioNewestMs)} · ${status.history.audioSamples.toLocaleString()} samples`;
|
||||
}
|
||||
elements["audio-history"].textContent = audioHistory;
|
||||
elements["pin-status"].textContent = status.pinActive ? "Pinned" : "None";
|
||||
elements["linked-page"].textContent = status.linkedPage?.title || "No reading page is linked.";
|
||||
}
|
||||
|
||||
function captureSourceLabel(source) {
|
||||
if (!source) return "No tab, window, or screen is being captured.";
|
||||
const kind = { browser: "Browser tab", window: "Application window", monitor: "Entire screen" }[source.displaySurface]
|
||||
|| "Shared media";
|
||||
return !source.name || /^(?:web-contents-media-stream|screen|window):/u.test(source.name)
|
||||
? kind : `${source.name} · ${kind}`;
|
||||
}
|
||||
|
||||
async function refreshTabs() {
|
||||
try {
|
||||
const reply = await send("hd_capture_tabs");
|
||||
elements["reading-tab"].replaceChildren(...reply.tabs.map(tab => new Option(tab.title || tab.url, String(tab.id))));
|
||||
if (reply.tabs.some(tab => tab.id === selectedTabId)) elements["reading-tab"].value = String(selectedTabId);
|
||||
} catch (error) {
|
||||
elements["page-status"].textContent = describe(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function control(type) {
|
||||
pendingControl = true;
|
||||
elements["capture-start"].disabled = true;
|
||||
elements["capture-stop"].disabled = false;
|
||||
try { render(await send(type)); }
|
||||
catch (error) { elements["capture-error"].textContent = describe(error); }
|
||||
finally { pendingControl = false; await refreshStatus(); }
|
||||
}
|
||||
|
||||
elements["capture-start"].addEventListener("click", () => { void control("hd_capture_start"); });
|
||||
elements["capture-stop"].addEventListener("click", () => { void control("hd_capture_stop"); });
|
||||
elements["reading-tab"].addEventListener("focus", () => { void refreshTabs(); });
|
||||
elements["link-page"].addEventListener("click", async () => {
|
||||
try {
|
||||
const tabId = Number(elements["reading-tab"].value);
|
||||
const reply = await send("hd_capture_link", { tabId });
|
||||
selectedTabId = reply.page.tabId;
|
||||
await refreshStatus();
|
||||
} catch (error) {
|
||||
elements["page-status"].textContent = describe(error);
|
||||
}
|
||||
});
|
||||
elements["select-video"].addEventListener("click", async () => {
|
||||
try {
|
||||
await send("hd_capture_video_select", {
|
||||
tabId: selectedTabId,
|
||||
videoId: elements["reading-video"].value,
|
||||
});
|
||||
elements["page-status"].textContent = "Video source selected.";
|
||||
} catch (error) {
|
||||
elements["page-status"].textContent = describe(error);
|
||||
}
|
||||
});
|
||||
elements["track-area"].addEventListener("click", async () => {
|
||||
try {
|
||||
await send("hd_capture_track_area", { tabId: selectedTabId });
|
||||
elements["page-status"].textContent = "Choose an area on the linked page; press Escape to cancel.";
|
||||
} catch (error) {
|
||||
elements["page-status"].textContent = describe(error);
|
||||
}
|
||||
});
|
||||
elements["clear-area"].addEventListener("click", async () => {
|
||||
try {
|
||||
await send("hd_capture_clear_area", { tabId: selectedTabId });
|
||||
elements["page-status"].textContent = "Tracked text area cleared.";
|
||||
} catch (error) {
|
||||
elements["page-status"].textContent = describe(error);
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshStatus() {
|
||||
try { render(await send("hd_capture_status")); }
|
||||
catch (error) { elements["capture-error"].textContent = describe(error); }
|
||||
}
|
||||
|
||||
await refreshStatus();
|
||||
await refreshTabs();
|
||||
setInterval(() => { void refreshStatus(); }, 1000);
|
||||
@@ -0,0 +1,44 @@
|
||||
// Chrome MV3 lifecycle for the shared offscreen engine document.
|
||||
// Firefox imports this shared module but never calls its guarded Chrome path.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import { extensionApi as chrome } from "./browser-api.js";
|
||||
|
||||
let creating = null;
|
||||
|
||||
export function chromeOffscreenSupported() {
|
||||
return typeof chrome.runtime.getContexts === "function"
|
||||
&& typeof chrome.offscreen?.createDocument === "function";
|
||||
}
|
||||
|
||||
async function offscreenExists(url) {
|
||||
const contexts = await chrome.runtime.getContexts({
|
||||
contextTypes: ["OFFSCREEN_DOCUMENT"],
|
||||
documentUrls: [chrome.runtime.getURL(url)],
|
||||
});
|
||||
return contexts.length > 0;
|
||||
}
|
||||
|
||||
async function createOffscreen(url) {
|
||||
try {
|
||||
await chrome.offscreen.createDocument({
|
||||
url,
|
||||
reasons: ["DOM_SCRAPING", "AUDIO_PLAYBACK", "DISPLAY_MEDIA"],
|
||||
justification:
|
||||
"Runs the dictionary engine and pronunciation audio, and owns explicitly started local display capture across control-page closure.",
|
||||
});
|
||||
} catch (error) {
|
||||
// Another extension context may have won the race; only a genuine absence
|
||||
// is a failure.
|
||||
if (!(await offscreenExists(url))) throw error;
|
||||
} finally {
|
||||
creating = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureChromeOffscreen(url) {
|
||||
if (!chromeOffscreenSupported()) return;
|
||||
if (await offscreenExists(url)) return;
|
||||
if (creating === null) creating = createOffscreen(url);
|
||||
await creating;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Page-document styles for Hachidori's hover highlight.
|
||||
*
|
||||
* Everything else the extension draws lives in the popup's shadow root and is
|
||||
* styled by render/reader.css. This rule cannot: `::highlight()` resolves
|
||||
* only against the ranges' own tree, and the ranges CSS.highlights receives
|
||||
* point at the page's text nodes -- a `::highlight()` rule inside the shadow
|
||||
* root would never match them. Fallback paint is extension-owned, inside the
|
||||
* popup host's shadow root; it never adds classes to page elements.
|
||||
*
|
||||
* Colours here are the initial default. The shared popup appearance helper
|
||||
* updates an owned CSSOM sheet from the shadow host's selected palette without
|
||||
* modifying the page's html element or inheriting its theme policy.
|
||||
*
|
||||
* Copyright (C) 2026 Manhhao
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
::highlight(gsm-hoshidicts-match) {
|
||||
color: inherit;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--hoshidicts-palette-primary, #7aa2ff) 34%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import "./external-links.js";
|
||||
|
||||
export function validateLinkButton(label, url) {
|
||||
if (!label || /[\u0000-\u001f\u007f]/u.test(label)) return "Enter a name for the button.";
|
||||
if (!globalThis.HDExternalLinks.expandCustomLinkUrl(url, { word: "word", reading: "reading", sentence: "sentence" })) {
|
||||
return "Enter an http:// or https:// URL. %w, %r and %s are supported.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Retained for callers that validate legacy custom-link drafts directly.
|
||||
export const validateCustomLink = validateLinkButton;
|
||||
|
||||
export function createCustomButtonSettings({
|
||||
document,
|
||||
readButtons,
|
||||
saveButtons,
|
||||
readTemplates,
|
||||
createId = () => document.defaultView.crypto.randomUUID(),
|
||||
}) {
|
||||
const list = document.getElementById("custom-button-list");
|
||||
const empty = document.getElementById("custom-button-empty");
|
||||
const form = document.getElementById("custom-button-form");
|
||||
const name = document.getElementById("opt-custom-button-name");
|
||||
const type = document.getElementById("opt-custom-button-type");
|
||||
const url = document.getElementById("opt-custom-button-url");
|
||||
const template = document.getElementById("opt-custom-button-template");
|
||||
const linkField = document.getElementById("custom-button-link-field");
|
||||
const ankiField = document.getElementById("custom-button-anki-field");
|
||||
const submit = document.getElementById("custom-button-submit");
|
||||
const cancel = document.getElementById("custom-button-cancel");
|
||||
const status = document.getElementById("custom-buttons-status");
|
||||
let editing = null;
|
||||
let rendered = null;
|
||||
|
||||
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
||||
const templates = () => readTemplates().filter(value => value && typeof value.id === "string");
|
||||
const templateById = id => templates().find(value => value.id === id);
|
||||
|
||||
function button(text, label, onClick, iconName) {
|
||||
const node = document.createElement("button");
|
||||
node.type = "button";
|
||||
node.className = "ghost";
|
||||
if (iconName) {
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "hd-icon";
|
||||
icon.dataset.icon = iconName;
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
node.append(icon);
|
||||
} else {
|
||||
node.textContent = text;
|
||||
}
|
||||
node.setAttribute("aria-label", label);
|
||||
node.addEventListener("click", onClick);
|
||||
return node;
|
||||
}
|
||||
|
||||
function focusRow(index, action) {
|
||||
const row = list.children[Math.min(index, list.children.length - 1)];
|
||||
(row?.querySelector(`[data-action="${action}"]:not(:disabled)`)
|
||||
?? row?.querySelector("button:not(:disabled)") ?? name).focus();
|
||||
}
|
||||
|
||||
function move(index, offset) {
|
||||
const buttons = readButtons().slice();
|
||||
[buttons[index], buttons[index + offset]] = [buttons[index + offset], buttons[index]];
|
||||
if (editing?.index === index) editing.index += offset;
|
||||
else if (editing?.index === index + offset) editing.index -= offset;
|
||||
saveButtons(buttons);
|
||||
render();
|
||||
focusRow(index + offset, offset < 0 ? "up" : "down");
|
||||
}
|
||||
|
||||
function remove(index) {
|
||||
if (editing?.index === index) closeForm();
|
||||
else if (editing && editing.index > index) editing.index -= 1;
|
||||
saveButtons(readButtons().filter((_button, buttonIndex) => buttonIndex !== index));
|
||||
render();
|
||||
focusRow(index, "edit");
|
||||
}
|
||||
|
||||
function edit(index) {
|
||||
const value = readButtons()[index];
|
||||
editing = { index, button: value };
|
||||
name.value = value.label;
|
||||
type.value = value.type;
|
||||
url.value = value.type === "link" ? value.url : "";
|
||||
template.value = value.type === "anki" ? value.templateId : "";
|
||||
status.textContent = "";
|
||||
updateForm();
|
||||
name.focus();
|
||||
}
|
||||
|
||||
function createRow(value, index, count) {
|
||||
const row = document.createElement("li");
|
||||
row.className = "custom-button-row";
|
||||
const text = document.createElement("div");
|
||||
text.className = "custom-button-text";
|
||||
const label = document.createElement("strong");
|
||||
label.textContent = value.label;
|
||||
const summary = document.createElement("span");
|
||||
summary.className = "custom-button-summary";
|
||||
const kind = document.createElement("span");
|
||||
kind.className = "custom-button-kind";
|
||||
kind.textContent = value.type === "link" ? "Link" : "Anki";
|
||||
const detail = document.createElement(value.type === "link" ? "code" : "span");
|
||||
if (value.type === "link") detail.textContent = value.url;
|
||||
else detail.textContent = templateById(value.templateId)?.name ?? "Template unavailable";
|
||||
summary.append(kind, " · ", detail);
|
||||
text.append(label, summary);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "custom-button-actions";
|
||||
const up = button("", `Move up: ${value.label}`, () => move(index, -1), "arrow-up");
|
||||
const down = button("", `Move down: ${value.label}`, () => move(index, 1), "arrow-down");
|
||||
up.dataset.action = "up";
|
||||
down.dataset.action = "down";
|
||||
up.disabled = index === 0;
|
||||
down.disabled = index === count - 1;
|
||||
const editButton = button("Edit", `Edit button ${value.label}`, () => edit(index));
|
||||
editButton.dataset.action = "edit";
|
||||
actions.append(up, down, editButton, button("Delete", `Delete button ${value.label}`, () => remove(index)));
|
||||
row.append(text, actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderTemplateChoices() {
|
||||
const values = templates();
|
||||
const selected = template.value;
|
||||
const choices = values.map(value => new document.defaultView.Option(value.name, value.id));
|
||||
if (selected && !values.some(value => value.id === selected)) {
|
||||
choices.push(new document.defaultView.Option("Unavailable Template", selected));
|
||||
}
|
||||
template.replaceChildren(...choices);
|
||||
template.value = selected && choices.some(option => option.value === selected)
|
||||
? selected : values[0]?.id ?? "";
|
||||
}
|
||||
|
||||
function updateForm() {
|
||||
renderTemplateChoices();
|
||||
const link = type.value === "link";
|
||||
linkField.hidden = !link;
|
||||
ankiField.hidden = link;
|
||||
submit.textContent = editing ? "Save button" : "Add button";
|
||||
cancel.hidden = !editing;
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
editing = null;
|
||||
name.value = url.value = "";
|
||||
type.value = "link";
|
||||
template.value = templates()[0]?.id ?? "";
|
||||
status.textContent = "";
|
||||
updateForm();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const buttons = readButtons();
|
||||
const key = JSON.stringify([buttons, templates().map(({ id, name: templateName }) => [id, templateName])]);
|
||||
if (key === rendered) return;
|
||||
rendered = key;
|
||||
list.replaceChildren(...buttons.map((value, index) => createRow(value, index, buttons.length)));
|
||||
empty.hidden = buttons.length > 0;
|
||||
renderTemplateChoices();
|
||||
}
|
||||
|
||||
function uniqueId() {
|
||||
const ids = new Set(readButtons().map(value => value.id));
|
||||
let id;
|
||||
do id = createId();
|
||||
while (typeof id !== "string" || id === "" || ids.has(id));
|
||||
return id;
|
||||
}
|
||||
|
||||
function draft() {
|
||||
const base = {
|
||||
id: editing?.button.id ?? "",
|
||||
type: type.value,
|
||||
label: name.value.trim(),
|
||||
};
|
||||
return type.value === "anki"
|
||||
? { ...base, templateId: template.value }
|
||||
: { ...base, url: url.value.trim() };
|
||||
}
|
||||
|
||||
form.addEventListener("submit", event => {
|
||||
event.preventDefault();
|
||||
const value = draft();
|
||||
let error = "";
|
||||
if (value.type === "link") error = validateLinkButton(value.label, value.url);
|
||||
else if (!value.label || /[\u0000-\u001f\u007f]/u.test(value.label)) error = "Enter a name for the button.";
|
||||
else if (!templateById(value.templateId)) error = "Choose an available Template.";
|
||||
if (error) {
|
||||
status.textContent = error;
|
||||
return;
|
||||
}
|
||||
const buttons = readButtons().slice();
|
||||
if (editing && !same(buttons[editing.index], editing.button)) {
|
||||
status.textContent = "This button changed elsewhere. Cancel, then edit it again.";
|
||||
return;
|
||||
}
|
||||
if (editing) buttons[editing.index] = value;
|
||||
else buttons.push({ ...value, id: uniqueId() });
|
||||
saveButtons(buttons);
|
||||
closeForm();
|
||||
render();
|
||||
name.focus();
|
||||
});
|
||||
for (const input of [name, url, template]) {
|
||||
input.addEventListener("input", () => { status.textContent = ""; });
|
||||
input.addEventListener("change", () => { status.textContent = ""; });
|
||||
}
|
||||
type.addEventListener("change", () => {
|
||||
status.textContent = "";
|
||||
updateForm();
|
||||
});
|
||||
cancel.addEventListener("click", () => {
|
||||
closeForm();
|
||||
name.focus();
|
||||
});
|
||||
updateForm();
|
||||
render();
|
||||
return {
|
||||
render,
|
||||
reset() { closeForm(); render(); },
|
||||
dirty: () => {
|
||||
const value = draft();
|
||||
if (!editing) return value.label !== "" || (value.type === "link" ? value.url !== "" : false);
|
||||
return !same(value, editing.button);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
/*
|
||||
* Shared source and archive rules for Hachidori's managed custom dictionary.
|
||||
*
|
||||
* This module deliberately has no Chrome or engine dependencies so Settings,
|
||||
* the background worker, and both engine runtimes use the same representation.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export const CUSTOM_DICTIONARY_ID = "e4c2e20a1a964b6cbd4ae3f87643c1f0";
|
||||
export const CUSTOM_DICTIONARY_TITLE = "Hachidori Custom Dictionary";
|
||||
export const CUSTOM_DICTIONARY_SOURCE_KEY = "customDictionarySource";
|
||||
export const CUSTOM_DICTIONARY_SOURCE_SCHEMA_VERSION = 1;
|
||||
export const EMPTY_CUSTOM_DICTIONARY_SEMANTIC_REVISION =
|
||||
"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945";
|
||||
|
||||
export function customDictionaryMetadataMatches(value, semanticRevision, entryCount) {
|
||||
return value?.title === CUSTOM_DICTIONARY_TITLE && value.revision === semanticRevision
|
||||
&& value.termCount === entryCount
|
||||
&& ["frequencyCount", "pitchCount", "kanjiCount", "mediaCount"].every(key => value[key] === 0)
|
||||
&& value.isUpdatable === false && value.indexUrl === null && value.downloadUrl === null && value.language === "ja";
|
||||
}
|
||||
|
||||
export function assertCustomDictionaryCommit(dictionaries) {
|
||||
const customIndexes = dictionaries.flatMap((dictionary, index) =>
|
||||
dictionary?.id === CUSTOM_DICTIONARY_ID ? [index] : []);
|
||||
if (customIndexes.length > 1) {
|
||||
throw new Error("the custom dictionary state contains duplicate managed packages");
|
||||
}
|
||||
if (customIndexes.length === 0) return;
|
||||
const custom = dictionaries[customIndexes[0]];
|
||||
if (customIndexes[0] !== 0
|
||||
|| custom?.title !== CUSTOM_DICTIONARY_TITLE
|
||||
|| custom?.enabled !== true) {
|
||||
throw new Error("the managed custom dictionary must stay enabled and first");
|
||||
}
|
||||
if (dictionaries.some((dictionary, index) =>
|
||||
index !== customIndexes[0] && dictionary?.title === CUSTOM_DICTIONARY_TITLE)) {
|
||||
throw new Error(`a dictionary named ${CUSTOM_DICTIONARY_TITLE} is already installed`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCustomSourceState(dictionaries, semanticRevision, entryCount) {
|
||||
const custom = dictionaries.filter((dictionary) => dictionary?.id === CUSTOM_DICTIONARY_ID);
|
||||
if (entryCount === 0) {
|
||||
if (custom.length !== 0) {
|
||||
throw new Error("a zero-entry custom source cannot retain its managed package");
|
||||
}
|
||||
return;
|
||||
}
|
||||
assertCustomDictionaryCommit(dictionaries);
|
||||
const entry = custom[0];
|
||||
if (custom.length !== 1
|
||||
|| !customDictionaryMetadataMatches(entry, semanticRevision, entryCount)) {
|
||||
throw new Error("the custom dictionary package does not match its source semantics");
|
||||
}
|
||||
}
|
||||
|
||||
const TERM_BANK_SIZE = 1_000;
|
||||
const ZIP_UTF8_FLAG = 0x0800;
|
||||
const ZIP_STORE = 0;
|
||||
const ZIP_DATE = 0x21;
|
||||
const ZIP_VERSION = 20;
|
||||
const UINT16_MAX = 0xffff;
|
||||
const UINT32_MAX = 0xff_ff_ff_ff;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function sourceText(value) {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError("the custom dictionary source must be text");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function emptyCustomDictionaryDocument() {
|
||||
return {
|
||||
schemaVersion: CUSTOM_DICTIONARY_SOURCE_SCHEMA_VERSION,
|
||||
revision: 0,
|
||||
semanticRevision: EMPTY_CUSTOM_DICTIONARY_SEMANTIC_REVISION,
|
||||
text: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function normaliseCustomDictionaryDocument(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return emptyCustomDictionaryDocument();
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError("the custom dictionary document is invalid");
|
||||
}
|
||||
if (value.schemaVersion !== CUSTOM_DICTIONARY_SOURCE_SCHEMA_VERSION) {
|
||||
throw new Error(`unsupported custom dictionary source schema ${String(value.schemaVersion)}`);
|
||||
}
|
||||
if (!Number.isInteger(value.revision) || value.revision < 0) {
|
||||
throw new TypeError("the custom dictionary document revision is invalid");
|
||||
}
|
||||
if (typeof value.text !== "string"
|
||||
|| typeof value.semanticRevision !== "string"
|
||||
|| !/^[0-9a-f]{64}$/u.test(value.semanticRevision)) {
|
||||
throw new TypeError("the custom dictionary document content is invalid");
|
||||
}
|
||||
return {
|
||||
schemaVersion: CUSTOM_DICTIONARY_SOURCE_SCHEMA_VERSION,
|
||||
revision: value.revision,
|
||||
semanticRevision: value.semanticRevision,
|
||||
text: value.text,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeDefinition(value) {
|
||||
if (!value.includes("\\")) return value;
|
||||
let decoded = "";
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const character = value[index];
|
||||
if (character !== "\\" || index + 1 >= value.length) {
|
||||
decoded += character;
|
||||
continue;
|
||||
}
|
||||
const next = value[index + 1];
|
||||
if (next === "\\") {
|
||||
decoded += "\\";
|
||||
index += 1;
|
||||
} else if (next === "n") {
|
||||
decoded += "\n";
|
||||
index += 1;
|
||||
} else {
|
||||
decoded += character;
|
||||
}
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function normaliseEntry(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError("the custom dictionary entry must be an object");
|
||||
}
|
||||
const term = typeof value.term === "string" ? value.term.trim() : "";
|
||||
const reading = typeof value.reading === "string" ? value.reading.trim() : "";
|
||||
const definition = typeof value.definition === "string"
|
||||
? value.definition.trim().replace(/\r\n?|\n/gu, "\n")
|
||||
: "";
|
||||
if (term === "") throw new TypeError("the custom dictionary term is empty");
|
||||
if (term.startsWith("#")) {
|
||||
throw new TypeError("the custom dictionary term cannot begin with #");
|
||||
}
|
||||
if (term.includes(",") || /[\r\n]/u.test(term)) {
|
||||
throw new TypeError("the custom dictionary term cannot contain a comma or newline");
|
||||
}
|
||||
if (reading === "") throw new TypeError("the custom dictionary reading is empty");
|
||||
if (reading.includes(",") || /[\r\n]/u.test(reading)) {
|
||||
throw new TypeError("the custom dictionary reading cannot contain a comma or newline");
|
||||
}
|
||||
if (definition === "") throw new TypeError("the custom dictionary definition is empty");
|
||||
return { term, reading, definition };
|
||||
}
|
||||
|
||||
export function parseCustomDictionary(value) {
|
||||
const source = sourceText(value);
|
||||
const parsedSource = source.startsWith("\ufeff") ? source.slice(1) : source;
|
||||
const entries = [];
|
||||
const errors = [];
|
||||
const lines = parsedSource.split(/\r\n|\r|\n/u);
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "" || line.trimStart().startsWith("#")) continue;
|
||||
|
||||
const firstComma = line.indexOf(",");
|
||||
const secondComma = firstComma < 0 ? -1 : line.indexOf(",", firstComma + 1);
|
||||
if (firstComma < 0 || secondComma < 0) {
|
||||
errors.push({ lineNumber: index + 1, reason: "expected two commas" });
|
||||
continue;
|
||||
}
|
||||
const term = line.slice(0, firstComma).trim();
|
||||
const reading = line.slice(firstComma + 1, secondComma).trim();
|
||||
const encodedDefinition = line.slice(secondComma + 1).trim();
|
||||
const definition = decodeDefinition(encodedDefinition);
|
||||
if (term === "") {
|
||||
errors.push({ lineNumber: index + 1, reason: "term is empty" });
|
||||
} else if (reading === "") {
|
||||
errors.push({ lineNumber: index + 1, reason: "reading is empty" });
|
||||
} else if (definition.trim() === "") {
|
||||
errors.push({ lineNumber: index + 1, reason: "definition is empty" });
|
||||
} else {
|
||||
entries.push({ term, reading, definition });
|
||||
}
|
||||
}
|
||||
return { entries, errors };
|
||||
}
|
||||
|
||||
export function serializeCustomDictionaryEntry(value) {
|
||||
const { term, reading, definition } = normaliseEntry(value);
|
||||
const encodedDefinition = definition
|
||||
.replaceAll("\\", "\\\\")
|
||||
.replaceAll("\n", String.raw`\n`);
|
||||
return `${term}, ${reading}, ${encodedDefinition}`;
|
||||
}
|
||||
|
||||
export function appendCustomDictionaryEntry(value, entry) {
|
||||
const source = sourceText(value);
|
||||
const newline = source.includes("\r\n") ? "\r\n" : "\n";
|
||||
const separator = source === "" || /[\r\n]$/u.test(source) ? "" : newline;
|
||||
return `${source}${separator}${serializeCustomDictionaryEntry(entry)}${newline}`;
|
||||
}
|
||||
|
||||
export async function customDictionarySemanticRevision(values) {
|
||||
if (!Array.isArray(values)) {
|
||||
throw new TypeError("the custom dictionary entries must be a list");
|
||||
}
|
||||
const bytes = encoder.encode(JSON.stringify(values.map(normaliseEntry)));
|
||||
const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes));
|
||||
return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
const crcTable = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let index = 0; index < table.length; index += 1) {
|
||||
let value = index;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
value = (value & 1) === 0
|
||||
? value >>> 1
|
||||
: 0xed_b8_83_20 ^ (value >>> 1);
|
||||
}
|
||||
table[index] = value >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(bytes) {
|
||||
let crc = UINT32_MAX;
|
||||
let index = 0;
|
||||
while (index < bytes.length) {
|
||||
crc = crcTable[(crc ^ bytes[index]) & 0xff] ^ (crc >>> 8);
|
||||
index += 1;
|
||||
}
|
||||
return (crc ^ UINT32_MAX) >>> 0;
|
||||
}
|
||||
|
||||
function zipFile(name, value) {
|
||||
const nameBytes = encoder.encode(name);
|
||||
const data = encoder.encode(JSON.stringify(value));
|
||||
if (nameBytes.byteLength > UINT16_MAX || data.byteLength > UINT32_MAX) {
|
||||
throw new RangeError("the custom dictionary exceeds the classic ZIP representation");
|
||||
}
|
||||
return { nameBytes, data, crc: crc32(data), localOffset: 0 };
|
||||
}
|
||||
|
||||
function checkedZipSize(value) {
|
||||
if (!Number.isSafeInteger(value) || value > UINT32_MAX) {
|
||||
throw new RangeError("the custom dictionary exceeds the classic ZIP representation");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function writeLocalHeader(view, offset, file) {
|
||||
view.setUint32(offset, 0x04_03_4b_50, true);
|
||||
view.setUint16(offset + 4, ZIP_VERSION, true);
|
||||
view.setUint16(offset + 6, ZIP_UTF8_FLAG, true);
|
||||
view.setUint16(offset + 8, ZIP_STORE, true);
|
||||
view.setUint16(offset + 10, 0, true);
|
||||
view.setUint16(offset + 12, ZIP_DATE, true);
|
||||
view.setUint32(offset + 14, file.crc, true);
|
||||
view.setUint32(offset + 18, file.data.byteLength, true);
|
||||
view.setUint32(offset + 22, file.data.byteLength, true);
|
||||
view.setUint16(offset + 26, file.nameBytes.byteLength, true);
|
||||
view.setUint16(offset + 28, 0, true);
|
||||
}
|
||||
|
||||
function writeCentralHeader(view, offset, file) {
|
||||
view.setUint32(offset, 0x02_01_4b_50, true);
|
||||
view.setUint16(offset + 4, ZIP_VERSION, true);
|
||||
view.setUint16(offset + 6, ZIP_VERSION, true);
|
||||
view.setUint16(offset + 8, ZIP_UTF8_FLAG, true);
|
||||
view.setUint16(offset + 10, ZIP_STORE, true);
|
||||
view.setUint16(offset + 12, 0, true);
|
||||
view.setUint16(offset + 14, ZIP_DATE, true);
|
||||
view.setUint32(offset + 16, file.crc, true);
|
||||
view.setUint32(offset + 20, file.data.byteLength, true);
|
||||
view.setUint32(offset + 24, file.data.byteLength, true);
|
||||
view.setUint16(offset + 28, file.nameBytes.byteLength, true);
|
||||
view.setUint16(offset + 30, 0, true);
|
||||
view.setUint16(offset + 32, 0, true);
|
||||
view.setUint16(offset + 34, 0, true);
|
||||
view.setUint16(offset + 36, 0, true);
|
||||
view.setUint32(offset + 38, (0o100644 << 16) >>> 0, true);
|
||||
view.setUint32(offset + 42, file.localOffset, true);
|
||||
}
|
||||
|
||||
function writeEndRecord(view, offset, entryCount, centralSize, centralOffset) {
|
||||
view.setUint32(offset, 0x06_05_4b_50, true);
|
||||
view.setUint16(offset + 4, 0, true);
|
||||
view.setUint16(offset + 6, 0, true);
|
||||
view.setUint16(offset + 8, entryCount, true);
|
||||
view.setUint16(offset + 10, entryCount, true);
|
||||
view.setUint32(offset + 12, centralSize, true);
|
||||
view.setUint32(offset + 16, centralOffset, true);
|
||||
view.setUint16(offset + 20, 0, true);
|
||||
}
|
||||
|
||||
export function buildCustomDictionaryZip(values, revision) {
|
||||
if (!Array.isArray(values) || values.length === 0) {
|
||||
throw new TypeError("the custom dictionary needs at least one entry");
|
||||
}
|
||||
if (typeof revision !== "string" || revision === "") {
|
||||
throw new TypeError("the custom dictionary needs a semantic revision");
|
||||
}
|
||||
const entries = values.map(normaliseEntry);
|
||||
const files = [zipFile("index.json", {
|
||||
title: CUSTOM_DICTIONARY_TITLE,
|
||||
format: 3,
|
||||
revision,
|
||||
sequenced: true,
|
||||
author: "Hachidori",
|
||||
description: "Personal entries managed by Hachidori",
|
||||
sourceLanguage: "ja",
|
||||
targetLanguage: "en",
|
||||
})];
|
||||
for (let offset = 0; offset < entries.length; offset += TERM_BANK_SIZE) {
|
||||
const rows = entries.slice(offset, offset + TERM_BANK_SIZE).map((entry, index) => [
|
||||
entry.term,
|
||||
entry.reading,
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
[entry.definition],
|
||||
offset + index + 1,
|
||||
"",
|
||||
]);
|
||||
files.push(zipFile(`term_bank_${files.length}.json`, rows));
|
||||
}
|
||||
if (files.length > UINT16_MAX) {
|
||||
throw new RangeError("the custom dictionary exceeds the classic ZIP representation");
|
||||
}
|
||||
|
||||
let localSize = 0;
|
||||
for (const file of files) {
|
||||
file.localOffset = localSize;
|
||||
localSize = checkedZipSize(localSize + 30 + file.nameBytes.byteLength + file.data.byteLength);
|
||||
}
|
||||
const centralSize = checkedZipSize(files.reduce(
|
||||
(size, file) => size + 46 + file.nameBytes.byteLength,
|
||||
0,
|
||||
));
|
||||
const totalSize = checkedZipSize(localSize + centralSize + 22);
|
||||
const bytes = new Uint8Array(totalSize);
|
||||
const view = new DataView(bytes.buffer);
|
||||
|
||||
let cursor = 0;
|
||||
for (const file of files) {
|
||||
writeLocalHeader(view, cursor, file);
|
||||
cursor += 30;
|
||||
bytes.set(file.nameBytes, cursor);
|
||||
cursor += file.nameBytes.byteLength;
|
||||
bytes.set(file.data, cursor);
|
||||
cursor += file.data.byteLength;
|
||||
}
|
||||
for (const file of files) {
|
||||
writeCentralHeader(view, cursor, file);
|
||||
cursor += 46;
|
||||
bytes.set(file.nameBytes, cursor);
|
||||
cursor += file.nameBytes.byteLength;
|
||||
}
|
||||
writeEndRecord(view, cursor, files.length, centralSize, localSize);
|
||||
return bytes;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
const SCRIPT_ID = "hachidori-custom-javascript";
|
||||
let application = Promise.resolve();
|
||||
|
||||
// Chrome MV3 `userScripts` registration. Firefox MV2 has no `userScripts`
|
||||
// with a USER_SCRIPT world, so the saved code stays inert there and Settings
|
||||
// hides the editor through HOST_CAPABILITIES.customJavaScript.
|
||||
async function apply(browser, code) {
|
||||
let scripts;
|
||||
try {
|
||||
scripts = browser.userScripts;
|
||||
await scripts.getScripts({ ids: [SCRIPT_ID] });
|
||||
} catch {
|
||||
return { supported: false, registered: false };
|
||||
}
|
||||
await scripts.unregister({ ids: [SCRIPT_ID] }).catch(() => {});
|
||||
if (code === "") return { supported: true, registered: false };
|
||||
await scripts.register([{
|
||||
id: SCRIPT_ID,
|
||||
matches: ["<all_urls>"],
|
||||
js: [{ code }],
|
||||
runAt: "document_idle",
|
||||
world: "USER_SCRIPT",
|
||||
}]);
|
||||
return { supported: true, registered: true };
|
||||
}
|
||||
|
||||
export function applyCustomJavaScript(browser, code) {
|
||||
application = application.catch(() => {}).then(() => apply(browser, code));
|
||||
return application;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
#preview-host { position: fixed; inset: 0; pointer-events: none; }
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Dictionary popup preview</title>
|
||||
<link rel="stylesheet" href="visual-novel.css" />
|
||||
<link rel="stylesheet" href="design-preview.css" />
|
||||
<link rel="stylesheet" href="content.css" />
|
||||
<link rel="stylesheet" href="icons.css" />
|
||||
<script src="reader-options.js" defer></script>
|
||||
<script src="visual-novel.js" defer></script>
|
||||
<script src="external-links.js" defer></script>
|
||||
<script src="render/glossary.js" defer></script>
|
||||
<script src="render/popup.js" defer></script>
|
||||
<script src="design-preview.js" defer></script>
|
||||
</head>
|
||||
<body class="vn-scene">
|
||||
<article class="vn-dialogue" lang="ja" aria-label="Game dialogue">
|
||||
<p class="vn-speaker">ひなた</p>
|
||||
<p class="vn-line"><span id="preview-source">朝ごはんを食べる。</span></p>
|
||||
</article>
|
||||
<div id="preview-host"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,282 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Static, local sample data; the view itself is the production popup renderer.
|
||||
(function () {
|
||||
"use strict";
|
||||
HDVisualNovel.initialize(document.querySelector(".vn-scene"));
|
||||
const host = document.getElementById("preview-host");
|
||||
const shadow = host.attachShadow({ mode: "open" });
|
||||
const appearance = HDPopup.createPopupAppearance(host);
|
||||
const customStyle = HDPopup.createCustomPopupStyle(shadow);
|
||||
const stylesheet = document.createElement("link");
|
||||
stylesheet.rel = "stylesheet";
|
||||
stylesheet.href = "render/reader.css";
|
||||
const popup = document.createElement("div");
|
||||
popup.className = "gsm-hoshidicts-popup";
|
||||
const iconStylesheet = document.createElement("link");
|
||||
iconStylesheet.rel = "stylesheet";
|
||||
iconStylesheet.href = "icons.css";
|
||||
shadow.append(stylesheet, iconStylesheet, popup);
|
||||
const source = document.getElementById("preview-source");
|
||||
const candidate = { query: "食べる", sentence: source.textContent,
|
||||
sourceElements: [source], matchOffset: source.textContent.indexOf("食べる") };
|
||||
let options = { ...HDReaderOptions.DEFAULT_OPTIONS };
|
||||
let state;
|
||||
let sample;
|
||||
let sampleKey;
|
||||
let updateKey;
|
||||
let imageSources = null;
|
||||
let kanjiCharacter = null;
|
||||
let kanjiSource = null;
|
||||
let termView;
|
||||
let selectedDictionaryTab = null;
|
||||
let sampleMedia = null;
|
||||
let sampleLookupStats = null;
|
||||
let clickedKanjiIndex = 0;
|
||||
// Fixed mature word, count of 3 and native frequency samples; the shared
|
||||
// rules, real hover and real delay decide the preview's blur. Nothing is
|
||||
// recorded, looked up or sent to Anki.
|
||||
const SAMPLE_LOOKUP_COUNT = 3;
|
||||
let sampleRevealed = false;
|
||||
let sampleBlurTimer = null;
|
||||
let sampleTermView = false;
|
||||
const DEFINITION_BLUR_KEYS = [
|
||||
"showLookupCounts", "definitionBlurEnabled", "definitionBlurAnkiMature",
|
||||
"definitionBlurFrequencyEnabled", "definitionBlurFrequencyDictionary",
|
||||
"definitionBlurFrequencyOrder", "definitionBlurFrequencyThreshold",
|
||||
"definitionBlurDirection", "definitionBlurThreshold", "definitionBlurReveal", "definitionBlurDelayMs",
|
||||
];
|
||||
|
||||
function sampleBlurState() {
|
||||
const count = options.showLookupCounts ? SAMPLE_LOOKUP_COUNT : null;
|
||||
const frequency = HDReaderOptions.definitionBlurFrequencyEvidence(options,
|
||||
sample?.results?.[0]?.term?.frequencies, sample?.dictionaryPresentation);
|
||||
return !sampleRevealed && HDReaderOptions.definitionBlurQualifies(options, count, true, frequency.qualified)
|
||||
? "blurred" : "revealed";
|
||||
}
|
||||
|
||||
function clearSampleBlurTimer() {
|
||||
if (sampleBlurTimer === null) return;
|
||||
clearTimeout(sampleBlurTimer);
|
||||
sampleBlurTimer = null;
|
||||
}
|
||||
|
||||
function revealSample() {
|
||||
sampleRevealed = true;
|
||||
clearSampleBlurTimer();
|
||||
view.setDefinitionBlurState("revealed");
|
||||
}
|
||||
|
||||
function armSampleBlur() {
|
||||
clearSampleBlurTimer();
|
||||
if (sampleBlurState() !== "blurred" || options.definitionBlurReveal !== "timed") return;
|
||||
sampleBlurTimer = setTimeout(() => { sampleBlurTimer = null; revealSample(); }, options.definitionBlurDelayMs);
|
||||
}
|
||||
|
||||
popup.addEventListener("mouseover", (event) => {
|
||||
if (sampleBlurState() === "blurred" && event.target instanceof Element
|
||||
&& event.target.closest(".gsm-hoshidicts-definitions, .gsm-hoshidicts-compact-definition-summary")) revealSample();
|
||||
});
|
||||
|
||||
function positionPopup(resetToolbar = false) {
|
||||
const factor = HDPopup.popupCoordinateScale(1, options.popupScalePercent);
|
||||
const position = HDPopup.calculatePopupPosition(HDPopup.scaleRect(source.getBoundingClientRect(), factor),
|
||||
{ width: options.popupWidthPx, height: options.popupHeightPx }, { width: innerWidth * factor, height: innerHeight * factor });
|
||||
for (const key of ["left", "top", "width", "height"]) popup.style[key] = `${position[key]}px`;
|
||||
const edge = HDPopup.resolveToolbarPosition(options.popupToolbarPosition, position.placement,
|
||||
resetToolbar ? "top" : popup.dataset.toolbarPosition);
|
||||
if (popup.dataset.toolbarPosition !== edge) view.setToolbarPosition(edge);
|
||||
}
|
||||
|
||||
const view = HDPopup.createPopupView({ document, window, popup,
|
||||
appendExpressionRuby: HDGlossary.appendExpressionRuby,
|
||||
buildPitchAccentMorae: HDGlossary.buildPitchAccentMorae,
|
||||
appendTextOnlyGlossary: HDGlossary.appendTextOnlyGlossary,
|
||||
appendStructuredImage: HDGlossary.appendStructuredImage,
|
||||
parseTagList: HDGlossary.parseTagList,
|
||||
getPopupColumns: () => options.popupColumns,
|
||||
getPopupScalePercent: () => options.popupScalePercent,
|
||||
customButtons: options.customButtons,
|
||||
positionPopup, sourceHighlightEnabled: true,
|
||||
onKanjiClick(character, result, anchor, link) {
|
||||
if (!kanjiCharacter) {
|
||||
clickedKanjiIndex = [...popup.querySelectorAll(".gsm-hoshidicts-kanji-link")].indexOf(link);
|
||||
termView = { ...view.captureTermView(), selectedDictionaryTab };
|
||||
}
|
||||
kanjiCharacter = character;
|
||||
renderSample();
|
||||
popup.querySelector(".gsm-hoshidicts-kanji-back").focus({ preventScroll: true });
|
||||
},
|
||||
onAddCustomEntry() { throw new Error("This is a preview. Notes are not saved."); },
|
||||
onCustomLinkClick(link) {
|
||||
void (globalThis.browser ?? globalThis.chrome).runtime.sendMessage({ target: "hoshidicts-worker", type: "hd_open_external", ...link })
|
||||
.catch(error => console.debug("hachidori: preview link could not be opened", error));
|
||||
},
|
||||
onResultsRendered({ lookupStats }) {
|
||||
sampleLookupStats = lookupStats;
|
||||
paintSampleLookupStats();
|
||||
updateSampleAudio();
|
||||
},
|
||||
});
|
||||
|
||||
function updateSampleAudio() {
|
||||
const available = options.audioSources.some(source => source.enabled
|
||||
&& (source.type.startsWith("text-to-speech") || source.url.trim()));
|
||||
for (const control of popup.querySelectorAll(".gsm-hoshidicts-audio-control")) {
|
||||
if (control.hidden !== !available) control.hidden = !available;
|
||||
}
|
||||
}
|
||||
|
||||
// A static sample count; the switch only paints or hides the rendered slot.
|
||||
function paintSampleLookupStats() {
|
||||
if (!sampleLookupStats?.isConnected) return;
|
||||
view.setLookupStats(sampleLookupStats, options.showLookupCounts ? { lookupCount: 3, seenCount: null } : null);
|
||||
}
|
||||
|
||||
function createSample() {
|
||||
const enabled = state.dictionaries.filter(entry => entry.enabled);
|
||||
const definitions = enabled.filter(entry => entry.termCount > 0);
|
||||
const first = definitions[0]?.title || "Sample dictionary";
|
||||
const preferred = definitions.find(entry => entry.title === options.compactDefinitionSummaryDictionary)?.title;
|
||||
const second = preferred && preferred !== first ? preferred : definitions[1]?.title || "Sample usage";
|
||||
const pitch = enabled.find(entry => entry.title === options.pitchAccentFuriganaDictionary && entry.pitchCount > 0)?.title
|
||||
|| enabled.find(entry => entry.pitchCount > 0)?.title || "Sample pitch";
|
||||
const installedFrequencies = enabled
|
||||
.filter(entry => entry.frequencyCount > 0 && !["Sample ranks", "Sample corpus"].includes(entry.title))
|
||||
.map(entry => ({ dictionary: entry.title, frequencies: [{
|
||||
value: entry.frequencyMode === "rank-based" ? 120 : 18240,
|
||||
displayValue: entry.frequencyMode === "rank-based" ? "120" : "18,240",
|
||||
}] }));
|
||||
const glossary = (dictionary, items) => ({ dictionary, glossary: JSON.stringify(items), definitionTags: "v1 vt", termTags: "common" });
|
||||
const results = [{ matched: "食べる", deinflected: "食べる", trace: [], preprocessorSteps: 0,
|
||||
term: { expression: "食べる", reading: "たべる", rules: "v1", score: 0,
|
||||
glossaries: [
|
||||
glossary(first, ["to eat", "to live on (e.g. a salary)", "to have a meal"]),
|
||||
glossary(second, [{ type: "structured-content", content: [
|
||||
{ tag: "p", content: "朝ごはんを食べる。 — To eat breakfast." },
|
||||
{ tag: "img", path: "sample-meal.svg", width: 160, height: 80, title: "A bowl of rice and chopsticks" },
|
||||
{ tag: "details", content: [{ tag: "summary", content: "Usage note" },
|
||||
{ tag: "p", content: "食べる is an ichidan verb. Its polite form is 食べます。" }] },
|
||||
] }]),
|
||||
glossary("Sample collocations", ["ご飯を食べる — to eat a meal", "外で食べる — to eat out"]),
|
||||
glossary("Sample expressions", ["食べてみる — to try a food", "食べ終わる — to finish eating"]),
|
||||
],
|
||||
frequencies: [
|
||||
{ dictionary: "Sample ranks", frequencies: [{ value: 120, displayValue: "120" }, { value: 240, displayValue: "240" }] },
|
||||
{ dictionary: "Sample corpus", frequencies: [{ value: 18240, displayValue: "18,240" }] },
|
||||
...installedFrequencies,
|
||||
],
|
||||
pitches: [{ dictionary: pitch, pitches: [{ position: 2, pattern: "LHL", nasal: [], devoice: [] }], transcriptions: ["ta̠be̞ɾɯ̟ᵝ"] }],
|
||||
} }];
|
||||
return { results, dictionaryPresentation: [
|
||||
{ title: "Sample ranks", frequencyMode: "rank-based", frequencyCount: 2 },
|
||||
{ title: "Sample corpus", frequencyMode: "occurrence-based", frequencyCount: 1 }, ...enabled,
|
||||
] };
|
||||
}
|
||||
|
||||
function context() {
|
||||
return { ...HDPopup.metadataOptions(options),
|
||||
definitionBlurState: sampleBlurState(),
|
||||
showCompactDefinitionSummary: options.showCompactDefinitionSummary,
|
||||
compactDefinitionSummaryCount: options.compactDefinitionSummaryCount,
|
||||
compactDefinitionSummaryDictionary: options.compactDefinitionSummaryDictionary,
|
||||
dictionaryPresentation: sample.dictionaryPresentation,
|
||||
dictionaryTabGroups: state.groups.map(group => ({ ...group, dictionaries: group.dictionaryIds
|
||||
.map(id => state.dictionaries.find(entry => entry.id === id && entry.enabled)?.title).filter(Boolean) })),
|
||||
popupImageSources: imageSources,
|
||||
async resolveMedia(request) {
|
||||
const dictionary = imageSources === null ? request.dictionary : imageSources[0];
|
||||
if (!dictionary) throw new Error("No enabled image source in this selection.");
|
||||
request.onResolvedSource?.(dictionary);
|
||||
sampleMedia ??= fetch("sample-meal.svg").then(response => response.blob()).then(blob => URL.createObjectURL(blob));
|
||||
return sampleMedia;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderSample(preserveViewControls = false) {
|
||||
if (kanjiCharacter) {
|
||||
const capability = HDReaderOptions.resolveKanjiDictionary(options.kanjiClickDictionary, state.dictionaries);
|
||||
kanjiSource = capability;
|
||||
const renderContext = { ...context(), preserveViewControls, highlightText: candidate.query, onBack() {
|
||||
kanjiCharacter = null;
|
||||
renderSample();
|
||||
popup.querySelectorAll(".gsm-hoshidicts-kanji-link")[clickedKanjiIndex]?.focus({ preventScroll: true });
|
||||
} };
|
||||
if (capability?.kind === "term") {
|
||||
view.renderResults([{ matched: kanjiCharacter, trace: [], term: {
|
||||
expression: kanjiCharacter, reading: "しょく", glossaries: [{ dictionary: capability.title,
|
||||
glossary: JSON.stringify(["food; eating — sample single-kanji entry"]) }], frequencies: [], pitches: [],
|
||||
} }], candidate, renderContext);
|
||||
sampleTermView = true;
|
||||
armSampleBlur();
|
||||
} else {
|
||||
// Native kanji is outside term blur.
|
||||
sampleTermView = false;
|
||||
clearSampleBlurTimer();
|
||||
view.renderKanji({ character: kanjiCharacter, entries: [{ dictionary: capability?.title || "Sample kanji",
|
||||
onyomi: "ショク ジキ", kunyomi: "た.べる く.う", tags: "常用", definitions: ["eat", "food"],
|
||||
stats: [{ name: "strokes", value: "9" }, { name: "grade", value: "2" }],
|
||||
}] }, candidate, { ...renderContext, definitionBlurState: "revealed" });
|
||||
}
|
||||
} else {
|
||||
view.renderResults(sample.results, candidate, { ...context(), preserveViewControls,
|
||||
onDictionaryTabSelected(selection) { selectedDictionaryTab = selection; },
|
||||
selectedDictionaryTab: termView?.selectedDictionaryTab, expandAll: termView?.expandAll,
|
||||
restoreScrollTop: termView?.restoreScrollTop, disclosures: termView?.disclosures,
|
||||
});
|
||||
termView = null;
|
||||
sampleTermView = true;
|
||||
armSampleBlur();
|
||||
}
|
||||
}
|
||||
|
||||
window.HDDesignPreview = { update(nextOptions, nextState) {
|
||||
const toolbarChanged = !state || options.popupToolbarPosition !== nextOptions.popupToolbarPosition;
|
||||
const geometryChanged = !state || options.popupColumns !== nextOptions.popupColumns
|
||||
|| options.popupWidthPx !== nextOptions.popupWidthPx || options.popupHeightPx !== nextOptions.popupHeightPx
|
||||
|| options.popupScalePercent !== nextOptions.popupScalePercent;
|
||||
if (!state || options.sourceHighlightEnabled !== nextOptions.sourceHighlightEnabled) {
|
||||
view.setSourceHighlightEnabled(nextOptions.sourceHighlightEnabled);
|
||||
}
|
||||
appearance.update(nextOptions);
|
||||
const cssChanged = customStyle.update(nextOptions.customPopupCss);
|
||||
const countsChanged = !state || options.showLookupCounts !== nextOptions.showLookupCounts;
|
||||
// A blur edit restarts the sample decision so its effect is visible.
|
||||
const blurChanged = !state || DEFINITION_BLUR_KEYS.some(key => options[key] !== nextOptions[key]);
|
||||
options = { ...nextOptions };
|
||||
view.setCustomButtons(options.customButtons);
|
||||
updateSampleAudio();
|
||||
if (geometryChanged) view.hideImagePreview();
|
||||
if (geometryChanged || toolbarChanged) positionPopup(toolbarChanged);
|
||||
if (geometryChanged || cssChanged) view.scheduleMasonry();
|
||||
if (countsChanged) paintSampleLookupStats();
|
||||
if (blurChanged) {
|
||||
sampleRevealed = false;
|
||||
if (sampleTermView) {
|
||||
view.setDefinitionBlurState(sampleBlurState());
|
||||
armSampleBlur();
|
||||
}
|
||||
}
|
||||
const key = JSON.stringify([HDPopup.metadataOptions(nextOptions),
|
||||
nextOptions.showCompactDefinitionSummary, nextOptions.compactDefinitionSummaryCount,
|
||||
nextOptions.compactDefinitionSummaryDictionary, nextOptions.popupImageSource, nextOptions.kanjiClickDictionary, nextState.revision]);
|
||||
if (key === updateKey) return;
|
||||
updateKey = key;
|
||||
state = nextState;
|
||||
const nextSources = HDReaderOptions.resolvePopupImageSources(options.popupImageSource, state.dictionaries, state.groups);
|
||||
if (JSON.stringify(nextSources) !== JSON.stringify(imageSources)) imageSources = nextSources;
|
||||
const nextSample = createSample();
|
||||
const nextSampleKey = JSON.stringify(nextSample.results);
|
||||
sample = nextSample;
|
||||
const changed = kanjiCharacter
|
||||
? JSON.stringify(kanjiSource) !== JSON.stringify(HDReaderOptions.resolveKanjiDictionary(options.kanjiClickDictionary, state.dictionaries))
|
||||
: sampleKey !== nextSampleKey;
|
||||
sampleKey = nextSampleKey;
|
||||
if (changed) {
|
||||
if (!kanjiCharacter) termView = { ...view.captureTermView(), selectedDictionaryTab };
|
||||
renderSample(true);
|
||||
} else view.updateDictionaryPresentation(context());
|
||||
} };
|
||||
stylesheet.addEventListener("load", () => { appearance.refreshHighlight(); view.scheduleMasonry(); });
|
||||
window.addEventListener("pagehide", () => { clearSampleBlurTimer(); customStyle.destroy(); appearance.destroy(); view.destroy(); }, { once: true });
|
||||
}());
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Shared dictionary-group state rules for extension consumers.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function normaliseGroupName(value) {
|
||||
return (typeof value === "string" ? value : "")
|
||||
.normalize("NFKC")
|
||||
.trim()
|
||||
.replace(/\s+/gu, " ");
|
||||
}
|
||||
|
||||
function groupNameKey(value) {
|
||||
return normaliseGroupName(value).toLowerCase();
|
||||
}
|
||||
|
||||
function retainedMemberIds(value, installedIds) {
|
||||
const seen = new Set();
|
||||
return Array.isArray(value)
|
||||
? value.filter((id) => {
|
||||
if (!installedIds.has(id) || seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return true;
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
||||
function normaliseDictionaryGroups(value, installedDictionaries) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const installedIds = new Set(installedDictionaries.map((dictionary) => dictionary.id));
|
||||
return value.map((group) => {
|
||||
const id = typeof group?.id === "string" ? group.id : "";
|
||||
const name = normaliseGroupName(group?.name);
|
||||
if (id === "" || name === "") return null;
|
||||
return { id, name, dictionaryIds: retainedMemberIds(group.dictionaryIds, installedIds) };
|
||||
}).filter((group) => group !== null);
|
||||
}
|
||||
|
||||
function pruneGroupMemberships(value, dictionaries) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const installedIds = new Set(dictionaries.map((dictionary) => dictionary.id));
|
||||
return value.map((group) => ({
|
||||
...group,
|
||||
dictionaryIds: retainedMemberIds(group.dictionaryIds, installedIds),
|
||||
}));
|
||||
}
|
||||
|
||||
globalThis.HDDictionaryGroups = {
|
||||
normaliseGroupName, groupNameKey, normaliseDictionaryGroups, pruneGroupMemberships,
|
||||
};
|
||||
}());
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Dictionary-group Settings controls.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./dictionary-group-state.js";
|
||||
|
||||
const { normaliseGroupName, groupNameKey } = globalThis.HDDictionaryGroups;
|
||||
export const normaliseDictionaryGroups = globalThis.HDDictionaryGroups.normaliseDictionaryGroups;
|
||||
|
||||
const ALL_GROUP_NAME_KEY = groupNameKey("All");
|
||||
|
||||
function element(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function groupNameError(groups, name, excludedId = null) {
|
||||
if (name === "") return "Enter a group name.";
|
||||
const key = groupNameKey(name);
|
||||
if (key === ALL_GROUP_NAME_KEY) return "All is reserved and cannot be used as a group name.";
|
||||
if (groups.some((group) =>
|
||||
group.id !== excludedId && groupNameKey(group.name) === key)) {
|
||||
return "A group with this name already exists.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function bindMoveButtons(row, prefix, index, length, label, move) {
|
||||
const up = row.querySelector(`.${prefix}-up`);
|
||||
const down = row.querySelector(`.${prefix}-down`);
|
||||
up.dataset.pinnedDisabled = String(index === 0);
|
||||
down.dataset.pinnedDisabled = String(index === length - 1);
|
||||
up.setAttribute("aria-label", `Move ${label} up`);
|
||||
down.setAttribute("aria-label", `Move ${label} down`);
|
||||
up.addEventListener("click", () => move(-1));
|
||||
down.addEventListener("click", () => move(1));
|
||||
}
|
||||
|
||||
export function createDictionaryGroupController({
|
||||
setError,
|
||||
readState,
|
||||
readDictionaries,
|
||||
commitGroups,
|
||||
dictionaryLabel,
|
||||
moveListItem,
|
||||
updateItemById,
|
||||
renderDeferredAfterBlur,
|
||||
bindNameDraft,
|
||||
}) {
|
||||
function changeNamedGroup(name, excludedId, update) {
|
||||
void commitGroups((current) => {
|
||||
const error = groupNameError(current, name, excludedId);
|
||||
if (error) {
|
||||
setError(error);
|
||||
return null;
|
||||
}
|
||||
setError("");
|
||||
return update(current);
|
||||
});
|
||||
}
|
||||
|
||||
function changeGroup(id, update) {
|
||||
void commitGroups((current) => updateItemById(current, id, update));
|
||||
}
|
||||
|
||||
function moveGroup(id, step) {
|
||||
void commitGroups((current) => {
|
||||
const index = current.findIndex((group) => group.id === id);
|
||||
return moveListItem(current, index, index + step);
|
||||
});
|
||||
}
|
||||
|
||||
function moveMember(groupId, dictionaryId, step) {
|
||||
changeGroup(groupId, (group) => {
|
||||
const index = group.dictionaryIds.indexOf(dictionaryId);
|
||||
const dictionaryIds = moveListItem(group.dictionaryIds, index, index + step);
|
||||
return dictionaryIds === null ? group : { ...group, dictionaryIds };
|
||||
});
|
||||
}
|
||||
|
||||
function renderMember(group, dictionary, index) {
|
||||
const row = element("dict-group-member-template").content.firstElementChild.cloneNode(true);
|
||||
const label = dictionaryLabel(dictionary);
|
||||
row.dataset.dictionaryId = dictionary.id;
|
||||
row.querySelector(".dict-group-member-name").textContent = label;
|
||||
const canonical = row.querySelector(".dict-group-member-canonical");
|
||||
canonical.textContent = dictionary.displayName ? dictionary.title : "";
|
||||
canonical.hidden = !dictionary.displayName;
|
||||
|
||||
bindMoveButtons(
|
||||
row,
|
||||
"dict-group-member",
|
||||
index,
|
||||
group.dictionaryIds.length,
|
||||
`${label} in ${group.name}`,
|
||||
(step) => moveMember(group.id, dictionary.id, step),
|
||||
);
|
||||
|
||||
const remove = row.querySelector(".dict-group-member-remove");
|
||||
remove.setAttribute("aria-label", `Remove ${label} from ${group.name}`);
|
||||
remove.addEventListener("click", () => {
|
||||
changeGroup(group.id, (current) => ({
|
||||
...current,
|
||||
dictionaryIds: current.dictionaryIds.filter((id) => id !== dictionary.id),
|
||||
}));
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function bindName(row, group) {
|
||||
const input = row.querySelector(".dict-group-name");
|
||||
input.setAttribute("aria-label", `Name for ${group.name}`);
|
||||
bindNameDraft(input, "groups", group.id, "name", group.name, normaliseGroupName, groupNameError);
|
||||
renderDeferredAfterBlur(input);
|
||||
}
|
||||
|
||||
function bindAdd(row, group, dictionaries) {
|
||||
const select = row.querySelector(".dict-group-add-select");
|
||||
const add = row.querySelector(".dict-group-add");
|
||||
const available = dictionaries.filter((dictionary) => !group.dictionaryIds.includes(dictionary.id));
|
||||
const placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
placeholder.textContent = available.length === 0 ? "All dictionaries added" : "Choose a dictionary";
|
||||
select.appendChild(placeholder);
|
||||
for (const dictionary of available) {
|
||||
const option = document.createElement("option");
|
||||
option.value = dictionary.id;
|
||||
option.textContent = dictionaryLabel(dictionary);
|
||||
select.appendChild(option);
|
||||
}
|
||||
select.dataset.pinnedDisabled = String(available.length === 0);
|
||||
add.dataset.pinnedDisabled = String(available.length === 0);
|
||||
add.addEventListener("click", () => {
|
||||
if (select.value === "") return;
|
||||
const dictionaryId = select.value;
|
||||
changeGroup(group.id, (current) => ({
|
||||
...current,
|
||||
dictionaryIds: [...current.dictionaryIds, dictionaryId],
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function renderRow(group, index, state, dictionaries, installedById) {
|
||||
const row = element("dict-group-template").content.firstElementChild.cloneNode(true);
|
||||
row.dataset.groupId = group.id;
|
||||
bindName(row, group);
|
||||
|
||||
const remove = row.querySelector(".dict-group-delete");
|
||||
remove.setAttribute("aria-label", `Delete ${group.name}`);
|
||||
bindMoveButtons(
|
||||
row,
|
||||
"dict-group",
|
||||
index,
|
||||
state.groups.length,
|
||||
group.name,
|
||||
(step) => moveGroup(group.id, step),
|
||||
);
|
||||
remove.addEventListener("click", () => {
|
||||
void commitGroups((current) => current.filter((entry) => entry.id !== group.id));
|
||||
});
|
||||
|
||||
bindAdd(row, group, dictionaries);
|
||||
const members = row.querySelector(".dict-group-members");
|
||||
group.dictionaryIds.forEach((dictionaryId, memberIndex) => {
|
||||
members.appendChild(renderMember(group, installedById.get(dictionaryId), memberIndex));
|
||||
});
|
||||
row.querySelector(".dict-group-members-empty").hidden = group.dictionaryIds.length > 0;
|
||||
return row;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const state = readState();
|
||||
const dictionaries = readDictionaries();
|
||||
const installedById = new Map(dictionaries.map((dictionary) => [dictionary.id, dictionary]));
|
||||
const list = element("dict-group-list");
|
||||
list.textContent = "";
|
||||
state.groups.forEach((group, index) => {
|
||||
list.appendChild(renderRow(group, index, state, dictionaries, installedById));
|
||||
});
|
||||
element("dict-group-empty").hidden = state.groups.length > 0;
|
||||
}
|
||||
|
||||
function create() {
|
||||
const input = element("dict-group-name-new");
|
||||
const name = normaliseGroupName(input.value);
|
||||
const error = groupNameError(readState().groups, name);
|
||||
if (error) {
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
input.value = "";
|
||||
setError("");
|
||||
changeNamedGroup(name, null, (current) => [
|
||||
...current,
|
||||
{ id: crypto.randomUUID(), name, dictionaryIds: [] },
|
||||
]);
|
||||
}
|
||||
|
||||
return { create, render };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Reads only Yomitan index.json for an interactive import decision. The engine
|
||||
* still imports and validates the complete archive before anything is published.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { BlobReader, Writer, ZipReader } from "./vendor/zip.js";
|
||||
import { dictionaryArchiveIdentity } from "./dictionary-import.js";
|
||||
|
||||
export const MAX_PREFLIGHT_INDEX_BYTES = 1024 * 1024;
|
||||
|
||||
export class BoundedIndexWriter extends Writer {
|
||||
constructor(limit) {
|
||||
super();
|
||||
this.limit = limit;
|
||||
this.length = 0;
|
||||
this.parts = [];
|
||||
}
|
||||
|
||||
writeUint8Array(bytes) {
|
||||
if (this.length + bytes.byteLength > this.limit) {
|
||||
throw new Error(`The selected archive's index.json exceeds ${this.limit} bytes.`);
|
||||
}
|
||||
this.parts.push(bytes.slice());
|
||||
this.length += bytes.byteLength;
|
||||
}
|
||||
|
||||
getData() {
|
||||
const data = new Uint8Array(this.length);
|
||||
let offset = 0;
|
||||
for (const part of this.parts) {
|
||||
data.set(part, offset);
|
||||
offset += part.byteLength;
|
||||
}
|
||||
return new TextDecoder().decode(data);
|
||||
}
|
||||
}
|
||||
|
||||
function assertRegularIndex(entry) {
|
||||
const kind = entry.unixMode === undefined ? 0 : entry.unixMode & 0o170000;
|
||||
if (entry.directory || entry.symlink || (kind !== 0 && kind !== 0o100000)) {
|
||||
throw new Error("The selected archive's index.json is not a regular file.");
|
||||
}
|
||||
if (entry.encrypted) {
|
||||
throw new Error("The selected archive's index.json is encrypted.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function readDictionaryArchiveIdentity(blob) {
|
||||
const reader = new ZipReader(new BlobReader(blob), {
|
||||
useWebWorkers: false,
|
||||
checkAmbiguity: true,
|
||||
});
|
||||
try {
|
||||
const entries = await reader.getEntries();
|
||||
const indexes = entries.filter(entry => entry.filename === "index.json");
|
||||
if (indexes.length !== 1) {
|
||||
throw new Error(indexes.length === 0
|
||||
? "The selected archive has no index.json."
|
||||
: "The selected archive has more than one index.json.");
|
||||
}
|
||||
const entry = indexes[0];
|
||||
assertRegularIndex(entry);
|
||||
if (entry.uncompressedSize > MAX_PREFLIGHT_INDEX_BYTES) {
|
||||
throw new Error(`The selected archive's index.json exceeds ${MAX_PREFLIGHT_INDEX_BYTES} bytes.`);
|
||||
}
|
||||
const data = await entry.getData(new BoundedIndexWriter(MAX_PREFLIGHT_INDEX_BYTES), {
|
||||
useWebWorkers: false,
|
||||
checkSignature: true,
|
||||
checkOverlappingEntry: true,
|
||||
checkAmbiguity: true,
|
||||
});
|
||||
let index;
|
||||
try {
|
||||
index = JSON.parse(data);
|
||||
} catch (error) {
|
||||
throw new Error(`The selected archive's index.json is malformed: ${error.message || String(error)}`);
|
||||
}
|
||||
return dictionaryArchiveIdentity(index);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Local dictionary import identity and revision helpers.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export const MAX_REVISION_COMPONENTS = 32;
|
||||
export const MAX_REVISION_COMPONENT_LENGTH = 64;
|
||||
export const MAX_REVISION_LENGTH = 2048;
|
||||
|
||||
function optionalExactString(value) {
|
||||
return typeof value === "string" && value !== "" ? value : null;
|
||||
}
|
||||
|
||||
function comparableRevision(value) {
|
||||
if (typeof value !== "string"
|
||||
|| value.length === 0
|
||||
|| value.length > MAX_REVISION_LENGTH
|
||||
|| !/^[0-9]+(?:\.[0-9]+)*$/u.test(value)) {
|
||||
return null;
|
||||
}
|
||||
const components = value.split(".");
|
||||
if (components.length > MAX_REVISION_COMPONENTS
|
||||
|| components.some(component => component.length > MAX_REVISION_COMPONENT_LENGTH)) {
|
||||
return null;
|
||||
}
|
||||
const normalised = components.map((component) => {
|
||||
const withoutLeadingZeroes = component.replace(/^0+(?=[0-9])/u, "");
|
||||
return withoutLeadingZeroes === "" ? "0" : withoutLeadingZeroes;
|
||||
});
|
||||
while (normalised.length > 1 && normalised.at(-1) === "0") normalised.pop();
|
||||
return normalised;
|
||||
}
|
||||
|
||||
function compareNumericComponent(left, right) {
|
||||
if (left.length !== right.length) return left.length < right.length ? -1 : 1;
|
||||
if (left === right) return 0;
|
||||
return left < right ? -1 : 1;
|
||||
}
|
||||
|
||||
export function compareDictionaryRevisions(imported, installed) {
|
||||
const left = comparableRevision(imported);
|
||||
const right = comparableRevision(installed);
|
||||
if (left === null || right === null) return "uncomparable";
|
||||
const length = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const comparison = compareNumericComponent(left[index] ?? "0", right[index] ?? "0");
|
||||
if (comparison < 0) return "lower";
|
||||
if (comparison > 0) return "higher";
|
||||
}
|
||||
return "same";
|
||||
}
|
||||
|
||||
export function dictionaryArchiveIdentity(index) {
|
||||
if (!index || typeof index !== "object" || Array.isArray(index)
|
||||
|| typeof index.title !== "string" || index.title === "") {
|
||||
throw new Error("The selected archive's index.json has no dictionary title.");
|
||||
}
|
||||
return {
|
||||
title: index.title,
|
||||
revision: typeof index.revision === "string" ? index.revision : null,
|
||||
indexUrl: optionalExactString(index.indexUrl),
|
||||
downloadUrl: optionalExactString(index.downloadUrl),
|
||||
};
|
||||
}
|
||||
|
||||
export function dictionaryImportTarget(dictionary) {
|
||||
return {
|
||||
id: typeof dictionary?.id === "string" ? dictionary.id : "",
|
||||
title: typeof dictionary?.title === "string" ? dictionary.title : "",
|
||||
path: typeof dictionary?.path === "string" ? dictionary.path : "",
|
||||
revision: typeof dictionary?.revision === "string" ? dictionary.revision : "",
|
||||
sourceId: optionalExactString(dictionary?.sourceId),
|
||||
indexUrl: optionalExactString(dictionary?.indexUrl),
|
||||
downloadUrl: optionalExactString(dictionary?.downloadUrl),
|
||||
isUpdatable: dictionary?.isUpdatable === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function dictionaryImportMatches(identity, dictionaries) {
|
||||
const exact = dictionaries
|
||||
.filter(dictionary => dictionary?.title === identity.title)
|
||||
.map(dictionary => ({ dictionary, kind: "title" }));
|
||||
if (exact.length > 0 || identity.indexUrl === null) return exact;
|
||||
return dictionaries
|
||||
.filter(dictionary => optionalExactString(dictionary?.indexUrl) === identity.indexUrl)
|
||||
.map(dictionary => ({ dictionary, kind: "source" }));
|
||||
}
|
||||
|
||||
export function describeRevisionComparison(imported, installed) {
|
||||
switch (compareDictionaryRevisions(imported, installed)) {
|
||||
case "higher":
|
||||
return "The imported revision is newer than the installed revision.";
|
||||
case "same":
|
||||
return "The imported and installed revisions are the same.";
|
||||
case "lower":
|
||||
return "The imported revision is older than the installed revision. Replacing will downgrade it.";
|
||||
default:
|
||||
return "Hachidori cannot compare these revision values. Choose how to import the archive.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Group names and package aliases share an editing baseline, not the latest
|
||||
// broad dictionary revision: unrelated presentation changes may be merged.
|
||||
export function renameWithBaseline(items, id, field, baseName, name, validate = () => "") {
|
||||
const entry = items.find(item => item.id === id);
|
||||
if (!entry) return { error: "This item was removed elsewhere.", retryRequired: true };
|
||||
const current = entry[field] ?? "";
|
||||
if (current !== baseName && current !== name) {
|
||||
return { error: `The name changed elsewhere to “${current}”. Your draft is kept.`, retryRequired: true };
|
||||
}
|
||||
const error = validate(items, name, id);
|
||||
if (error) return { error, retryRequired: false };
|
||||
return { items: current === name ? items : items.map(item => item === entry
|
||||
? { ...item, [field]: name || null } : item) };
|
||||
}
|
||||
|
||||
export function createDictionaryNameDrafts({ delayMs, afterSave = () => {} }) {
|
||||
const drafts = new Map();
|
||||
const pending = draft => draft.dirty || draft.saving || draft.error !== "";
|
||||
|
||||
function paint(draft) {
|
||||
const { input } = draft;
|
||||
if (input.value !== draft.value) input.value = draft.value;
|
||||
if (!draft.error) {
|
||||
draft.feedback?.remove();
|
||||
draft.feedback = null;
|
||||
input.removeAttribute("aria-invalid");
|
||||
return;
|
||||
}
|
||||
input.setAttribute("aria-invalid", "true");
|
||||
if (!draft.feedback) {
|
||||
const document = input.ownerDocument;
|
||||
const feedback = document.createElement("div");
|
||||
feedback.className = "name-draft-feedback";
|
||||
const output = document.createElement("output");
|
||||
output.setAttribute("role", "status");
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "options-actions";
|
||||
for (const [className, label, action] of [
|
||||
["name-draft-retry", "Retry my name", () => {
|
||||
const current = draft.readName();
|
||||
if (current === undefined) return;
|
||||
draft.baseName = current;
|
||||
draft.error = "";
|
||||
void flush(draft);
|
||||
}],
|
||||
["name-draft-discard", "Use saved name", () => {
|
||||
clearTimeout(draft.timer);
|
||||
draft.timer = null;
|
||||
draft.value = draft.baseName = draft.readName() ?? "";
|
||||
draft.dirty = false;
|
||||
draft.error = "";
|
||||
paint(draft);
|
||||
afterSave();
|
||||
}],
|
||||
]) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `ghost ${className}`;
|
||||
button.textContent = label;
|
||||
button.addEventListener("click", action);
|
||||
actions.append(button);
|
||||
}
|
||||
feedback.append(output, actions);
|
||||
input.parentElement.after(feedback);
|
||||
draft.feedback = feedback;
|
||||
}
|
||||
draft.feedback.firstElementChild.textContent = draft.error;
|
||||
}
|
||||
|
||||
async function flush(draft) {
|
||||
clearTimeout(draft.timer);
|
||||
draft.timer = null;
|
||||
if (draft.saving || draft.error || !draft.dirty) return;
|
||||
const name = draft.normalise(draft.value);
|
||||
draft.dirty = false;
|
||||
if (name === draft.baseName) {
|
||||
paint(draft);
|
||||
afterSave();
|
||||
return;
|
||||
}
|
||||
draft.saving = true;
|
||||
draft.retryRequired = true;
|
||||
paint(draft);
|
||||
try {
|
||||
const reply = await draft.save(draft.baseName, name);
|
||||
if (!reply.ok) {
|
||||
draft.retryRequired = reply.retryRequired !== false;
|
||||
throw new Error(reply.error ?? "The name was not saved. Your draft is kept.");
|
||||
}
|
||||
// The storage event may already contain a newer external name. Only our
|
||||
// own committed value can advance the queued draft's baseline.
|
||||
draft.baseName = name;
|
||||
} catch (error) {
|
||||
if (draft.retryRequired || !draft.dirty) draft.error = error.message;
|
||||
draft.dirty = true;
|
||||
} finally {
|
||||
draft.saving = false;
|
||||
paint(draft);
|
||||
if (drafts.get(draft.key) === draft && draft.timer === null) void flush(draft);
|
||||
afterSave();
|
||||
}
|
||||
}
|
||||
|
||||
function bind(key, input, options) {
|
||||
let draft = drafts.get(key);
|
||||
if (!draft || !pending(draft)) {
|
||||
draft = { key, value: options.value, baseName: options.value, dirty: false,
|
||||
saving: false, timer: null, error: "", feedback: null };
|
||||
drafts.set(key, draft);
|
||||
}
|
||||
draft.feedback?.remove();
|
||||
Object.assign(draft, options, { input, value: pending(draft) ? draft.value : options.value, feedback: null });
|
||||
paint(draft);
|
||||
const edit = immediate => {
|
||||
draft.value = input.value;
|
||||
draft.dirty = true;
|
||||
if (!draft.retryRequired) {
|
||||
draft.error = "";
|
||||
paint(draft);
|
||||
}
|
||||
clearTimeout(draft.timer);
|
||||
draft.timer = null;
|
||||
if (immediate) void flush(draft);
|
||||
else if (!draft.error) draft.timer = setTimeout(() => { void flush(draft); }, delayMs);
|
||||
};
|
||||
input.addEventListener("input", () => edit(false));
|
||||
input.addEventListener("change", () => edit(true));
|
||||
input.addEventListener("blur", () => { void flush(draft); });
|
||||
}
|
||||
|
||||
function retain(keys) {
|
||||
for (const [key, draft] of drafts) {
|
||||
if (keys.has(key)) continue;
|
||||
clearTimeout(draft.timer);
|
||||
drafts.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return { bind, retain, hasPendingChanges: () => [...drafts.values()].some(pending),
|
||||
hasInFlightSave: () => [...drafts.values()].some(draft => draft.saving),
|
||||
dispose: () => retain(new Set()) };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
||||
|
||||
.setup-dictionary-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.setup-dictionary-list[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.setup-dictionary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
column-gap: 16px;
|
||||
row-gap: 2px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.setup-dictionary-name {
|
||||
grid-area: 1 / 1;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.setup-dictionary-purpose {
|
||||
grid-area: 2 / 1;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.setup-dictionary-status {
|
||||
grid-area: 3 / 1;
|
||||
margin-top: 5px;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.setup-dictionary.is-active {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.setup-dictionary-status.is-ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.setup-dictionary-status.is-error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.setup-dictionary .setup-track {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.setup-track.is-determinate .track-fill {
|
||||
width: var(--progress, 0%);
|
||||
animation: none;
|
||||
transition: width 120ms linear;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.setup-track.is-determinate .track-fill {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
export function formatBytes(bytes) {
|
||||
return bytes < 1_048_576
|
||||
? `${Math.round(bytes / 1024)} KB`
|
||||
: `${(bytes / 1_048_576).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function formatSeconds(seconds) {
|
||||
return seconds < 10 ? `${seconds.toFixed(1)} seconds` : `${Math.round(seconds)} seconds`;
|
||||
}
|
||||
|
||||
export function installEntryState(entry) {
|
||||
switch (entry.phase) {
|
||||
case "downloading": {
|
||||
const received = formatBytes(entry.receivedBytes);
|
||||
if (entry.totalBytes === null) return { text: `Downloading… ${received}`, progress: { value: null } };
|
||||
const fraction = Math.min(1, entry.receivedBytes / entry.totalBytes);
|
||||
return { text: `Downloading… ${received} of ${formatBytes(entry.totalBytes)} (${Math.floor(fraction * 100)}%)`,
|
||||
progress: { value: fraction } };
|
||||
}
|
||||
case "installing": return { text: "Installing…", progress: { value: null } };
|
||||
case "installed": return { text: `Installed in ${formatSeconds(entry.seconds)}`, tone: "ok" };
|
||||
case "already-installed": return { text: "Already installed", tone: "ok" };
|
||||
case "failed": return { text: `Failed: ${entry.error}`, tone: "error" };
|
||||
default: return { text: "Waiting" };
|
||||
}
|
||||
}
|
||||
|
||||
export function createDictionaryProgressList({
|
||||
document,
|
||||
ariaLabel,
|
||||
idPrefix = "dictionary-progress",
|
||||
}) {
|
||||
const list = document.createElement("ul");
|
||||
list.className = "setup-dictionary-list";
|
||||
list.setAttribute("aria-label", ariaLabel);
|
||||
list.hidden = true;
|
||||
const rows = new Map();
|
||||
|
||||
function setEntries(entries) {
|
||||
rows.clear();
|
||||
list.replaceChildren();
|
||||
entries.forEach((entry, index) => {
|
||||
const key = String(entry.id);
|
||||
const row = document.createElement("li");
|
||||
row.className = "setup-dictionary";
|
||||
row.dataset.sourceId = key;
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "setup-dictionary-name";
|
||||
name.id = entry.labelId ?? `${idPrefix}-${index}`;
|
||||
name.textContent = entry.name;
|
||||
|
||||
const purpose = document.createElement("span");
|
||||
purpose.className = "setup-dictionary-purpose";
|
||||
purpose.textContent = entry.purpose ?? "";
|
||||
purpose.hidden = purpose.textContent === "";
|
||||
|
||||
const status = document.createElement("span");
|
||||
status.className = "setup-dictionary-status";
|
||||
status.id = `${idPrefix}-status-${index}`;
|
||||
status.textContent = entry.status ?? "Waiting";
|
||||
|
||||
const track = document.createElement("div");
|
||||
track.className = "track setup-track";
|
||||
track.hidden = true;
|
||||
track.setAttribute("role", "progressbar");
|
||||
track.setAttribute("aria-labelledby", name.id);
|
||||
track.setAttribute("aria-describedby", status.id);
|
||||
track.appendChild(document.createElement("div")).className = "track-fill";
|
||||
|
||||
row.append(name, purpose, status, track);
|
||||
list.appendChild(row);
|
||||
rows.set(key, { row, status, track });
|
||||
});
|
||||
list.hidden = entries.length === 0;
|
||||
}
|
||||
|
||||
function update(id, state) {
|
||||
const current = rows.get(String(id));
|
||||
if (!current) return;
|
||||
const { row, status, track } = current;
|
||||
if (status.textContent !== state.text) status.textContent = state.text;
|
||||
status.classList.toggle("is-ok", state.tone === "ok");
|
||||
status.classList.toggle("is-error", state.tone === "error");
|
||||
row.classList.toggle("is-active", Boolean(state.progress));
|
||||
track.hidden = !state.progress;
|
||||
if (!state.progress) return;
|
||||
|
||||
const value = state.progress.value;
|
||||
const determinate = typeof value === "number";
|
||||
track.classList.toggle("is-determinate", determinate);
|
||||
if (determinate) {
|
||||
const percent = Math.floor(Math.min(1, Math.max(0, value)) * 100);
|
||||
track.removeAttribute("aria-valuetext");
|
||||
track.setAttribute("aria-valuemin", "0");
|
||||
track.setAttribute("aria-valuemax", "100");
|
||||
track.setAttribute("aria-valuenow", String(percent));
|
||||
track.style.setProperty("--progress", `${percent}%`);
|
||||
} else {
|
||||
track.removeAttribute("aria-valuenow");
|
||||
track.removeAttribute("aria-valuemin");
|
||||
track.removeAttribute("aria-valuemax");
|
||||
track.style.removeProperty("--progress");
|
||||
track.setAttribute("aria-valuetext", state.text);
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
setEntries([]);
|
||||
}
|
||||
|
||||
return { element: list, setEntries, update, clear };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Engine worker for hosts with shared memory and workers but no OPFS access
|
||||
* handles (Electron refuses them to chrome-extension:// origins): pthread
|
||||
* Hoshidicts on the classic FS with IDBFS persistence, so imports still use the
|
||||
* bounded worker group instead of one thread. See engine-worker-runtime.js.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import createHoshidicts from "./vendor/hoshidicts-threaded-idbfs.mjs";
|
||||
import { startEngineWorker } from "./engine-worker-runtime.js";
|
||||
|
||||
startEngineWorker({ createHoshidicts, storageBackend: "idbfs" });
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Dedicated owner of a pthread Hoshidicts WASM runtime.
|
||||
*
|
||||
* The runtime lives off the browser main thread so Hoshidicts can join pthreads
|
||||
* and WasmFS can synchronously proxy OPFS operations without deadlocking an
|
||||
* extension page. engine-worker.js starts it on WasmFS + direct OPFS and
|
||||
* engine-worker-idbfs.js on the classic FS + IDBFS; both call startEngineWorker.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import {
|
||||
configureEngineService,
|
||||
handleEngineMessage,
|
||||
startEngine,
|
||||
} from "./engine-service.js";
|
||||
import { boundResponseFailure } from "./response-limits.js";
|
||||
|
||||
let nextHostRequestId = 0;
|
||||
let nextProgressId = 0;
|
||||
const HOST_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const pendingHostRequests = new Map();
|
||||
const pendingProgressAcks = new Map();
|
||||
|
||||
function describe(error) {
|
||||
return error instanceof Error ? error.message || String(error) : String(error);
|
||||
}
|
||||
|
||||
function requestHost(message) {
|
||||
nextHostRequestId += 1;
|
||||
const id = nextHostRequestId;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (!pendingHostRequests.delete(id)) return;
|
||||
reject(new Error("the extension host did not answer the engine worker"));
|
||||
}, HOST_REQUEST_TIMEOUT_MS);
|
||||
pendingHostRequests.set(id, { resolve, reject, timer });
|
||||
globalThis.postMessage({ channel: "host-request", id, message });
|
||||
});
|
||||
}
|
||||
|
||||
function reportEngineProgress(progress) {
|
||||
if (progress?.phase !== "installing") {
|
||||
globalThis.postMessage({ channel: "engine-progress", progress });
|
||||
return undefined;
|
||||
}
|
||||
nextProgressId += 1;
|
||||
const id = nextProgressId;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (!pendingProgressAcks.delete(id)) return;
|
||||
reject(new Error("the extension host did not acknowledge the import lock"));
|
||||
}, HOST_REQUEST_TIMEOUT_MS);
|
||||
pendingProgressAcks.set(id, { resolve, reject, timer });
|
||||
globalThis.postMessage({ channel: "engine-progress", id, progress });
|
||||
});
|
||||
}
|
||||
|
||||
export function startEngineWorker({ createHoshidicts, storageBackend }) {
|
||||
configureEngineService(requestHost, {
|
||||
createHoshidicts,
|
||||
storageBackend,
|
||||
lowRam: false,
|
||||
reportProgress: reportEngineProgress,
|
||||
});
|
||||
startEngine();
|
||||
// A dedicated worker receives only from its creator over its implicit
|
||||
// MessagePort. MessageEvent.origin is always empty, so there is no origin
|
||||
// value to validate; the channel checks in onHostMessage validate the
|
||||
// expected protocol.
|
||||
globalThis.onmessage = onHostMessage; // NOSONAR
|
||||
}
|
||||
|
||||
function onHostMessage(event) {
|
||||
const data = event.data;
|
||||
if (data?.channel === "host-response") {
|
||||
const pending = pendingHostRequests.get(data.id);
|
||||
if (pending === undefined) return;
|
||||
pendingHostRequests.delete(data.id);
|
||||
clearTimeout(pending.timer);
|
||||
if (data.ok === true) pending.resolve(data.response);
|
||||
else pending.reject(new Error(data.error || "host request failed"));
|
||||
return;
|
||||
}
|
||||
if (data?.channel === "engine-progress-ack") {
|
||||
const pending = pendingProgressAcks.get(data.id);
|
||||
if (pending === undefined) return;
|
||||
pendingProgressAcks.delete(data.id);
|
||||
clearTimeout(pending.timer);
|
||||
if (data.ok === true) pending.resolve();
|
||||
else pending.reject(new Error(data.error || "the extension host refused the import lock"));
|
||||
return;
|
||||
}
|
||||
if (data?.channel !== "engine-request") return;
|
||||
|
||||
Promise.resolve(handleEngineMessage(data.message)).then(
|
||||
(response) => globalThis.postMessage({ channel: "engine-response", id: data.id, response }),
|
||||
(error) => globalThis.postMessage({
|
||||
channel: "engine-response",
|
||||
id: data.id,
|
||||
response: boundResponseFailure({
|
||||
type: `${data.message?.type || "hd_unknown"}_result`,
|
||||
requestId: data.message?.requestId ?? null,
|
||||
ok: false,
|
||||
error: describe(error),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Engine worker for the primary runtime: pthread Hoshidicts on WasmFS + direct
|
||||
* OPFS. See engine-worker-runtime.js.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import createHoshidicts from "./vendor/hoshidicts-threaded.mjs";
|
||||
import { startEngineWorker } from "./engine-worker-runtime.js";
|
||||
|
||||
startEngineWorker({ createHoshidicts, storageBackend: "opfs" });
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Settings → Advanced → Experimental features. Rows are built once from the
|
||||
// registry in reader-options.js; `render` only reflects the stored flags.
|
||||
export function createExperimentalSettings({ document, features, onToggle }) {
|
||||
const list = document.getElementById("experimental-features");
|
||||
const empty = document.getElementById("experimental-empty");
|
||||
const rows = new Map();
|
||||
|
||||
for (const feature of features) {
|
||||
const item = document.createElement("li");
|
||||
const label = document.createElement("label");
|
||||
label.className = "lookup-enable";
|
||||
const name = document.createElement("span");
|
||||
name.textContent = feature.label;
|
||||
const input = document.createElement("input");
|
||||
input.id = `opt-experimental-${feature.id}`;
|
||||
input.type = "checkbox";
|
||||
input.setAttribute("aria-describedby", `${input.id}-help`);
|
||||
input.addEventListener("change", () => onToggle(feature.id, input.checked));
|
||||
label.append(name, input);
|
||||
const hint = document.createElement("p");
|
||||
hint.className = "field-hint";
|
||||
hint.id = `${input.id}-help`;
|
||||
hint.textContent = feature.description;
|
||||
let link = null;
|
||||
if (feature.section) {
|
||||
link = document.createElement("a");
|
||||
link.href = `#${feature.section}`;
|
||||
link.textContent = "Open its settings";
|
||||
hint.append(" ", link);
|
||||
}
|
||||
item.append(label, hint);
|
||||
list.append(item);
|
||||
rows.set(feature.id, { input, link });
|
||||
}
|
||||
empty.hidden = features.length > 0;
|
||||
|
||||
function render(experimental) {
|
||||
for (const [id, { input, link }] of rows) {
|
||||
const enabled = experimental[id] === true;
|
||||
input.checked = enabled;
|
||||
if (link) link.hidden = !enabled;
|
||||
}
|
||||
}
|
||||
|
||||
return { render };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// A narrow request/result contract for an embedding host. Browser mode keeps
|
||||
// using the service worker and chrome.tabs.create; overlay mode uses this only
|
||||
// after external-links.js has normalized the URL.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const REQUEST_EVENT = "hachidori-open-external";
|
||||
const RESULT_EVENT = "hachidori-open-external-result";
|
||||
const DEFAULT_TIMEOUT_MS = 5000;
|
||||
let nextRequestId = 0;
|
||||
|
||||
function requestId(windowRef) {
|
||||
if (typeof windowRef.crypto?.randomUUID === "function") return windowRef.crypto.randomUUID();
|
||||
nextRequestId += 1;
|
||||
return `hachidori-external-${Date.now().toString(36)}-${nextRequestId.toString(36)}`;
|
||||
}
|
||||
|
||||
function open(windowRef, value, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
||||
const url = globalThis.HDExternalLinks.normaliseExternalUrl(value?.url);
|
||||
if (!url) return Promise.reject(new TypeError("external link URL is invalid"));
|
||||
const active = value?.active === undefined ? true : value.active;
|
||||
if (typeof active !== "boolean") {
|
||||
return Promise.reject(new TypeError("external link activation is invalid"));
|
||||
}
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
||||
return Promise.reject(new TypeError("external link timeout is invalid"));
|
||||
}
|
||||
|
||||
const id = requestId(windowRef);
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer = null;
|
||||
const finish = (callback, result) => {
|
||||
if (timer !== null) windowRef.clearTimeout(timer);
|
||||
windowRef.removeEventListener(RESULT_EVENT, onResult);
|
||||
callback(result);
|
||||
};
|
||||
const onResult = (event) => {
|
||||
const detail = event?.detail;
|
||||
if (!detail || detail.requestId !== id) return;
|
||||
if (detail.ok === true) {
|
||||
finish(resolve, { opened: true });
|
||||
return;
|
||||
}
|
||||
const message = typeof detail.error === "string" && detail.error
|
||||
? detail.error : "the overlay host could not open the link";
|
||||
finish(reject, new Error(message));
|
||||
};
|
||||
windowRef.addEventListener(RESULT_EVENT, onResult);
|
||||
timer = windowRef.setTimeout(() => {
|
||||
finish(reject, new Error("the overlay host did not answer the external link request"));
|
||||
}, timeoutMs);
|
||||
windowRef.dispatchEvent(new windowRef.CustomEvent(REQUEST_EVENT, {
|
||||
detail: { requestId: id, url, active },
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.HDExternalLinkHost = Object.freeze({
|
||||
REQUEST_EVENT,
|
||||
RESULT_EVENT,
|
||||
open,
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Shared by classic content scripts and the module service worker.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function normaliseExternalUrl(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
if (/[\u0000-\u001f\u007f]/u.test(value)) return null;
|
||||
const source = value.trim();
|
||||
if (!/^https?:\/\//iu.test(source)) return null;
|
||||
try {
|
||||
const url = new URL(source);
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password
|
||||
? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// GSM popup links use %w for the word and %s for the sentence. Reading is
|
||||
// useful to dictionary providers too; encode each value before substitution.
|
||||
function expandCustomLinkUrl(template, { word = "", reading = "", sentence = "" } = {}) {
|
||||
if (typeof template !== "string") return null;
|
||||
const values = { w: word, r: reading, s: sentence };
|
||||
const expanded = template.replace(/%([wrs])/gu, (_, marker) => encodeURIComponent(String(values[marker])));
|
||||
return normaliseExternalUrl(expanded);
|
||||
}
|
||||
|
||||
globalThis.HDExternalLinks = { normaliseExternalUrl, expandCustomLinkUrl };
|
||||
})();
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Hachidori background</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="firefox-background.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
// Firefox MV2 persistent background-page entry point.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Both imports are static so every runtime listener in background.js exists
|
||||
// before this module body runs. Firefox delivers runtime.onInstalled and
|
||||
// runtime.onStartup only to listeners registered during the background page's
|
||||
// synchronous evaluation; a dynamic import would miss them and first-run setup
|
||||
// would never open. The host's message listener is attached here, after
|
||||
// background.js, and before the engine iframe that announces itself to it.
|
||||
import "./background.js";
|
||||
import { createFirefoxBackgroundHost } from "./firefox-host.js";
|
||||
|
||||
createFirefoxBackgroundHost(document).mount();
|
||||
@@ -0,0 +1,146 @@
|
||||
// Persistent Firefox background-page host for the hidden engine iframe.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import {
|
||||
extensionApi,
|
||||
extensionDocumentUrl,
|
||||
IS_FIREFOX,
|
||||
isExactExtensionSender,
|
||||
} from "./browser-api.js";
|
||||
|
||||
const TARGET = "hachidori-firefox-host";
|
||||
const OFFSCREEN_DOCUMENT = "offscreen.html";
|
||||
const BACKGROUND_DOCUMENT = "firefox-background.html";
|
||||
const READY_TIMEOUT_MS = 10_000;
|
||||
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
||||
const OFFSCREEN_INSTANCE_ID = globalThis.crypto.randomUUID();
|
||||
|
||||
let ready = false;
|
||||
let readyDetails = null;
|
||||
let settleReady;
|
||||
let rejectReady;
|
||||
const readyPromise = new Promise((resolve, reject) => {
|
||||
settleReady = resolve;
|
||||
rejectReady = reject;
|
||||
});
|
||||
let readyTimer = null;
|
||||
|
||||
function describe(error) {
|
||||
return error instanceof Error ? error.message || String(error) : String(error);
|
||||
}
|
||||
|
||||
function clearReadyTimer() {
|
||||
if (readyTimer === null) return;
|
||||
clearTimeout(readyTimer);
|
||||
readyTimer = null;
|
||||
}
|
||||
|
||||
function failReady(error) {
|
||||
if (ready) return;
|
||||
clearReadyTimer();
|
||||
rejectReady(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
|
||||
function assertReadyMessage(message, sender) {
|
||||
if (!isExactExtensionSender(sender, OFFSCREEN_DOCUMENT, extensionApi, { tab: false })) {
|
||||
throw new Error("Firefox engine readiness came from an untrusted document.");
|
||||
}
|
||||
if (message.extensionId !== extensionApi.runtime.id
|
||||
|| message.documentUrl !== extensionDocumentUrl(OFFSCREEN_DOCUMENT)
|
||||
|| !UUID_V4.test(message.instanceId)) {
|
||||
throw new Error("Firefox engine readiness carried the wrong extension identity.");
|
||||
}
|
||||
}
|
||||
|
||||
function hostReply() {
|
||||
return {
|
||||
ok: true,
|
||||
extensionId: extensionApi.runtime.id,
|
||||
backgroundUrl: extensionDocumentUrl(BACKGROUND_DOCUMENT),
|
||||
offscreenUrl: extensionDocumentUrl(OFFSCREEN_DOCUMENT),
|
||||
instanceId: readyDetails?.instanceId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFirefoxBackgroundHost(document) {
|
||||
if (!IS_FIREFOX) throw new Error("The Firefox background host can run only in Firefox.");
|
||||
let iframe = null;
|
||||
|
||||
extensionApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message?.target !== TARGET) return false;
|
||||
try {
|
||||
if (message.type === "hd_firefox_offscreen_ready") {
|
||||
assertReadyMessage(message, sender);
|
||||
ready = true;
|
||||
readyDetails = {
|
||||
extensionId: sender.id,
|
||||
documentUrl: sender.url,
|
||||
backgroundUrl: extensionDocumentUrl(BACKGROUND_DOCUMENT),
|
||||
instanceId: message.instanceId,
|
||||
};
|
||||
clearReadyTimer();
|
||||
settleReady(readyDetails);
|
||||
sendResponse(hostReply());
|
||||
return false;
|
||||
}
|
||||
if (message.type === "hd_firefox_host_status") {
|
||||
if (sender?.id !== extensionApi.runtime.id) {
|
||||
throw new Error("Firefox host status came from another extension.");
|
||||
}
|
||||
sendResponse({ ...hostReply(), ready, details: readyDetails });
|
||||
return false;
|
||||
}
|
||||
throw new Error("Unknown Firefox host request.");
|
||||
} catch (error) {
|
||||
sendResponse({ ok: false, error: describe(error) });
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
mount() {
|
||||
if (iframe !== null) return iframe;
|
||||
iframe = document.createElement("iframe");
|
||||
iframe.id = "hachidori-firefox-engine";
|
||||
iframe.src = extensionDocumentUrl(OFFSCREEN_DOCUMENT);
|
||||
iframe.hidden = true;
|
||||
iframe.setAttribute("aria-hidden", "true");
|
||||
iframe.addEventListener("error", () => {
|
||||
failReady(new Error("Firefox could not load the hidden dictionary engine."));
|
||||
}, { once: true });
|
||||
document.body.append(iframe);
|
||||
readyTimer = setTimeout(() => {
|
||||
failReady(new Error("Firefox did not start the hidden dictionary engine."));
|
||||
}, READY_TIMEOUT_MS);
|
||||
return iframe;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function waitForFirefoxOffscreen() {
|
||||
if (!IS_FIREFOX) return null;
|
||||
return readyPromise;
|
||||
}
|
||||
|
||||
export async function announceFirefoxOffscreen() {
|
||||
if (!IS_FIREFOX) return null;
|
||||
const documentUrl = globalThis.location?.href;
|
||||
if (documentUrl !== extensionDocumentUrl(OFFSCREEN_DOCUMENT)) {
|
||||
throw new Error("Firefox loaded the dictionary engine from an unexpected URL.");
|
||||
}
|
||||
const reply = await extensionApi.runtime.sendMessage({
|
||||
target: TARGET,
|
||||
type: "hd_firefox_offscreen_ready",
|
||||
extensionId: extensionApi.runtime.id,
|
||||
documentUrl,
|
||||
instanceId: OFFSCREEN_INSTANCE_ID,
|
||||
});
|
||||
if (!reply?.ok
|
||||
|| reply.extensionId !== extensionApi.runtime.id
|
||||
|| reply.backgroundUrl !== extensionDocumentUrl(BACKGROUND_DOCUMENT)
|
||||
|| reply.offscreenUrl !== documentUrl
|
||||
|| reply.instanceId !== OFFSCREEN_INSTANCE_ID) {
|
||||
throw new Error(reply?.error || "Firefox background authentication failed.");
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
||||
.hd-icon,
|
||||
.gsm-hoshidicts-audio-button::before,
|
||||
.gsm-hoshidicts-popup-close::before,
|
||||
.gloss-link-external-icon,
|
||||
.operational-status::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
vertical-align: middle;
|
||||
background: currentColor;
|
||||
mask: var(--hd-icon) center / contain no-repeat;
|
||||
}
|
||||
.hd-icon[hidden] { display: none; }
|
||||
@media (forced-colors: active) {
|
||||
.hd-icon,
|
||||
.gsm-hoshidicts-audio-button::before,
|
||||
.gsm-hoshidicts-popup-close::before,
|
||||
.gloss-link-external-icon,
|
||||
.operational-status::before { forced-color-adjust: none; background: CanvasText; }
|
||||
}
|
||||
.hd-icon[data-icon="add"] { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M10%202.5C10.2761%202.5%2010.5%202.72386%2010.5%203V9.5H17C17.2761%209.5%2017.5%209.72386%2017.5%2010C17.5%2010.2761%2017.2761%2010.5%2017%2010.5H10.5V17C10.5%2017.2761%2010.2761%2017.5%2010%2017.5C9.72386%2017.5%209.5%2017.2761%209.5%2017V10.5H3C2.72386%2010.5%202.5%2010.2761%202.5%2010C2.5%209.72386%202.72386%209.5%203%209.5H9.5V3C9.5%202.72386%209.72386%202.5%2010%202.5Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="document-add"] { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M6%202C4.89543%202%204%202.89543%204%204V9.20703C4.32228%209.11588%204.65659%209.05337%205%209.02242V4C5%203.44772%205.44772%203%206%203H10V6.5C10%207.32843%2010.6716%208%2011.5%208H15V16C15%2016.5523%2014.5523%2017%2014%2017H10.4003C10.2174%2017.3578%209.99647%2017.6929%209.74284%2018H14C15.1046%2018%2016%2017.1046%2016%2016V7.41421C16%207.01639%2015.842%206.63486%2015.5607%206.35355L11.6464%202.43934C11.3651%202.15804%2010.9836%202%2010.5858%202H6ZM14.7929%207H11.5C11.2239%207%2011%206.77614%2011%206.5V3.20711L14.7929%207ZM10%2014.5C10%2016.9853%207.98528%2019%205.5%2019C3.01472%2019%201%2016.9853%201%2014.5C1%2012.0147%203.01472%2010%205.5%2010C7.98528%2010%2010%2012.0147%2010%2014.5ZM6%2012.5C6%2012.2239%205.77614%2012%205.5%2012C5.22386%2012%205%2012.2239%205%2012.5V14H3.5C3.22386%2014%203%2014.2239%203%2014.5C3%2014.7761%203.22386%2015%203.5%2015H5L5%2016.5C5%2016.7761%205.22386%2017%205.5%2017C5.77614%2017%206%2016.7761%206%2016.5V15H7.5C7.77614%2015%208%2014.7761%208%2014.5C8%2014.2239%207.77614%2014%207.5%2014H6V12.5Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="key"] { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M15%206C15%206.55228%2014.5523%207%2014%207C13.4477%207%2013%206.55228%2013%206C13%205.44772%2013.4477%205%2014%205C14.5523%205%2015%205.44772%2015%206ZM12.5%202C9.42386%202%207%204.42386%207%207.5C7%207.89668%207.04001%208.29598%207.12154%208.67456C7.17968%208.94454%207.11378%209.17912%206.98018%209.31271L2.43934%2013.8536C2.15804%2014.1349%202%2014.5164%202%2014.9142V16.5C2%2017.3284%202.67157%2018%203.5%2018H5.5C6.32843%2018%207%2017.3284%207%2016.5V16H8C8.55228%2016%209%2015.5523%209%2015V14H10C10.5523%2014%2011%2013.5523%2011%2013V12.8199C11.4935%2012.9536%2012.0069%2013%2012.5%2013C15.5761%2013%2018%2010.5761%2018%207.5C18%204.42386%2015.5761%202%2012.5%202ZM8%207.5C8%204.97614%209.97614%203%2012.5%203C15.0239%203%2017%204.97614%2017%207.5C17%2010.0239%2015.0239%2012%2012.5%2012C11.8408%2012%2011.2274%2011.9047%2010.7236%2011.6528C10.5686%2011.5753%2010.3845%2011.5836%2010.2371%2011.6747C10.0897%2011.7658%2010%2011.9267%2010%2012.1V13H9C8.44772%2013%208%2013.4477%208%2014V15H7C6.44772%2015%206%2015.4477%206%2016V16.5C6%2016.7761%205.77614%2017%205.5%2017H3.5C3.22386%2017%203%2016.7761%203%2016.5V14.9142C3%2014.7816%203.05268%2014.6544%203.14645%2014.5607L7.68729%2010.0198C8.11942%209.58768%208.20942%208.97619%208.09913%208.46404C8.03354%208.15949%208%207.83105%208%207.5Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="document-edit"] { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M11.5%208H16V7.41421C16%207.29283%2015.9853%207.17296%2015.9568%207.057C15.9525%207.03943%2015.9479%207.02195%2015.943%207.00458L15.9417%207C15.8721%206.75786%2015.742%206.53488%2015.5607%206.35355L11.6458%202.43867C11.4646%202.25769%2011.2418%202.12781%2011%202.05832C10.8665%202.01996%2010.7272%202%2010.5858%202H6C4.89543%202%204%202.89543%204%204V16C4%2017.1046%204.89543%2018%206%2018H8.22126L8.20776%2017.9744C8.092%2017.7508%208.01983%2017.5018%208.00293%2017.2392C7.99787%2017.1606%207.99776%2017.0807%208.00293%2017H6C5.44772%2017%205%2016.5523%205%2016V4C5%203.44772%205.44772%203%206%203H10V6.5C10%207.32843%2010.6716%208%2011.5%208ZM11.5%207C11.2239%207%2011%206.77614%2011%206.5V3.20711L14.7929%207H11.5ZM14.8092%209.54776C15.5395%208.81741%2016.7237%208.81741%2017.454%209.54776C18.1843%2010.2781%2018.1843%2011.4622%2017.454%2012.1926L12.6246%2017.022C12.343%2017.3036%2011.9902%2017.5033%2011.6039%2017.5999L10.106%2017.9744C9.45456%2018.1372%208.86451%2017.5472%209.02737%2016.8958L9.40184%2015.3979C9.49842%2015.0116%209.69818%2014.6588%209.97975%2014.3772L14.8092%209.54776Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="book-search"] { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M4%204V16C4%2017.1046%204.89543%2018%206%2018H15.5C15.7761%2018%2016%2017.7761%2016%2017.5C16%2017.2239%2015.7761%2017%2015.5%2017H6C5.44772%2017%205%2016.5523%205%2016H15C15.5523%2016%2016%2015.5523%2016%2015V4C16%202.89543%2015.1046%202%2014%202H6C4.89543%202%204%202.89543%204%204ZM14%203C14.5523%203%2015%203.44772%2015%204V15H5V4C5%203.44772%205.44772%203%206%203H14ZM11.5858%209.8787C11.8476%209.48348%2012%209.00954%2012%208.5C12%207.11929%2010.8807%206%209.5%206C8.11929%206%207%207.11929%207%208.5C7%209.88071%208.11929%2011%209.5%2011C10.0095%2011%2010.4835%2010.8476%2010.8787%2010.5858L12.1464%2011.8536C12.3417%2012.0488%2012.6583%2012.0488%2012.8536%2011.8536C13.0488%2011.6583%2013.0488%2011.3417%2012.8536%2011.1464L11.5858%209.8787ZM8%208.5C8%207.67157%208.67157%207%209.5%207C10.3284%207%2011%207.67157%2011%208.5C11%209.32843%2010.3284%2010%209.5%2010C8.67157%2010%208%209.32843%208%208.5Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="speaker-2"],
|
||||
.gsm-hoshidicts-audio-button::before { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M11.9961%203.00588C11.9961%202.13286%2010.9558%201.67903%2010.3159%202.27288L6.44369%205.86626C6.35122%205.95208%206.22973%205.99976%206.10358%205.99976H3.49609C2.66767%205.99976%201.99609%206.67134%201.99609%207.49976V12.4998C1.99609%2013.3282%202.66767%2013.9998%203.49609%2013.9998H6.10358C6.22973%2013.9998%206.35122%2014.0474%206.44369%2014.1333L10.3159%2017.7266C10.9558%2018.3205%2011.9961%2017.8667%2011.9961%2016.9936V3.00588ZM7.12392%206.59927L10.9961%203.00588V16.9936L7.12392%2013.4003C6.84651%2013.1428%206.48204%2012.9998%206.10358%2012.9998H3.49609C3.21995%2012.9998%202.99609%2012.7759%202.99609%2012.4998V7.49976C2.99609%207.22362%203.21995%206.99976%203.49609%206.99976H6.10358C6.48204%206.99976%206.84651%206.8567%207.12392%206.59927ZM15.2557%204.62735C15.4614%204.44308%2015.7775%204.46042%2015.9617%204.66609C18.678%207.69768%2018.678%2012.3018%2015.9618%2015.3334C15.7775%2015.5391%2015.4614%2015.5564%2015.2557%2015.3721C15.0501%2015.1879%2015.0327%2014.8718%2015.217%2014.6661C17.5929%2012.0143%2017.5929%207.98519%2015.217%205.3334C15.0327%205.12773%2015.05%204.81162%2015.2557%204.62735ZM14.0804%2012.933C13.8412%2012.7952%2013.7589%2012.4894%2013.8967%2012.2502C14.6729%2010.9026%2014.7284%209.19135%2013.8961%207.74967C13.758%207.51052%2013.8399%207.20473%2014.0791%207.06665C14.3182%206.92858%2014.624%207.01052%2014.7621%207.24967C15.7801%209.01294%2015.7103%2011.1052%2014.7633%2012.7493C14.6254%2012.9886%2014.3197%2013.0708%2014.0804%2012.933Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="edit"] { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M17.1813%202.92689C16.029%201.71505%2014.1046%201.69077%2012.9222%202.87317L3.54735%2012.2475C3.21952%2012.5754%202.99198%2012.9899%202.89142%2013.4424L2.0138%2017.3923C1.97672%2017.5592%202.02748%2017.7335%202.14838%2017.8544C2.26928%2017.9753%202.44356%2018.026%202.61044%2017.9889L6.53683%2017.1157C7.00426%2017.0118%207.43237%2016.7767%207.77096%2016.4381L17.129%207.08003C18.27%205.939%2018.2932%204.09631%2017.1813%202.92689ZM13.6293%203.58029C14.4142%202.79538%2015.6917%202.8115%2016.4566%203.61596C17.1947%204.39225%2017.1793%205.61548%2016.4218%206.37293L15.7506%207.04418L12.958%204.25155L13.6293%203.58029ZM12.2508%204.95864L15.0435%207.7513L7.06385%2015.731C6.8597%2015.9352%206.60158%2016.0769%206.31975%2016.1396L3.16044%2016.8421L3.86762%2013.6593C3.92692%2013.3924%204.0611%2013.148%204.25444%2012.9547L12.2508%204.95864Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="checkmark"],
|
||||
.operational-status.is-ready::before { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M3.37371%2010.1678C3.19025%209.96143%202.87421%209.94284%202.66782%2010.1263C2.46143%2010.3098%202.44284%2010.6258%202.6263%2010.8322L6.6263%2015.3322C6.81743%2015.5472%207.15013%2015.557%207.35356%2015.3536L17.8536%204.85355C18.0488%204.65829%2018.0488%204.34171%2017.8536%204.14645C17.6583%203.95118%2017.3417%203.95118%2017.1465%204.14645L7.02141%2014.2715L3.37371%2010.1678Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="error-circle"],
|
||||
.gsm-hoshidicts-audio-button[data-state="error"]::before,
|
||||
.operational-status.is-error::before { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M10%202C14.4183%202%2018%205.58172%2018%2010C18%2014.4183%2014.4183%2018%2010%2018C5.58172%2018%202%2014.4183%202%2010C2%205.58172%205.58172%202%2010%202ZM10%203C6.13401%203%203%206.13401%203%2010C3%2013.866%206.13401%2017%2010%2017C13.866%2017%2017%2013.866%2017%2010C17%206.13401%2013.866%203%2010%203ZM10%2012.5C10.4142%2012.5%2010.75%2012.8358%2010.75%2013.25C10.75%2013.6642%2010.4142%2014%2010%2014C9.58579%2014%209.25%2013.6642%209.25%2013.25C9.25%2012.8358%209.58579%2012.5%2010%2012.5ZM10%206C10.2455%206%2010.4496%206.17688%2010.4919%206.41012L10.5%206.5V11C10.5%2011.2761%2010.2761%2011.5%2010%2011.5C9.75454%2011.5%209.55039%2011.3231%209.50806%2011.0899L9.5%2011V6.5C9.5%206.22386%209.72386%206%2010%206Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="more-horizontal"],
|
||||
.gsm-hoshidicts-audio-button[data-state="loading"]::before,
|
||||
.operational-status.is-working::before { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M6.25%2010C6.25%2010.6904%205.69036%2011.25%205%2011.25C4.30964%2011.25%203.75%2010.6904%203.75%2010C3.75%209.30964%204.30964%208.75%205%208.75C5.69036%208.75%206.25%209.30964%206.25%2010ZM11.25%2010C11.25%2010.6904%2010.6904%2011.25%2010%2011.25C9.30964%2011.25%208.75%2010.6904%208.75%2010C8.75%209.30964%209.30964%208.75%2010%208.75C10.6904%208.75%2011.25%209.30964%2011.25%2010ZM15%2011.25C15.6904%2011.25%2016.25%2010.6904%2016.25%2010C16.25%209.30964%2015.6904%208.75%2015%208.75C14.3096%208.75%2013.75%209.30964%2013.75%2010C13.75%2010.6904%2014.3096%2011.25%2015%2011.25Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="arrow-clockwise"] { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M4%2010C4%206.68629%206.68629%204%2010%204C11.7766%204%2013.3732%204.77191%2014.4723%206H12.5C12.2239%206%2012%206.22386%2012%206.5C12%206.77614%2012.2239%207%2012.5%207H15.5C15.7761%207%2016%206.77614%2016%206.5V3.5C16%203.22386%2015.7761%203%2015.5%203C15.2239%203%2015%203.22386%2015%203.5V5.10109C13.7299%203.80499%2011.9591%203%2010%203C6.13401%203%203%206.13401%203%2010C3%2013.866%206.13401%2017%2010%2017C13.866%2017%2017%2013.866%2017%2010C17%209.8191%2016.9931%209.6397%2016.9796%209.46207C16.9587%209.18673%2016.7185%208.98049%2016.4431%209.00144C16.1678%209.02239%2015.9615%209.26258%2015.9825%209.53793C15.9941%209.69034%2016%209.84443%2016%2010C16%2013.3137%2013.3137%2016%2010%2016C6.68629%2016%204%2013.3137%204%2010Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="arrow-sync"] { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M11.4142%203.63503C11.6095%203.43977%2011.6095%203.12319%2011.4142%202.92792L9.29289%200.806603C9.09763%200.611341%208.78104%200.611341%208.58578%200.806603C8.39052%201.00186%208.39052%201.31845%208.58578%201.51371L9.58264%202.51056C7.80518%202.60911%206.05488%203.33754%204.69671%204.6957C1.76776%207.62463%201.76776%2012.3734%204.69671%2015.3023C4.95359%2015.5592%205.2247%2015.7937%205.50757%2016.0058C5.72852%2016.1714%206.04191%2016.1266%206.20756%2015.9057C6.3732%2015.6847%206.32838%2015.3713%206.10743%2015.2057C5.86235%2015.0219%205.6271%2014.8185%205.40382%2014.5952C2.8654%2012.0568%202.8654%207.94121%205.40382%205.40281C6.68997%204.11666%208.38002%203.48223%2010.0664%203.49934C10.0915%203.49959%2010.1162%203.49799%2010.1404%203.49466L8.58578%205.04924C8.39052%205.24451%208.39052%205.56109%208.58578%205.75635C8.78104%205.95161%209.09763%205.95161%209.29289%205.75635L11.4142%203.63503ZM8.58578%2016.363C8.39052%2016.5582%208.39052%2016.8748%208.58578%2017.0701L10.7071%2019.1914C10.9024%2019.3866%2011.219%2019.3866%2011.4142%2019.1914C11.6095%2018.9961%2011.6095%2018.6795%2011.4142%2018.4843L10.4174%2017.4874C12.1948%2017.3889%2013.9451%2016.6605%2015.3033%2015.3023C18.2322%2012.3734%2018.2322%207.62462%2015.3033%204.69569C15.0464%204.43881%2014.7753%204.20428%2014.4924%203.99221C14.2715%203.82656%2013.9581%203.87139%2013.7924%204.09233C13.6268%204.31327%2013.6716%204.62667%2013.8926%204.79231C14.1377%204.97606%2014.3729%205.17952%2014.5962%205.40279C17.1346%207.9412%2017.1346%2012.0568%2014.5962%2014.5952C13.31%2015.8813%2011.62%2016.5158%209.9336%2016.4987C9.90849%2016.4984%209.88379%2016.5%209.85963%2016.5033L11.4142%2014.9487C11.6095%2014.7535%2011.6095%2014.4369%2011.4142%2014.2416C11.219%2014.0464%2010.9024%2014.0464%2010.7071%2014.2416L8.58578%2016.363Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="subtract"],
|
||||
.operational-status:not(.is-working):not(.is-ready):not(.is-error)::before { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M3%2010C3%209.72386%203.22386%209.5%203.5%209.5H16.5C16.7761%209.5%2017%209.72386%2017%2010C17%2010.2761%2016.7761%2010.5%2016.5%2010.5H3.5C3.22386%2010.5%203%2010.2761%203%2010Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="dismiss"],
|
||||
.gsm-hoshidicts-popup-close::before { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M4.08859%204.21569L4.14645%204.14645C4.32001%203.97288%204.58944%203.9536%204.78431%204.08859L4.85355%204.14645L10%209.293L15.1464%204.14645C15.32%203.97288%2015.5894%203.9536%2015.7843%204.08859L15.8536%204.14645C16.0271%204.32001%2016.0464%204.58944%2015.9114%204.78431L15.8536%204.85355L10.707%2010L15.8536%2015.1464C16.0271%2015.32%2016.0464%2015.5894%2015.9114%2015.7843L15.8536%2015.8536C15.68%2016.0271%2015.4106%2016.0464%2015.2157%2015.9114L15.1464%2015.8536L10%2010.707L4.85355%2015.8536C4.67999%2016.0271%204.41056%2016.0464%204.21569%2015.9114L4.14645%2015.8536C3.97288%2015.68%203.9536%2015.4106%204.08859%2015.2157L4.14645%2015.1464L9.293%2010L4.14645%204.85355C3.97288%204.67999%203.9536%204.41056%204.08859%204.21569L4.14645%204.14645L4.08859%204.21569Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="open"],
|
||||
.gloss-link-external-icon { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M6%204C4.89543%204%204%204.89543%204%206V14C4%2015.1046%204.89543%2016%206%2016H14C15.1046%2016%2016%2015.1046%2016%2014V11.5C16%2011.2239%2016.2239%2011%2016.5%2011C16.7761%2011%2017%2011.2239%2017%2011.5V14C17%2015.6569%2015.6569%2017%2014%2017H6C4.34315%2017%203%2015.6569%203%2014V6C3%204.34315%204.34315%203%206%203H8.5C8.77614%203%209%203.22386%209%203.5C9%203.77614%208.77614%204%208.5%204H6ZM11%203.5C11%203.22386%2011.2239%203%2011.5%203H16.5C16.7761%203%2017%203.22386%2017%203.5V8.5C17%208.77614%2016.7761%209%2016.5%209C16.2239%209%2016%208.77614%2016%208.5V4.70711L11.8536%208.85355C11.6583%209.04882%2011.3417%209.04882%2011.1464%208.85355C10.9512%208.65829%2010.9512%208.34171%2011.1464%208.14645L15.2929%204H11.5C11.2239%204%2011%203.77614%2011%203.5Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="arrow-up"] { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M3.13269%209.16094C2.94651%209.36488%202.96091%209.68113%203.16485%209.86731C3.36879%2010.0535%203.68504%2010.0391%203.87122%209.83515L9.50195%203.6673L9.50195%2017.498C9.50195%2017.7742%209.72581%2017.998%2010.002%2017.998C10.2781%2017.998%2010.502%2017.7742%2010.502%2017.498L10.502%203.67019L16.13%209.83515C16.3162%2010.0391%2016.6325%2010.0535%2016.8364%209.86731C17.0404%209.68113%2017.0548%209.36488%2016.8686%209.16094L10.5545%202.24458C10.4268%202.10464%2010.2592%202.02481%2010.0861%202.0051C10.0587%202.00046%2010.0306%201.99805%2010.002%201.99805C9.97508%201.99805%209.9487%202.00017%209.92298%202.00425C9.74708%202.02242%209.57641%202.10253%209.44673%202.24458L3.13269%209.16094Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="arrow-down"] { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M16.8666%2010.8371C17.0528%2010.6332%2017.0384%2010.3169%2016.8345%2010.1307C16.6305%209.94456%2016.3143%209.95895%2016.1281%2010.1629L10.4974%2016.3307V2.5C10.4974%202.22386%2010.2735%202%209.99736%202C9.72122%202%209.49736%202.22386%209.49736%202.5V16.3279L3.86927%2010.1629C3.68309%209.95895%203.36684%209.94456%203.16289%2010.1307C2.95895%2010.3169%202.94456%2010.6332%203.13074%2010.8371L9.44478%2017.7535C9.57254%2017.8934%209.74008%2017.9732%209.91323%2017.993C9.94058%2017.9976%209.96869%2018%209.99736%2018C10.0242%2018%2010.0506%2017.9979%2010.0763%2017.9938C10.2522%2017.9756%2010.4229%2017.8955%2010.5526%2017.7535L16.8666%2010.8371Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="arrow-right"] { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M10.8371%203.13074C10.6332%202.94456%2010.3169%202.95895%2010.1307%203.16289C9.94456%203.36683%209.95895%203.68309%2010.1629%203.86927L16.3307%209.5H2.5C2.22386%209.5%202%209.72386%202%2010C2%2010.2761%202.22386%2010.5%202.5%2010.5H16.3279L10.1629%2016.1281C9.95895%2016.3143%209.94456%2016.6305%2010.1307%2016.8345C10.3169%2017.0384%2010.6332%2017.0528%2010.8371%2016.8666L17.7535%2010.5526C17.8934%2010.4248%2017.9732%2010.2573%2017.993%2010.0841C17.9976%2010.0568%2018%2010.0287%2018%2010C18%209.97313%2017.9979%209.94675%2017.9938%209.92103C17.9756%209.74512%2017.8955%209.57446%2017.7535%209.44478L10.8371%203.13074Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="star"] { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M9.10433%202.89923C9.47114%202.15598%2010.531%202.15599%2010.8978%202.89923L12.8282%206.81073L17.1448%207.43797C17.9651%207.55715%2018.2926%208.56513%2017.699%209.14366L14.5755%2012.1883L15.3129%2016.4875C15.453%2017.3044%2014.5956%2017.9274%2013.8619%2017.5417L10.0011%2015.5119L6.14018%2017.5417C5.40655%2017.9274%204.54913%2017.3044%204.68924%2016.4875L5.4266%2012.1883L2.30308%209.14366C1.70956%208.56512%202.03708%207.55715%202.8573%207.43797L7.17389%206.81073L9.10433%202.89923ZM10.0011%203.3418L8.07062%207.25329C7.92496%207.54844%207.6434%207.75301%207.31769%207.80034L3.00109%208.42757L6.12461%2011.4722C6.3603%2011.702%206.46784%2012.033%206.41221%2012.3574L5.67484%2016.6565L9.53572%2014.6267C9.82705%2014.4736%2010.1751%2014.4736%2010.4664%2014.6267L14.3273%2016.6565L13.5899%2012.3574C13.5343%2012.033%2013.6418%2011.702%2013.8775%2011.4722L17.001%208.42757L12.6844%207.80034C12.3587%207.75301%2012.0772%207.54844%2011.9315%207.25329L10.0011%203.3418Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="reorder"] { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M2.5%208C2.22386%208%202%208.22386%202%208.5C2%208.77614%202.22386%209%202.5%209H17.5C17.7761%209%2018%208.77614%2018%208.5C18%208.22386%2017.7761%208%2017.5%208H2.5ZM2.5%2011C2.22386%2011%202%2011.2239%202%2011.5C2%2011.7761%202.22386%2012%202.5%2012H17.5C17.7761%2012%2018%2011.7761%2018%2011.5C18%2011.2239%2017.7761%2011%2017.5%2011H2.5Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="settings"] { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M1.91099%207.38266C2.28028%206.24053%202.88863%205.19213%203.69133%204.30364C3.82707%204.15339%204.04002%204.09984%204.23069%204.16802L6.14897%204.85392C6.66905%205.03977%207.24131%204.76883%207.42716%204.24875C7.44544%204.19762%207.45952%204.14507%207.46925%204.09173L7.83471%202.08573C7.87104%201.88627%208.02422%201.7285%208.22251%201.6863C8.8027%201.5628%209.39758%201.5%2010.0003%201.5C10.6026%201.5%2011.1971%201.56273%2011.7769%201.68607C11.9752%201.72824%2012.1284%201.88591%2012.1648%202.08529L12.5313%204.09165C12.6303%204.63497%2013.1511%204.9951%2013.6944%204.89601C13.7479%204.88627%2013.8004%204.87219%2013.8515%204.85395L15.7698%204.16802C15.9605%204.09984%2016.1734%204.15339%2016.3092%204.30364C17.1119%205.19213%2017.7202%206.24053%2018.0895%207.38266C18.1518%207.57534%2018.0918%207.78658%2017.9374%207.91764L16.3825%209.23773C15.9615%209.5952%2015.9101%2010.2263%2016.2675%2010.6473C16.3027%2010.6887%2016.3411%2010.7271%2016.3825%2010.7623L17.9374%2012.0824C18.0918%2012.2134%2018.1518%2012.4247%2018.0895%2012.6173C17.7202%2013.7595%2017.1119%2014.8079%2016.3092%2015.6964C16.1734%2015.8466%2015.9605%2015.9002%2015.7698%2015.832L13.8515%2015.1461C13.3315%2014.9602%2012.7592%2015.2312%2012.5733%2015.7512C12.5551%2015.8024%2012.541%2015.8549%2012.5312%2015.9085L12.1648%2017.9147C12.1284%2018.1141%2011.9752%2018.2718%2011.7769%2018.3139C11.1971%2018.4373%2010.6026%2018.5%2010.0003%2018.5C9.39758%2018.5%208.8027%2018.4372%208.22251%2018.3137C8.02422%2018.2715%207.87104%2018.1137%207.83471%2017.9143L7.46926%2015.9084C7.37018%2015.365%206.8494%2015.0049%206.30608%2015.104C6.25265%2015.1137%206.20011%2015.1278%206.14906%2015.1461L4.23069%2015.832C4.04002%2015.9002%203.82707%2015.8466%203.69133%2015.6964C2.88863%2014.8079%202.28028%2013.7595%201.91099%2012.6173C1.84869%2012.4247%201.90876%2012.2134%202.06313%2012.0824L3.61798%2010.7623C4.03897%2010.4048%204.09046%209.77373%203.73299%209.35274C3.69784%209.31135%203.65937%209.27288%203.618%209.23775L2.06313%207.91764C1.90876%207.78658%201.84869%207.57534%201.91099%207.38266ZM2.97154%207.37709L4.26523%208.47546C4.34803%208.54576%204.42496%208.62269%204.49526%208.70548C5.2102%209.54746%205.10721%2010.8096%204.26521%2011.5246L2.97154%2012.6229C3.26359%2013.4051%203.68504%2014.1322%204.21648%2014.7751L5.81246%2014.2044C5.91473%2014.1679%206.01982%2014.1397%206.12667%2014.1202C7.21332%2013.922%208.25487%2014.6423%208.45305%2015.729L8.75702%2017.3975C9.16489%2017.4655%209.58024%2017.5%2010.0003%2017.5C10.42%2017.5%2010.8351%2017.4656%2011.2427%2017.3976L11.5475%2015.7289C11.567%2015.6221%2011.5951%2015.517%2011.6317%2015.4147C12.0034%2014.3746%2013.1479%2013.8327%2014.1881%2014.2044L15.784%2014.7751C16.3155%2014.1322%2016.7369%2013.4051%2017.029%2012.6229L15.7353%2011.5245C15.6525%2011.4542%2015.5756%2011.3773%2015.5053%2011.2945C14.7903%2010.4525%2014.8933%209.1904%2015.7353%208.47544L17.029%207.37709C16.7369%206.59486%2016.3155%205.86783%2015.784%205.22494L14.1881%205.79559C14.0858%205.83214%2013.9807%205.8603%2013.8738%205.87979C12.7872%206.07796%2011.7456%205.3577%2011.5475%204.27119L11.2427%202.60235C10.8351%202.53443%2010.42%202.5%2010.0003%202.5C9.58024%202.5%209.16489%202.53448%208.75702%202.60249L8.45304%204.27105C8.43355%204.37791%208.40539%204.48299%208.36884%204.58527C7.99714%205.62542%206.8526%206.1673%205.81237%205.79556L4.21648%205.22494C3.68504%205.86783%203.26359%206.59486%202.97154%207.37709ZM7.50026%2010C7.50026%208.61929%208.61954%207.5%2010.0003%207.5C11.381%207.5%2012.5003%208.61929%2012.5003%2010C12.5003%2011.3807%2011.381%2012.5%2010.0003%2012.5C8.61954%2012.5%207.50026%2011.3807%207.50026%2010ZM8.50026%2010C8.50026%2010.8284%209.17183%2011.5%2010.0003%2011.5C10.8287%2011.5%2011.5003%2010.8284%2011.5003%2010C11.5003%209.17157%2010.8287%208.5%2010.0003%208.5C9.17183%208.5%208.50026%209.17157%208.50026%2010Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
.hd-icon[data-icon="desktop"] { --hd-icon: url("data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d%3D%22M4%202C2.89543%202%202%202.89543%202%204V13C2%2014.1046%202.89543%2015%204%2015H7V17H5.5C5.22386%2017%205%2017.2239%205%2017.5C5%2017.7761%205.22386%2018%205.5%2018H14.5C14.7761%2018%2015%2017.7761%2015%2017.5C15%2017.2239%2014.7761%2017%2014.5%2017H13V15H16C17.1046%2015%2018%2014.1046%2018%2013V4C18%202.89543%2017.1046%202%2016%202H4ZM12%2015V17H8V15H12ZM3%204C3%203.44772%203.44772%203%204%203H16C16.5523%203%2017%203.44772%2017%204V13C17%2013.5523%2016.5523%2014%2016%2014H4C3.44772%2014%203%2013.5523%203%2013V4Z%22%20fill%3D%22%23212121%22%2F%3E%0A%3C%2Fsvg%3E"); }
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Microsoft Corporation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 2.5C10.2761 2.5 10.5 2.72386 10.5 3V9.5H17C17.2761 9.5 17.5 9.72386 17.5 10C17.5 10.2761 17.2761 10.5 17 10.5H10.5V17C10.5 17.2761 10.2761 17.5 10 17.5C9.72386 17.5 9.5 17.2761 9.5 17V10.5H3C2.72386 10.5 2.5 10.2761 2.5 10C2.5 9.72386 2.72386 9.5 3 9.5H9.5V3C9.5 2.72386 9.72386 2.5 10 2.5Z" fill="#212121"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 425 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 10C4 6.68629 6.68629 4 10 4C11.7766 4 13.3732 4.77191 14.4723 6H12.5C12.2239 6 12 6.22386 12 6.5C12 6.77614 12.2239 7 12.5 7H15.5C15.7761 7 16 6.77614 16 6.5V3.5C16 3.22386 15.7761 3 15.5 3C15.2239 3 15 3.22386 15 3.5V5.10109C13.7299 3.80499 11.9591 3 10 3C6.13401 3 3 6.13401 3 10C3 13.866 6.13401 17 10 17C13.866 17 17 13.866 17 10C17 9.8191 16.9931 9.6397 16.9796 9.46207C16.9587 9.18673 16.7185 8.98049 16.4431 9.00144C16.1678 9.02239 15.9615 9.26258 15.9825 9.53793C15.9941 9.69034 16 9.84443 16 10C16 13.3137 13.3137 16 10 16C6.68629 16 4 13.3137 4 10Z" fill="#212121"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 692 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.8666 10.8371C17.0528 10.6332 17.0384 10.3169 16.8345 10.1307C16.6305 9.94456 16.3143 9.95895 16.1281 10.1629L10.4974 16.3307V2.5C10.4974 2.22386 10.2735 2 9.99736 2C9.72122 2 9.49736 2.22386 9.49736 2.5V16.3279L3.86927 10.1629C3.68309 9.95895 3.36684 9.94456 3.16289 10.1307C2.95895 10.3169 2.94456 10.6332 3.13074 10.8371L9.44478 17.7535C9.57254 17.8934 9.74008 17.9732 9.91323 17.993C9.94058 17.9976 9.96869 18 9.99736 18C10.0242 18 10.0506 17.9979 10.0763 17.9938C10.2522 17.9756 10.4229 17.8955 10.5526 17.7535L16.8666 10.8371Z" fill="#212121"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 666 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.8371 3.13074C10.6332 2.94456 10.3169 2.95895 10.1307 3.16289C9.94456 3.36683 9.95895 3.68309 10.1629 3.86927L16.3307 9.5H2.5C2.22386 9.5 2 9.72386 2 10C2 10.2761 2.22386 10.5 2.5 10.5H16.3279L10.1629 16.1281C9.95895 16.3143 9.94456 16.6305 10.1307 16.8345C10.3169 17.0384 10.6332 17.0528 10.8371 16.8666L17.7535 10.5526C17.8934 10.4248 17.9732 10.2573 17.993 10.0841C17.9976 10.0568 18 10.0287 18 10C18 9.97313 17.9979 9.94675 17.9938 9.92103C17.9756 9.74512 17.8955 9.57446 17.7535 9.44478L10.8371 3.13074Z" fill="#212121"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 642 B |