feat: add local OneNote and ONEPKG reader

This commit is contained in:
2026-07-22 17:06:03 +02:00
commit bddcb98449
93 changed files with 14949 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
coverage/
release/
*.tsbuildinfo
.DS_Store

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/

6
.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
coverage
dist
release
package-lock.json
LICENSES/*.txt

6
.prettierrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "es5",
"printWidth": 80
}

24
CHANGELOG.md Normal file
View File

@@ -0,0 +1,24 @@
# Changelog
## 0.1.0 - 2026-07-22
- Add a standalone React/Vite reader integrated with Toolbox SDK 0.1.1,
including `AppShell`, the Toolbox manifest, nested-path-safe assets, and the
Portal release contract.
- Add a worker-hosted native TypeScript parser for a desktop OneNote
revision-store subset: bounded file-node and object-space traversal,
property sets, page metadata, titles, timestamps, levels, and plain text.
- Add in-memory CAB enumeration and extraction for uncompressed and LZX
folders, including checksums, strict paths, explicit resource limits, and
cooperative cancellation between data blocks.
- Open contained `.one` sections independently and expose package entries,
section/page navigation, partial failures, and structured diagnostics.
- Verify a Joplin `.one` golden with three pages and a Joplin LZX `0x1203`
`.onepkg` golden with seven entries, five sections, and seven pages. Also
verify rust-cab uncompressed and LZX `0x1303` fixtures.
- Add deterministic static release packaging, SHA-256 sidecars, pinned-source
provenance, fixture licensing, and security/support documentation.
Known unsupported areas are FSSHTTP-packaged `.one`, MSZIP and Quantum CAB
extraction, multi-cabinet archives, `.onetoc2` hierarchy/order interpretation,
visual content, attachments, layout/format fidelity, links, and exports.

4
LICENSE Normal file
View File

@@ -0,0 +1,4 @@
SPDX-License-Identifier: MPL-2.0
OneNote Tools is licensed under the Mozilla Public License, version 2.0.
The complete, unmodified licence text is provided in LICENSES/MPL-2.0.txt.

661
LICENSES/AGPL-3.0.txt Normal file
View File

@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 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 Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<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 Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

373
LICENSES/MPL-2.0.txt Normal file
View File

@@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.

21
LICENSES/lzxd-MIT.txt Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Lonami <gh@lonami.dev>
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.

21
LICENSES/rust-cab-MIT.txt Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 Matthew D. Steele
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.

93
README.md Normal file
View File

@@ -0,0 +1,93 @@
# OneNote Tools
OneNote Tools is a standalone, local-first browser reader for a tested subset
of Microsoft OneNote `.one` sections and CAB-based `.onepkg` packages. The
parser is native TypeScript, runs in a dedicated module worker, and never
uploads or persists the selected notebook.
It is also an independent application in the add·ideas Toolbox contract: the
source uses the Toolbox SDK shell and manifest, builds with Vite `base: './'`,
and is delivered to Toolbox Portal as an already-built, checksummed static ZIP.
## What version 0.1.0 can open
- Desktop revision-store `.one` sections from the tested OneNote family. The
reader exposes section/page names, stable page IDs, created and updated
timestamps when present, page level, and readable plain text.
- Single-cabinet `.onepkg` files whose CAB folders are uncompressed or LZX
compressed. Each contained desktop `.one` section is parsed independently,
so one unsupported section does not hide sections that did parse.
- Package entries, safe normalized paths, uncompressed sizes, compression
metadata, section/page navigation, and structured diagnostics.
The golden tests parse a real Joplin desktop `.one` file with three pages and a
real Joplin `.onepkg` with seven entries, five sections, and seven pages. LZX
`0x1203` and `0x1303` data and an uncompressed CAB are covered by fixtures.
This is a structural reader, not a full-fidelity OneNote renderer. Images,
tables, ink, embedded files, layout, formatting, links, and exports are not
rendered. FSSHTTP-packaged `.one`, MSZIP and Quantum CAB extraction,
multi-cabinet archives, and `.onetoc2` hierarchy/order interpretation are not
supported. See [format support](docs/FORMAT_SUPPORT.md) for the precise matrix.
There is no Rust/Wasm runtime, server, proxy, upload, telemetry, or notebook
storage. All parsing and CAB/LZX decompression are ordinary browser JavaScript
produced from the TypeScript source.
## Use
Run the static build or open it through Toolbox Portal, then drop or choose one
local `.one` or `.onepkg` file. Use **Clear file** to terminate the current
worker and release application references. Browser garbage collection does not
provide a secure-erasure guarantee.
## Development
Node.js 22 or newer is required. The checked-in `.npmrc` contains only the
public package registry mapping and no credential.
```sh
npm ci
npm run dev
npm run check
```
`npm run check` validates manifest synchronization, TypeScript, ESLint,
Prettier, fixture-backed Vitest tests, the production build, and
`toolbox-check dist`.
The application works standalone. A same-origin Toolbox catalogue can be
supplied through the SDK's `?toolbox=` context mechanism. The app uses the SDK
`AppShell` and does not add a second suite-level switcher.
## Release artifact
```sh
npm run release:artifact
(cd release && sha256sum -c onenote-tools-0.1.0.sha256)
```
The ZIP root contains the built static app and manifest plus the changelog,
corresponding-source information, security/support/provenance documents, and
the required project and third-party licence texts. File order and ZIP
timestamps are fixed. Existing artifacts are not overwritten unless `--force`
is passed after `--`.
Toolbox Portal expands the reviewed ZIP below an `apps/<target>/` path and
locks its exact SHA-256. It does not build this repository or import this app
into the portal React tree.
## Design and provenance
- [Format support](docs/FORMAT_SUPPORT.md)
- [Security model and active limits](docs/SECURITY.md)
- [Pinned references and normative specifications](docs/REFERENCE_IMPLEMENTATIONS.md)
- [Port provenance](docs/PORTING.md)
- [Fixture provenance](tests/fixtures/README.md)
## Licence
OneNote Tools is licensed under MPL-2.0. The complete licence is in
[`LICENSES/MPL-2.0.txt`](LICENSES/MPL-2.0.txt). Adapted and third-party material
remains under the terms recorded in
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).

17
SOURCE.md Normal file
View File

@@ -0,0 +1,17 @@
# Corresponding source
The corresponding source for OneNote Tools 0.1.0 is the `v0.1.0` tag:
https://git.add-ideas.de/zemion/onenote-tools/src/tag/v0.1.0
Build the release from that tag with Node.js 22 or newer:
```sh
npm ci
npm run release:artifact
```
The committed lockfile fixes the npm dependency graph. Exact reference
implementation revisions and the source-to-TypeScript mapping are recorded in
`docs/REFERENCE_IMPLEMENTATIONS.md` and `docs/PORTING.md`. Third-party terms
are reproduced in `LICENSES/` and summarized in `THIRD_PARTY_NOTICES.md`.

38
THIRD_PARTY_NOTICES.md Normal file
View File

@@ -0,0 +1,38 @@
# Third-party notices
The production application uses:
- `@add-ideas/toolbox-contract` 0.1.1, Apache-2.0;
- `@add-ideas/toolbox-shell-react` 0.1.1, Apache-2.0;
- React, React DOM, and Scheduler, MIT.
The release packager collects the complete installed runtime licence texts into
`LICENSES/THIRD-PARTY-LICENSES.txt`.
The native TypeScript OneStore/MS-ONE parser follows selected structures and
traversal from `onenote.rs` (MPL-2.0), pinned at
`5138a39a3f4e72b840932f9872fecde52fa9da60`. Adapted source files carry MPL
provenance headers.
The browser-native CAB parser/checksum implementation is a translated and
security-bounded adaptation of `rust-cab` (MIT), pinned at
`c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2`. The LZX decoder is a translated
adaptation of `lzxd` 0.2.5 (MIT OR Apache-2.0), pinned at
`4748e43594e3e30cff2ace3a6ad7a376c9816fdd`; this project uses it under the MIT
option. Adapted modules carry provenance headers and the licence texts are in
`LICENSES/rust-cab-MIT.txt` and `LICENSES/lzxd-MIT.txt`.
The Base64-encoded `onenote_desktop.one` test fixture comes from Joplin at
commit `28eb53bd9f0cd4ae0dec0c59a08037075a38302b` and is distributed under
AGPL-3.0-or-later. Its licence text is in `LICENSES/AGPL-3.0.txt`.
The Base64-encoded Joplin `test.onepkg` fixture is pinned at commit
`1e73aad7eb08fde5d9e4cb533df40052a5cd32d7` and is likewise test-only data
distributed under AGPL-3.0-or-later.
The encoded fixture bytes are excluded from `dist` and the release ZIP. The
release retains the fixture provenance document and AGPL text so these notices
remain complete.
The remaining npm packages are build and test tooling and are not shipped as
runtime modules in the static application.

57
docs/FORMAT_SUPPORT.md Normal file
View File

@@ -0,0 +1,57 @@
# Format support
## Version 0.1.0 support matrix
| Input or feature | Status | Tested evidence and boundary |
| ---------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------- |
| Desktop revision-store `.one` | Supported subset | Real 34,904-byte Joplin golden; three pages |
| FSSHTTP-packaged `.one` | Unsupported | Header is detected and rejected with a controlled diagnostic |
| Unknown/legacy `.one` variants | Unsupported | File type and format GUIDs are validated before traversal |
| `.onepkg`, single CAB, LZX | Supported subset | Real Joplin package: LZX `0x1203`, three CFDATA blocks, seven entries, five sections, seven pages |
| CAB LZX `0x1303` | Supported extraction | Byte-for-byte rust-cab MIT unit fixture, two text entries |
| CAB without compression | Supported extraction | Byte-for-byte rust-cab MIT unit fixture |
| CAB MSZIP | Unsupported | Metadata is recognized; extraction fails with `cab-compression-unsupported` |
| CAB Quantum | Unsupported | Metadata is recognized; extraction fails with `cab-compression-unsupported` |
| Multi-cabinet CAB / spanning files | Unsupported | Header flags and spanning folder indexes are rejected |
| `.onetoc2` package entries | Listed only | Files are identified, but notebook hierarchy and section order are not interpreted |
| Standalone `.onetoc2` | Unsupported in the app | The picker accepts only `.one` and `.onepkg` |
“Supported subset” is intentionally narrower than “all files with this
extension.” A successful open requires the tested desktop OneStore structures
and content graph nodes. Malformed offsets, counts, references, CAB checksums,
paths, compressed data, and unsupported variants produce bounded diagnostics or
a controlled failure.
The default LZX limit accepts CAB window sizes through 8 MiB, but compatibility
claims are fixture-backed only for window bits 18 (`0x1203`) and 19 (`0x1303`).
Trailing CAB data is ignored with a warning. Reserved CAB areas are bounded and
skipped. CAB names are decoded as strict UTF-8; unmarked valid UTF-8 receives a
warning, while invalid non-ASCII legacy-code-page names are rejected rather
than guessed.
## Extracted OneNote data
The desktop section path currently returns:
- section display name;
- page ID, title, created/updated timestamp when available, and page level;
- visible Unicode/extended-ASCII rich text as plain text and a text preview;
- diagnostics tied to an offset, structure, or page where available.
It does not preserve visual position, rich-text formatting, list formatting,
tables, images, ink, equations, embedded files, audio/video, active links,
page versions/conflicts, or exports. Unsupported content objects remain visible
as diagnostics instead of being silently presented as parsed.
Within a supported `.onepkg`, each `.one` entry is parsed independently.
Malformed or unsupported sections are marked failed/unsupported while other
sections remain available. Package navigation is temporary normalized-path
order until `.onetoc2` interpretation is implemented.
## Test fixtures
The exact hashes, source revisions, licences, expected page names, and CAB
structures are recorded in
[`tests/fixtures/README.md`](../tests/fixtures/README.md). Tests use no private
notebooks and do not treat a synthetic archive as proof of Microsoft OneNote
export compatibility.

33
docs/PORTING.md Normal file
View File

@@ -0,0 +1,33 @@
# Port provenance
The desktop `.one` reader adapts selected structures from the pinned
`onenote.rs` implementation. It is not a line-for-line or complete port.
| TypeScript module | Upstream path(s) | Revision | Port scope and deviations |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `src/onenote/onestore/file-node.ts` | `onestore/desktop/file_structure/file_node_list_fragment.rs`, `file_node/*`, chunk references | `onenote.rs@5138a39a3f4e72b840932f9872fecde52fa9da60` | Selected; immutable byte input, strict range checks, fragment/child cycle detection, and depth/count limits |
| `src/onenote/onestore/property-set.ts` | `onestore/shared/{object_prop_set,prop_set,property,object_stream_header}.rs` | same | Substantial; bounded vectors/counts/nesting and serializable discriminated unions |
| `src/onenote/onestore/desktop-store.ts` | `onestore/desktop/{file_node,file_structure,objects}/*` | same | Selected desktop section path; explicit missing-reference errors; file-data/attachment payloads skipped with diagnostics |
| `src/onenote/one/{constants,section}.ts` | `one/property*`, `one/property_set/*`, `onenote/{section,page,page_series,outline,rich_text}.rs` | same | Page metadata, titles, and plain text only; bounded graph traversal; visual/embedded content diagnosed |
The browser-native package path has separate MIT provenance:
| TypeScript module | Upstream path(s) | Revision and selected licence | Port scope and deviations |
| -------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/onenote/onepkg/{cabinet,checksum}.ts` | rust-cab `src/{cabinet,folder,file,ctype,checksum}.rs` | `c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2`, MIT | CAB reading/checksum adapted for immutable browser bytes, strict normalized paths, checked ranges/checksums, whole-folder extraction, explicit limits, and cancellation/yield hooks; writers and MSZIP are not ported |
| `src/onenote/onepkg/lzx/{bit-reader,huffman,decoder}.ts` | lzxd 0.2.5 `src/{bitstream,tree,block,window,lib}.rs` | `4748e43594e3e30cff2ace3a6ad7a376c9816fdd`, MIT option of MIT OR Apache-2.0 | Directly translated core with typed arrays, CAB CFDATA boundaries, persistent folder state, strict malformed-stream errors, and operation/window/cancellation limits; delta reference data is omitted |
The bounded binary readers, format detection, parser limits/error model, path
policy, worker protocol/DTOs, package-to-section boundary, and application UI
are original TypeScript implementations guided by the normative specifications
and project requirements.
Files adapted from `onenote.rs` carry an MPL-2.0 notice, SPDX identifier,
upstream path/revision, and deviations. Files adapted from rust-cab or lzxd
carry their MIT provenance, copyright notice, exact revision, and deviations.
Licence texts are reproduced in `LICENSES/` and included in the release ZIP.
Focused tests cover successful pinned fixtures plus malformed bounds,
references, checksums, paths, Huffman trees, compressed blocks, limits, and
cancellation. Fixture origins and redistribution terms are recorded separately
in `tests/fixtures/README.md`.

View File

@@ -0,0 +1,46 @@
# Specifications and pinned references
Version 0.1.0 records these immutable implementation revisions:
| Repository | Exact revision | Use |
| ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------- |
| [`toolbox-sdk`](https://git.add-ideas.de/zemion/toolbox-sdk/src/commit/5e206baf42dfae5a00f2743fb2cc501bb12d9b7a) | `5e206baf42dfae5a00f2743fb2cc501bb12d9b7a` | App contract, React shell, manifest schema/testkit, and nested-path behavior |
| [`toolbox-portal`](https://git.add-ideas.de/zemion/toolbox-portal/src/commit/5d2e466ad61e36a0e2be2a2d8459b1a127f53f11) | `5d2e466ad61e36a0e2be2a2d8459b1a127f53f11` | Reviewed ZIP shape, exact checksum lock, and independent-app assembly model |
| [`onenote.rs`](https://github.com/msiemens/onenote.rs/tree/5138a39a3f4e72b840932f9872fecde52fa9da60) | `5138a39a3f4e72b840932f9872fecde52fa9da60` | Selected desktop OneStore and MS-ONE structures adapted into TypeScript |
| [`rust-cab`](https://github.com/mdsteele/rust-cab/tree/c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2) | `c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2` | CAB structures, checksum, enumeration/extraction, and two unit fixtures |
| [`lzxd` 0.2.5](https://github.com/Lonami/lzxd/tree/4748e43594e3e30cff2ace3a6ad7a376c9816fdd) | `4748e43594e3e30cff2ace3a6ad7a376c9816fdd` | LZX bitstream, Huffman, block/window, match, and E8 logic adapted into TypeScript |
The SDK and Portal revisions were inspected from the local repositories. The
three parser references were cloned and reviewed at the hashes above; a branch
name or package version alone is not used as provenance. Source-level mappings
and behavioural deviations are in [PORTING.md](PORTING.md).
## Normative Microsoft documents
The implementation uses the official specifications as the source of format
truth and the pinned projects as implementation references:
- [[MS-ONESTORE]: OneNote Revision Store File Format](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-onestore/ae670cd2-4b38-4b24-82d1-87cfb2cc3725),
particularly sections 2.2.4 (chunk references), 2.3.1 (header), 2.4
(file-node lists and fragments), 2.5.1-2.5.8 (object-space/revision
manifests), and 2.6.7 (property sets).
- [[MS-ONE]: OneNote File Format](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-one/73d22548-a613-4350-8c23-07d15576be50),
particularly sections 2.1.8-2.1.13 (root objects, page object spaces, and
property sets), 2.2.17-2.2.23 (section/page-series/outline/rich-text nodes),
and 2.2.30, 2.2.34, 2.2.62-2.2.65, and 2.2.91 (page metadata/manifest,
titles, page-space references, and section name).
- [[MS-CAB]: Cabinet File Format](https://download.microsoft.com/download/4/d/a/4da14f27-b4ef-4170-a6e6-5b1ef85b1baa/%5Bms-cab%5D.pdf),
sections 2.1-2.4 (`CFHEADER`, `CFFOLDER`, `CFFILE`, and `CFDATA`) and 3.1
(checksum method).
- [[MS-PATCH]: LZX Delta Compression and Decompression](https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-patch/cc78752a-b4af-4eee-88cb-01f4d8a4c2bf),
sections 2.1-2.7 (bitstream/window concepts, header/E8 translation, blocks,
Huffman trees, pretree encoding, tokens, and decoding). `lzxd` 0.2.5 states
that it was based on revision 9.0; the port removes delta/reference-data use
and applies CAB CFDATA chunk framing.
The Microsoft documents are linked rather than copied. Their published pages
can change independently; the source references and golden bytes remain pinned
for reproducibility.
`one2html` is deliberately not pinned or inspected because visual HTML
rendering is outside this release.

113
docs/SECURITY.md Normal file
View File

@@ -0,0 +1,113 @@
# Security model
## Trust boundary and local processing
Selected `.one` and `.onepkg` files are untrusted. The main thread checks the
source size, reads one file into an `ArrayBuffer`, transfers it to a dedicated
module worker, and receives serializable data-transfer objects. CAB entries are
extracted only into memory; archive paths are never written to the filesystem.
The worker is a fault-containment and responsiveness boundary, not a security
sandbox. A successful open keeps its worker session alive for page requests.
Clearing the file, choosing another file, or unmounting the app terminates that
worker; a failed open also terminates it. JavaScript garbage collection does
not promise immediate release or secure erasure.
The application does not:
- upload or transmit notebook bytes;
- use a backend, proxy, telemetry, analytics, remote fonts, or external content
fetching;
- persist source or derived content in LocalStorage, IndexedDB, Cache Storage,
cookies, or a service worker;
- execute notebook content or inject notebook HTML/SVG;
- use WebAssembly, a Rust runtime, or a native helper.
## Active default limits
The main thread rejects a source larger than 512 MiB before reading it. The
desktop section parser then applies these independent defaults:
| Resource | Default limit |
| ------------------------------------- | -------------------: |
| Section bytes | 512 MiB |
| File-node list fragments / nesting | 16,384 / 64 |
| File nodes | 1,000,000 |
| Objects | 500,000 |
| Properties per set / property nesting | 16,384 / 64 |
| Stream IDs | 1,000,000 |
| One property vector | 64 MiB |
| Object references | 1,000,000 |
| Content-graph depth | 256 |
| Pages per section | 100,000 |
| Diagnostics per section | 10,000 |
| Extracted text per section | 2,000,000 characters |
The CAB/package path applies these defaults:
| Resource | Default limit |
| ------------------------------- | -------------: |
| Cabinet input | 512 MiB |
| Folders / files | 1,024 / 10,000 |
| CFDATA blocks | 131,072 |
| CAB name bytes | 255 |
| Path characters / path segments | 4,096 / 64 |
| One extracted file | 256 MiB |
| One folder output | 512 MiB |
| Total folder output | 512 MiB |
| Total extracted entry bytes | 512 MiB |
| Folder compression ratio | 200:1 |
| LZX window | 8 MiB |
| LZX decode operations | 1,000,000,000 |
A CFDATA block may declare at most 32 KiB of output. Counts and offsets use
checked arithmetic and bounded readers. The CAB parser verifies supplied block
checksums, rejects overlapping/out-of-range data, rejects absolute/traversal,
empty, control-character, and case-insensitive duplicate paths, and refuses
multi-cabinet/spanning entries.
LZX checks its window, Huffman trees, block sizes, match ranges, output size,
and operation budget. CAB extraction checks cancellation and yields between
data blocks so the worker can process termination. Desktop OneStore traversal
checks fragment/reference cycles, nesting, object/property/reference counts,
and byte ranges.
## Rendering and links
DTO strings are rendered as React text nodes. Notebook HTML, active SVG,
scripts, attachment payloads, and automatic external navigation are not
rendered. Link activation is not implemented. If links are added, they require
explicit user action, scheme validation, and safe navigation attributes.
## Known gaps and roadmap
- There is no wall-clock deadline. Synchronous desktop `.one` parsing cannot
observe cooperative cancellation until control returns; terminating the
worker is the current hard stop.
- The package flow holds the source, decompressed folder buffers, extracted
entry slices, and parsed DTOs in memory at overlapping times. The byte/count
limits bound these individually but do not guarantee a low peak heap.
- Section text/page/diagnostic limits apply per contained section rather than
once to the complete package.
- CAB checksums detect accidental corruption but do not authenticate a package.
- Format coverage is narrow and has only the documented golden files. New
producer/version claims require licensed regression fixtures and malformed
variants.
- The worker boundary reduces UI impact but is not a substitute for browser and
operating-system isolation. Open especially sensitive files in a dedicated
browser profile if that risk matters.
Planned hardening includes an elapsed-time budget, cancellation checkpoints in
desktop OneStore graph traversal, aggregate package DTO/text budgets, lower
peak-memory extraction, expanded malformed-input/fuzz corpora, and additional
licensed OneNote export fixtures.
## Fixtures and vulnerability reporting
Never commit private notebooks. Fixture provenance and redistribution terms
must be recorded in `tests/fixtures/README.md`.
Report suspected parser vulnerabilities privately to the repository owner
through the Gitea instance before publishing malicious fixtures. Include the
application version, browser, smallest reproducible input, diagnostic, and
observed resource use. Do not attach sensitive notebooks.

34
eslint.config.mjs Normal file
View File

@@ -0,0 +1,34 @@
import js from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist', 'release', 'coverage', 'node_modules'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
{
files: ['scripts/**/*.mjs'],
languageOptions: { globals: globals.node },
},
eslintConfigPrettier
);

17
index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="A local-first browser workspace for native OneNote parsing."
/>
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<title>OneNote Tools</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4007
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

58
package.json Normal file
View File

@@ -0,0 +1,58 @@
{
"name": "onenote-tools",
"version": "0.1.0",
"description": "A local-first browser workspace for native OneNote parsing.",
"license": "MPL-2.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"predev": "npm run manifest:generate",
"dev": "vite",
"prebuild": "npm run manifest:generate",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --pretty false",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:watch": "vitest",
"manifest:generate": "node scripts/generate-toolbox-manifest.mjs",
"manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
"toolbox:check": "toolbox-check dist",
"release:artifact": "npm run check && node scripts/package-release.mjs",
"check": "npm run manifest:check && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run toolbox:check"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.1.1",
"@add-ideas/toolbox-shell-react": "0.1.1",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.1.1",
"@eslint/js": "^10.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.8.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"fflate": "^0.8.3",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"prettier": "^3.8.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.3",
"vite": "^8.0.13",
"vitest": "^4.1.6"
}
}

4
public/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="OneNote Tools">
<rect width="64" height="64" rx="12" fill="#5b2c83"/>
<path fill="#fff" d="M15 14h27a7 7 0 0 1 7 7v28H22a7 7 0 0 1-7-7V14Zm9 11v20h6V34l9 11h5V25h-6v11L29 25h-5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 275 B

33
public/toolbox-app.json Normal file
View File

@@ -0,0 +1,33 @@
{
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.onenote-tools",
"name": "OneNote Tools",
"description": "Local-first reader for tested desktop revision-store .one sections and LZX .onepkg packages; other OneNote formats are not implemented.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["documents", "onenote"],
"tags": ["local", "read-only", "one", "onepkg", "cab", "lzx"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": true,
"indexedDb": false,
"crossOriginIsolated": false
},
"privacy": {
"processing": "local",
"fileUploads": false,
"telemetry": false,
"label": "Local only · no uploads · no storage · no telemetry"
},
"source": {
"repository": "https://git.add-ideas.de/zemion/onenote-tools",
"license": "MPL-2.0"
},
"version": "0.1.0"
}

View File

@@ -0,0 +1,64 @@
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { parseToolboxApp } from '@add-ideas/toolbox-contract';
import { format } from 'prettier';
const projectRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..'
);
const packageJsonPath = path.join(projectRoot, 'package.json');
const definitionPath = path.join(
projectRoot,
'src',
'toolbox',
'manifest.definition.json'
);
const versionSourcePath = path.join(projectRoot, 'src', 'version.ts');
const manifestPath = path.join(projectRoot, 'public', 'toolbox-app.json');
const [packageJson, definition, versionSource] = await Promise.all([
readJson(packageJsonPath),
readJson(definitionPath),
readFile(versionSourcePath, 'utf8'),
]);
const sourceVersion = versionSource.match(
/APP_VERSION\s*=\s*['"]([^'"]+)['"]/
)?.[1];
if (!sourceVersion) {
throw new Error('Could not read APP_VERSION from src/version.ts.');
}
if (sourceVersion !== packageJson.version) {
throw new Error(
`Version mismatch: package.json is ${packageJson.version}, while src/version.ts is ${sourceVersion}.`
);
}
const manifest = {
$schema:
'https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json',
schemaVersion: 1,
...definition,
version: packageJson.version,
};
parseToolboxApp(manifest);
const output = await format(JSON.stringify(manifest), { parser: 'json' });
if (process.argv.includes('--check')) {
const existing = await readFile(manifestPath, 'utf8').catch(() => '');
if (existing !== output) {
throw new Error(
'public/toolbox-app.json is out of date. Run npm run manifest:generate.'
);
}
} else {
await writeFile(manifestPath, output);
}
async function readJson(filePath) {
return JSON.parse(await readFile(filePath, 'utf8'));
}

126
scripts/package-release.mjs Normal file
View File

@@ -0,0 +1,126 @@
import { createHash } from 'node:crypto';
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
collectDirectoryFiles,
createDeterministicZip,
createThirdPartyLicenseBundle,
} from './release-archive.mjs';
const modulePath = fileURLToPath(import.meta.url);
const defaultProjectRoot = path.resolve(path.dirname(modulePath), '..');
export async function collectReleaseMetadataFiles(projectRoot) {
const files = {};
for (const fileName of [
'CHANGELOG.md',
'LICENSE',
'README.md',
'SOURCE.md',
'THIRD_PARTY_NOTICES.md',
]) {
files[fileName] = await readFile(path.join(projectRoot, fileName));
}
for (const licenseName of [
'AGPL-3.0.txt',
'MPL-2.0.txt',
'lzxd-MIT.txt',
'rust-cab-MIT.txt',
]) {
files[`LICENSES/${licenseName}`] = await readFile(
path.join(projectRoot, 'LICENSES', licenseName)
);
}
files['LICENSES/THIRD-PARTY-LICENSES.txt'] =
await createThirdPartyLicenseBundle(projectRoot, [
'@add-ideas/toolbox-contract',
'@add-ideas/toolbox-shell-react',
'react',
'react-dom',
'scheduler',
]);
for (const documentName of [
'FORMAT_SUPPORT.md',
'PORTING.md',
'REFERENCE_IMPLEMENTATIONS.md',
'SECURITY.md',
]) {
files[`docs/${documentName}`] = await readFile(
path.join(projectRoot, 'docs', documentName)
);
}
files['tests/fixtures/README.md'] = await readFile(
path.join(projectRoot, 'tests', 'fixtures', 'README.md')
);
return files;
}
async function packageRelease(projectRoot, arguments_) {
const packageJson = JSON.parse(
await readFile(path.join(projectRoot, 'package.json'), 'utf8')
);
const releaseDirectory = path.join(projectRoot, 'release');
const archiveName = `onenote-tools-${packageJson.version}.zip`;
const archivePath = path.join(releaseDirectory, archiveName);
const checksumPath = path.join(
releaseDirectory,
`onenote-tools-${packageJson.version}.sha256`
);
const force = arguments_.includes('--force');
const unknownArguments = arguments_.filter(
(argument) => argument !== '--force'
);
if (unknownArguments.length > 0) {
throw new Error(`Unknown release argument: ${unknownArguments.join(', ')}`);
}
await mkdir(releaseDirectory, { recursive: true });
if (!force) {
await assertDoesNotExist(archivePath, projectRoot);
await assertDoesNotExist(checksumPath, projectRoot);
}
const files = await collectDirectoryFiles(path.join(projectRoot, 'dist'));
if (Object.keys(files).some((fileName) => fileName.endsWith('.wasm'))) {
throw new Error('Release input contains a forbidden WebAssembly file.');
}
Object.assign(files, await collectReleaseMetadataFiles(projectRoot));
const archive = createDeterministicZip(files);
const checksum = createHash('sha256').update(archive).digest('hex');
const writeFlag = force ? 'w' : 'wx';
await writeFile(archivePath, archive, { flag: writeFlag });
await writeFile(checksumPath, `${checksum} ${archiveName}\n`, {
encoding: 'utf8',
flag: writeFlag,
});
console.log(`Created release/${archiveName}`);
console.log(`SHA-256 ${checksum}`);
}
async function assertDoesNotExist(outputPath, projectRoot) {
try {
await access(outputPath);
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') return;
throw error;
}
throw new Error(
`${path.relative(projectRoot, outputPath)} already exists; pass --force to replace this exact version.`
);
}
if (
process.argv[1] !== undefined &&
path.resolve(process.argv[1]) === modulePath
) {
await packageRelease(defaultProjectRoot, process.argv.slice(2));
}

View File

@@ -0,0 +1,12 @@
export function compareCodePoints(left: string, right: string): number;
export function createDeterministicZip(
files: Record<string, Uint8Array>
): Uint8Array;
export function collectDirectoryFiles(
directory: string,
prefix?: string
): Promise<Record<string, Buffer>>;
export function createThirdPartyLicenseBundle(
projectRoot: string,
packageNames: string[]
): Promise<Buffer>;

118
scripts/release-archive.mjs Normal file
View File

@@ -0,0 +1,118 @@
import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import { zipSync } from 'fflate';
const LICENSE_FILE = /^(?:licen[cs]e|copying|notice)(?:[._-].*)?$/iu;
export function compareCodePoints(left, right) {
return left < right ? -1 : left > right ? 1 : 0;
}
export function createDeterministicZip(files) {
const orderedFiles = Object.fromEntries(
Object.entries(files).sort(([left], [right]) =>
compareCodePoints(left, right)
)
);
return zipSync(orderedFiles, {
level: 9,
mtime: new Date(1980, 0, 1, 0, 0, 0),
});
}
export async function collectDirectoryFiles(directory, prefix = '') {
const result = {};
const entries = await readdir(directory, { withFileTypes: true });
entries.sort((left, right) => compareCodePoints(left.name, right.name));
for (const entry of entries) {
const absolutePath = path.join(directory, entry.name);
const relativePath = path.posix.join(prefix, entry.name);
if (entry.isDirectory()) {
Object.assign(
result,
await collectDirectoryFiles(absolutePath, relativePath)
);
} else if (entry.isFile()) {
result[relativePath] = await readFile(absolutePath);
} else {
throw new Error(
`Unsupported filesystem entry in release input: ${relativePath}`
);
}
}
return result;
}
export async function createThirdPartyLicenseBundle(projectRoot, packageNames) {
const sections = [];
for (const packageName of [...new Set(packageNames)].sort(
compareCodePoints
)) {
const packageDirectory = path.join(
projectRoot,
'node_modules',
...packageName.split('/')
);
const packageJson = JSON.parse(
await readFile(path.join(packageDirectory, 'package.json'), 'utf8')
);
const licenseFiles = await collectLicenseFiles(packageDirectory);
if (licenseFiles.length === 0) {
throw new Error(`No license text found for ${packageName}.`);
}
const contents = [];
for (const licenseFile of licenseFiles) {
const text = await readFile(
path.join(packageDirectory, ...licenseFile.split('/')),
'utf8'
);
contents.push(
`--- ${licenseFile} ---\n${text.replaceAll('\r\n', '\n').trim()}\n`
);
}
sections.push(
[
'================================================================================',
`${packageJson.name ?? packageName}@${packageJson.version ?? 'unknown'}`,
`Declared license: ${String(packageJson.license ?? 'unspecified')}`,
'================================================================================',
...contents,
].join('\n')
);
}
return Buffer.from(
[
'Runtime third-party software license texts bundled with OneNote Tools',
'Generated deterministically from installed production packages.',
'',
...sections,
'',
].join('\n'),
'utf8'
);
}
async function collectLicenseFiles(directory, prefix = '') {
const matches = [];
const entries = await readdir(directory, { withFileTypes: true });
entries.sort((left, right) => compareCodePoints(left.name, right.name));
for (const entry of entries) {
if (entry.name === 'node_modules') continue;
const relativePath = path.posix.join(prefix, entry.name);
const absolutePath = path.join(directory, entry.name);
if (entry.isDirectory()) {
matches.push(...(await collectLicenseFiles(absolutePath, relativePath)));
} else if (entry.isFile() && LICENSE_FILE.test(entry.name)) {
matches.push(relativePath);
} else if (!entry.isFile()) {
throw new Error(
`Unsupported filesystem entry in license input: ${relativePath}`
);
}
}
return matches;
}

333
src/App.test.tsx Normal file
View File

@@ -0,0 +1,333 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { OneNoteApplication } from './App.js';
import type {
OneNotePackageDto,
OneNoteSectionDto,
} from './onenote/model/dto.js';
import {
MAX_SOURCE_FILE_BYTES,
sourceKindFromFileName,
} from './source-file.js';
import {
OneNoteWorkerClient,
type WorkerPort,
} from './worker/onenote.client.js';
import type {
WorkerRequest,
WorkerResponse,
} from './worker/worker-protocol.js';
const section: OneNoteSectionDto = {
format: 'one',
sourceName: 'Section.one',
sectionName: 'Project notes',
pages: [
{
id: 'page-1',
title: 'First meeting',
text: 'This is the complete extracted page text.',
textPreview: 'This is the complete extracted page text.',
createdAt: '2024-01-02T10:00:00.000Z',
},
],
diagnostics: [
{
severity: 'warning',
code: 'UNSUPPORTED_IMAGE',
message: 'An image was not rendered.',
recoverable: true,
},
],
parserInfo: {
implementation: 'typescript',
supportedVariant: 'desktop-revision-store',
referenceRevision: 'test',
},
};
class SuccessfulParserWorker implements WorkerPort {
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
onerror: ((event: ErrorEvent) => void) | null = null;
terminated = false;
constructor(private readonly packageResult?: OneNotePackageDto) {}
postMessage(message: WorkerRequest): void {
queueMicrotask(() => {
if (message.type === 'parse-one') {
this.respond({
type: 'section',
requestId: message.requestId,
sessionId: 'session-one',
section,
});
} else if (message.type === 'parse-onepkg' && this.packageResult) {
this.respond({
type: 'package',
requestId: message.requestId,
sessionId: 'session-package',
notebook: this.packageResult,
});
} else if (message.type === 'get-page') {
const page = section.pages[0]!;
this.respond({
type: 'page',
requestId: message.requestId,
sessionId: message.sessionId,
page: {
...page,
id: message.pageId,
blocks: [{ kind: 'text', text: page.text }],
diagnostics: [],
},
});
}
});
}
terminate(): void {
this.terminated = true;
}
private respond(response: WorkerResponse): void {
this.onmessage?.(new MessageEvent('message', { data: response }));
}
}
class FailingParserWorker implements WorkerPort {
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
onerror: ((event: ErrorEvent) => void) | null = null;
postMessage(message: WorkerRequest): void {
if (message.type !== 'parse-one' && message.type !== 'parse-onepkg') return;
queueMicrotask(() => {
this.onmessage?.(
new MessageEvent('message', {
data: {
type: 'failure',
requestId: message.requestId,
error: {
code: 'invalid-format',
message: 'The source header is not a recognized OneNote file.',
recoverable: false,
structure: 'OneStore header',
},
} satisfies WorkerResponse,
})
);
});
}
terminate(): void {}
}
describe('OneNoteApplication', () => {
it('recognizes only supported local filename extensions', () => {
expect(sourceKindFromFileName('Section.ONE')).toBe('one');
expect(sourceKindFromFileName('Notebook.OnePkg')).toBe('onepkg');
expect(sourceKindFromFileName('notes.zip')).toBeNull();
});
it('opens a section, navigates its page, shows diagnostics, and clears', async () => {
const user = userEvent.setup();
const worker = new SuccessfulParserWorker();
render(
<OneNoteApplication
createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
/>
);
await user.upload(
screen.getByLabelText('Choose a OneNote file'),
new File([new Uint8Array([1, 2, 3])], 'Section.one')
);
expect(
await screen.findByRole('heading', { name: 'First meeting' })
).toBeInTheDocument();
expect(
within(screen.getByRole('article', { name: 'First meeting' })).getByText(
'This is the complete extracted page text.'
)
).toBeInTheDocument();
expect(screen.getByText('UNSUPPORTED_IMAGE')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Clear file' }));
expect(worker.terminated).toBe(true);
expect(
screen.getByRole('heading', { name: 'Open a OneNote file' })
).toBeInTheDocument();
});
it('opens a dropped OneNote package through the local worker', async () => {
const notebook: OneNotePackageDto = {
format: 'onepkg',
sourceName: 'Notebook.onepkg',
sourceSize: 1,
entries: [],
sections: [],
diagnostics: [],
};
render(
<OneNoteApplication
createWorkerClient={() =>
new OneNoteWorkerClient(() => new SuccessfulParserWorker(notebook))
}
/>
);
fireEvent.drop(screen.getByTestId('file-drop-zone'), {
dataTransfer: {
files: [new File([new Uint8Array([1])], 'Notebook.onepkg')],
},
});
expect(
await screen.findByRole('heading', { name: 'Package summary' })
).toBeInTheDocument();
});
it('keeps a failed packaged section visible beside a readable one', async () => {
const notebook: OneNotePackageDto = {
format: 'onepkg',
sourceName: 'Notebook.onepkg',
sourceSize: 4096,
entries: [
{
path: 'Notes/Readable.one',
normalizedPath: 'Notes/Readable.one',
kind: 'section',
uncompressedSize: 2048,
compressionMethod: 'LZX',
parseStatus: 'parsed',
},
{
path: 'Notes/Broken.one',
normalizedPath: 'Notes/Broken.one',
kind: 'section',
uncompressedSize: 1024,
compressionMethod: 'LZX',
parseStatus: 'failed',
},
],
sections: [
{
id: 'readable',
path: 'Notes/Readable.one',
displayName: 'Readable',
parseStatus: 'parsed',
section,
diagnostics: [],
},
{
id: 'broken',
path: 'Notes/Broken.one',
displayName: 'Broken',
parseStatus: 'failed',
diagnostics: [
{
severity: 'error',
code: 'SECTION_PARSE_FAILED',
message: 'This section is malformed.',
recoverable: true,
},
],
},
],
diagnostics: [
{
severity: 'warning',
code: 'TOC_NOT_INTERPRETED',
message: 'Package path order is temporary.',
recoverable: true,
},
],
};
const user = userEvent.setup();
render(
<OneNoteApplication
createWorkerClient={() =>
new OneNoteWorkerClient(() => new SuccessfulParserWorker(notebook))
}
/>
);
await user.upload(
screen.getByLabelText('Choose a OneNote file'),
new File([new Uint8Array([1])], 'Notebook.onepkg')
);
expect(
await screen.findByRole('heading', { name: 'Package summary' })
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /Broken, failed/i })
).toBeVisible();
expect(screen.getByText('TOC_NOT_INTERPRETED')).toBeInTheDocument();
expect(
await within(
screen.getByRole('article', { name: 'First meeting' })
).findByText('This is the complete extracted page text.')
).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Broken, failed/i }));
expect(screen.getByText('SECTION_PARSE_FAILED')).toBeInTheDocument();
expect(
screen.getByText('This section did not produce a readable page list.')
).toBeInTheDocument();
});
it('shows a controlled parser failure', async () => {
const user = userEvent.setup();
render(
<OneNoteApplication
createWorkerClient={() =>
new OneNoteWorkerClient(() => new FailingParserWorker())
}
/>
);
await user.upload(
screen.getByLabelText('Choose a OneNote file'),
new File(['not OneNote'], 'Broken.one')
);
expect(await screen.findByRole('alert')).toHaveTextContent(
'The source header is not a recognized OneNote file.'
);
expect(screen.getByRole('alert')).toHaveTextContent(
'invalid-format · OneStore header'
);
});
it('rejects unrelated extensions without starting a worker', async () => {
const user = userEvent.setup({ applyAccept: false });
render(<OneNoteApplication />);
await user.upload(
screen.getByLabelText('Choose a OneNote file'),
new File(['not a notebook'], 'notes.zip')
);
expect(await screen.findByRole('alert')).toHaveTextContent(
'Choose a file whose name ends in .one or .onepkg.'
);
});
it('enforces the source-size safety limit before reading', async () => {
const user = userEvent.setup();
render(<OneNoteApplication />);
const file = new File(['tiny'], 'large.one');
Object.defineProperty(file, 'size', {
configurable: true,
value: MAX_SOURCE_FILE_BYTES + 1,
});
await user.upload(screen.getByLabelText('Choose a OneNote file'), file);
expect(await screen.findByRole('alert')).toHaveTextContent('512 MiB');
});
});

410
src/App.tsx Normal file
View File

@@ -0,0 +1,410 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppShell } from '@add-ideas/toolbox-shell-react';
import '@add-ideas/toolbox-shell-react/styles.css';
import { DiagnosticsPanel } from './components/DiagnosticsPanel.js';
import { FileDropZone } from './components/FileDropZone.js';
import { HelpDialog } from './components/HelpDialog.js';
import { PackageInspector } from './components/PackageInspector.js';
import { PageReader } from './components/PageReader.js';
import {
PackageNavigation,
SingleSectionNavigation,
} from './components/SectionNavigation.js';
import type {
OneNotePackageDto,
OneNotePageDto,
OneNoteSectionDto,
} from './onenote/model/dto.js';
import type { ParserDiagnostic } from './onenote/model/diagnostics.js';
import {
MAX_SOURCE_FILE_BYTES,
sourceKindFromFileName,
} from './source-file.js';
import { toolboxApp } from './toolbox/manifest.js';
import {
OneNoteWorkerClient,
WorkerClientError,
} from './worker/onenote.client.js';
type LoadedSource =
| {
kind: 'one';
sessionId: string;
section: OneNoteSectionDto;
}
| {
kind: 'onepkg';
sessionId: string;
notebook: OneNotePackageDto;
};
type ApplicationState =
| { phase: 'idle' }
| { phase: 'reading'; fileName: string }
| { phase: 'loaded'; fileName: string; source: LoadedSource }
| {
phase: 'error';
title: string;
message: string;
detail?: string;
};
type PageState =
| { phase: 'empty' }
| { phase: 'loading'; pageId: string }
| { phase: 'loaded'; page: OneNotePageDto }
| { phase: 'error'; message: string };
function uniqueDiagnostics(
diagnostics: ParserDiagnostic[]
): ParserDiagnostic[] {
const seen = new Set<string>();
return diagnostics.filter((diagnostic) => {
const key = JSON.stringify([
diagnostic.severity,
diagnostic.code,
diagnostic.message,
diagnostic.offset,
diagnostic.structure,
diagnostic.propertyId,
]);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export interface OneNoteApplicationProps {
createWorkerClient?: () => OneNoteWorkerClient;
}
export function OneNoteApplication({
createWorkerClient = () => new OneNoteWorkerClient(),
}: OneNoteApplicationProps) {
const [state, setState] = useState<ApplicationState>({ phase: 'idle' });
const [pageState, setPageState] = useState<PageState>({ phase: 'empty' });
const [selectedSectionId, setSelectedSectionId] = useState<
string | undefined
>();
const activeClient = useRef<OneNoteWorkerClient | null>(null);
const generation = useRef(0);
const pageGeneration = useRef(0);
const clearFile = useCallback(() => {
generation.current += 1;
pageGeneration.current += 1;
activeClient.current?.terminate();
activeClient.current = null;
setSelectedSectionId(undefined);
setPageState({ phase: 'empty' });
setState({ phase: 'idle' });
}, []);
useEffect(() => {
return () => {
generation.current += 1;
pageGeneration.current += 1;
activeClient.current?.terminate();
};
}, []);
const loadPage = async (
sessionId: string,
pageId: string,
sectionId?: string
) => {
const client = activeClient.current;
const requestGeneration = ++pageGeneration.current;
if (!client) {
setPageState({
phase: 'error',
message: 'The local parser session is no longer available.',
});
return;
}
setPageState({ phase: 'loading', pageId });
try {
const response = await client.getPage(sessionId, pageId, sectionId);
if (requestGeneration !== pageGeneration.current) return;
if (response.type === 'failure') {
setPageState({ phase: 'error', message: response.error.message });
return;
}
setPageState({ phase: 'loaded', page: response.page });
} catch (error) {
if (requestGeneration !== pageGeneration.current) return;
setPageState({
phase: 'error',
message:
error instanceof WorkerClientError
? error.message
: 'The selected page could not be read from the parser session.',
});
}
};
const openFile = async (file: File) => {
const sourceKind = sourceKindFromFileName(file.name);
if (!sourceKind) {
setState({
phase: 'error',
title: 'Unsupported file name',
message: 'Choose a file whose name ends in .one or .onepkg.',
});
return;
}
if (file.size > MAX_SOURCE_FILE_BYTES) {
setState({
phase: 'error',
title: 'File is too large',
message: 'The selected file exceeds the current 512 MiB safety limit.',
});
return;
}
generation.current += 1;
pageGeneration.current += 1;
const requestGeneration = generation.current;
activeClient.current?.terminate();
const client = createWorkerClient();
activeClient.current = client;
setSelectedSectionId(undefined);
setPageState({ phase: 'empty' });
setState({ phase: 'reading', fileName: file.name });
try {
const bytes = await file.arrayBuffer();
if (generation.current !== requestGeneration) return;
const response = await client.parseSource(sourceKind, file.name, bytes);
if (generation.current !== requestGeneration) return;
if (response.type === 'failure') {
client.terminate();
if (activeClient.current === client) activeClient.current = null;
setState({
phase: 'error',
title: 'Could not open this OneNote source',
message: response.error.message,
detail: response.error.structure
? `${response.error.code} · ${response.error.structure}`
: response.error.code,
});
return;
}
if (response.type === 'section') {
setState({
phase: 'loaded',
fileName: file.name,
source: {
kind: 'one',
sessionId: response.sessionId,
section: response.section,
},
});
const firstPage = response.section.pages[0];
if (firstPage) void loadPage(response.sessionId, firstPage.id);
return;
}
const preferredSection =
response.notebook.sections.find(
(section) =>
section.parseStatus === 'parsed' &&
section.section !== undefined &&
section.section.pages.length > 0
) ??
response.notebook.sections.find(
(section) => section.parseStatus === 'parsed' && section.section
) ??
response.notebook.sections[0];
setSelectedSectionId(preferredSection?.id);
setState({
phase: 'loaded',
fileName: file.name,
source: {
kind: 'onepkg',
sessionId: response.sessionId,
notebook: response.notebook,
},
});
const firstPage = preferredSection?.section?.pages[0];
if (firstPage) {
void loadPage(response.sessionId, firstPage.id, preferredSection?.id);
}
} catch (error) {
if (generation.current !== requestGeneration) return;
client.terminate();
if (activeClient.current === client) activeClient.current = null;
setState({
phase: 'error',
title: 'Local parser stopped',
message:
error instanceof WorkerClientError
? error.message
: 'The local file could not be handed to the parser worker.',
});
}
};
const selectedPageId =
pageState.phase === 'loading'
? pageState.pageId
: pageState.phase === 'loaded'
? pageState.page.id
: undefined;
const selectedPage =
pageState.phase === 'loaded' ? pageState.page : undefined;
const selectPackageSection = (sectionId: string) => {
if (state.phase !== 'loaded' || state.source.kind !== 'onepkg') return;
setSelectedSectionId(sectionId);
const section = state.source.notebook.sections.find(
(candidate) => candidate.id === sectionId
);
const firstPage = section?.section?.pages[0];
if (firstPage) {
void loadPage(state.source.sessionId, firstPage.id, sectionId);
} else {
pageGeneration.current += 1;
setPageState({ phase: 'empty' });
}
};
const visibleDiagnostics = uniqueDiagnostics(
(() => {
if (state.phase !== 'loaded') return [];
const pageDiagnostics = selectedPage?.diagnostics ?? [];
if (state.source.kind === 'one') {
return [...state.source.section.diagnostics, ...pageDiagnostics];
}
const selectedSection = state.source.notebook.sections.find(
(section) => section.id === selectedSectionId
);
return [
...state.source.notebook.diagnostics,
...(selectedSection?.diagnostics ?? []),
...(selectedSection?.section?.diagnostics ?? []),
...pageDiagnostics,
];
})()
);
return (
<main className="application">
{state.phase === 'idle' ? (
<FileDropZone onFile={(file) => void openFile(file)} />
) : null}
{state.phase === 'reading' ? (
<section className="status-card" aria-live="polite">
<p className="status-card__label">Local parser worker</p>
<h2>Opening {state.fileName}</h2>
<p>
Validating the source and extracting supported content in this
browser.
</p>
<button type="button" onClick={clearFile}>
Cancel
</button>
</section>
) : null}
{state.phase === 'loaded' ? (
<>
<header className="source-toolbar">
<div>
<p className="eyebrow">
{state.source.kind === 'one' ? 'OneNote section' : 'ONEPKG'}
</p>
<h2>{state.fileName}</h2>
</div>
<button type="button" onClick={clearFile}>
Clear file
</button>
</header>
{state.source.kind === 'onepkg' ? (
<PackageInspector notebook={state.source.notebook} />
) : null}
<div className="reader-workspace">
{state.source.kind === 'one' ? (
<SingleSectionNavigation
section={state.source.section}
selectedPageId={selectedPageId}
onSelectPage={(pageId) =>
void loadPage(state.source.sessionId, pageId)
}
/>
) : (
<PackageNavigation
sections={state.source.notebook.sections}
selectedSectionId={selectedSectionId}
selectedPageId={selectedPageId}
onSelectSection={selectPackageSection}
onSelectPage={(pageId) =>
void loadPage(
state.source.sessionId,
pageId,
selectedSectionId
)
}
/>
)}
<PageReader
page={selectedPage}
loading={pageState.phase === 'loading'}
error={
pageState.phase === 'error' ? pageState.message : undefined
}
/>
</div>
<DiagnosticsPanel diagnostics={visibleDiagnostics} />
</>
) : null}
{state.phase === 'error' ? (
<section className="status-card status-card--error" role="alert">
<p className="status-card__label">Could not open file</p>
<h2>{state.title}</h2>
<p>{state.message}</p>
{state.detail ? <code>{state.detail}</code> : null}
<div>
<button type="button" onClick={clearFile}>
Try another file
</button>
</div>
</section>
) : null}
<aside className="privacy-note">
<strong>Local-only.</strong> Files are transferred to an in-browser
worker for parsing and are not uploaded, persisted, or sent to
analytics. Clearing ends the worker session.
</aside>
</main>
);
}
export function App() {
const [helpOpen, setHelpOpen] = useState(false);
return (
<AppShell
app={toolboxApp}
manifestUrl="./toolbox-app.json"
appActions={
<button type="button" onClick={() => setHelpOpen(true)}>
Help
</button>
}
>
<OneNoteApplication />
{helpOpen ? <HelpDialog onClose={() => setHelpOpen(false)} /> : null}
</AppShell>
);
}

View File

@@ -0,0 +1,70 @@
import type { ParserDiagnostic } from '../onenote/model/diagnostics.js';
interface DiagnosticsPanelProps {
diagnostics: ParserDiagnostic[];
title?: string;
}
function formatOffset(offset: number): string {
return `0x${offset.toString(16).toUpperCase()}`;
}
export function DiagnosticsPanel({
diagnostics,
title = 'Diagnostics',
}: DiagnosticsPanelProps) {
return (
<section className="diagnostics" aria-labelledby="diagnostics-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Parser details</p>
<h2 id="diagnostics-title">{title}</h2>
</div>
<span className="count-badge">{diagnostics.length}</span>
</div>
{diagnostics.length === 0 ? (
<p className="empty-message">No parser diagnostics for this view.</p>
) : (
<ul className="diagnostic-list">
{diagnostics.map((diagnostic, index) => (
<li
className={`diagnostic diagnostic--${diagnostic.severity}`}
key={`${diagnostic.code}-${diagnostic.offset ?? 'none'}-${index}`}
>
<div className="diagnostic__summary">
<strong>{diagnostic.code}</strong>
<span>{diagnostic.severity}</span>
</div>
<p>{diagnostic.message}</p>
{diagnostic.structure !== undefined ||
diagnostic.offset !== undefined ||
diagnostic.propertyId !== undefined ? (
<dl className="diagnostic__context">
{diagnostic.structure !== undefined ? (
<>
<dt>Structure</dt>
<dd>{diagnostic.structure}</dd>
</>
) : null}
{diagnostic.offset !== undefined ? (
<>
<dt>Offset</dt>
<dd>{formatOffset(diagnostic.offset)}</dd>
</>
) : null}
{diagnostic.propertyId !== undefined ? (
<>
<dt>Property</dt>
<dd>{diagnostic.propertyId}</dd>
</>
) : null}
</dl>
) : null}
</li>
))}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,70 @@
import { useRef, useState, type ChangeEvent, type DragEvent } from 'react';
interface FileDropZoneProps {
disabled?: boolean;
onFile(file: File): void;
}
export function FileDropZone({ disabled = false, onFile }: FileDropZoneProps) {
const input = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const selectFile = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) onFile(file);
};
const dropFile = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setDragging(false);
if (disabled) return;
const file = event.dataTransfer.files[0];
if (file) onFile(file);
};
return (
<div
className={`drop-zone${dragging ? ' drop-zone--active' : ''}`}
onDragEnter={(event) => {
event.preventDefault();
if (!disabled) setDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setDragging(false);
}
}}
onDrop={dropFile}
data-testid="file-drop-zone"
>
<p className="drop-zone__eyebrow">Private, local-first reader</p>
<h2>Open a OneNote file</h2>
<p>
Inspect a <code>.one</code> section or <code>.onepkg</code> notebook
package without sending it to a server.
</p>
<input
ref={input}
className="visually-hidden"
type="file"
accept=".one,.onepkg"
disabled={disabled}
onChange={selectFile}
aria-label="Choose a OneNote file"
/>
<button
type="button"
className="primary-button"
disabled={disabled}
onClick={() => input.current?.click()}
>
Choose OneNote file
</button>
<span className="drop-zone__hint">
or drop one .one or .onepkg file here
</span>
</div>
);
}

View File

@@ -0,0 +1,37 @@
interface HelpDialogProps {
onClose(): void;
}
export function HelpDialog({ onClose }: HelpDialogProps) {
return (
<div className="dialog-backdrop">
<section
className="help-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="help-title"
>
<h2 id="help-title">About OneNote Tools</h2>
<p>
Read the tested desktop revision-store .one subset and single-cabinet
LZX or uncompressed .onepkg packages with a native TypeScript parser.
Unsupported content remains visible as diagnostics instead of being
silently discarded.
</p>
<p>
FSSHTTP sections, MSZIP and Quantum packages, multi-cabinet archives,
notebook TOC ordering, visual fidelity, attachments, and export are
not supported in this release.
</p>
<p>
Nothing is uploaded, persisted, or sent to telemetry. Closing or
clearing releases application references and terminates the worker;
this is not a secure-erasure guarantee.
</p>
<button type="button" onClick={onClose}>
Close
</button>
</section>
</div>
);
}

View File

@@ -0,0 +1,93 @@
import type { OneNotePackageDto } from '../onenote/model/dto.js';
interface PackageInspectorProps {
notebook: OneNotePackageDto;
}
function formatBytes(value: number): string {
if (value < 1024) return `${value} B`;
const units = ['KiB', 'MiB', 'GiB'];
let amount = value / 1024;
let unit = units[0]!;
for (let index = 1; index < units.length && amount >= 1024; index += 1) {
amount /= 1024;
unit = units[index]!;
}
return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${unit}`;
}
export function PackageInspector({ notebook }: PackageInspectorProps) {
const totalExpanded = notebook.entries.reduce(
(total, entry) => total + entry.uncompressedSize,
0
);
const sectionCount = notebook.entries.filter(
(entry) => entry.kind === 'section'
).length;
return (
<section className="package-inspector" aria-labelledby="package-title">
<div className="panel-heading">
<div>
<p className="eyebrow">CAB package</p>
<h2 id="package-title">Package summary</h2>
</div>
</div>
<dl className="package-summary">
<div>
<dt>Source size</dt>
<dd>{formatBytes(notebook.sourceSize)}</dd>
</div>
<div>
<dt>Entries</dt>
<dd>{notebook.entries.length}</dd>
</div>
<div>
<dt>Sections</dt>
<dd>{sectionCount}</dd>
</div>
<div>
<dt>Expanded size</dt>
<dd>{formatBytes(totalExpanded)}</dd>
</div>
</dl>
<details className="entry-inspector">
<summary>Inspect {notebook.entries.length} package entries</summary>
<div className="entry-table-wrap">
<table className="entry-table">
<thead>
<tr>
<th scope="col">Safe path</th>
<th scope="col">Kind</th>
<th scope="col">Expanded</th>
<th scope="col">Compression</th>
<th scope="col">Parse</th>
</tr>
</thead>
<tbody>
{notebook.entries.map((entry) => (
<tr key={entry.normalizedPath}>
<td>
<code>{entry.normalizedPath}</code>
{entry.path !== entry.normalizedPath ? (
<small>Original: {entry.path}</small>
) : null}
</td>
<td>{entry.kind}</td>
<td>{formatBytes(entry.uncompressedSize)}</td>
<td>{entry.compressionMethod || 'unknown'}</td>
<td>
<span className={`status status--${entry.parseStatus}`}>
{entry.parseStatus}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</section>
);
}

View File

@@ -0,0 +1,107 @@
import type { OneNotePageDto } from '../onenote/model/dto.js';
interface PageReaderProps {
page?: OneNotePageDto;
loading: boolean;
error?: string;
}
function formatDate(value: string | undefined): string | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date);
}
export function PageReader({ page, loading, error }: PageReaderProps) {
if (loading) {
return (
<section className="page-reader page-reader--empty" aria-live="polite">
<p>Loading extracted page text</p>
</section>
);
}
if (error) {
return (
<section className="page-reader page-reader--error" role="alert">
<p className="eyebrow">Page unavailable</p>
<h2>Could not read this page</h2>
<p>{error}</p>
</section>
);
}
if (!page) {
return (
<section className="page-reader page-reader--empty">
<p>Select a readable page to inspect its extracted text.</p>
</section>
);
}
const createdAt = formatDate(page.createdAt);
const updatedAt = formatDate(page.updatedAt);
return (
<article className="page-reader" aria-labelledby="selected-page-title">
<header className="page-reader__header">
<p className="eyebrow">Selected page</p>
<h2 id="selected-page-title">{page.title || 'Untitled page'}</h2>
{createdAt || updatedAt ? (
<dl className="page-metadata">
{createdAt ? (
<>
<dt>Created</dt>
<dd>{createdAt}</dd>
</>
) : null}
{updatedAt ? (
<>
<dt>Modified</dt>
<dd>{updatedAt}</dd>
</>
) : null}
</dl>
) : null}
</header>
<div className="page-content">
{page.blocks.length === 0 && page.text.length === 0 ? (
<p className="empty-message">No readable plain text was extracted.</p>
) : page.blocks.length === 0 ? (
<p className="extracted-text">{page.text}</p>
) : (
page.blocks.map((block, index) => {
switch (block.kind) {
case 'text':
return (
<p className="extracted-text" key={index}>
{block.text}
</p>
);
case 'line-break':
return <br key={index} />;
case 'link':
return (
<p className="extracted-text" key={index}>
{block.text}
</p>
);
case 'unsupported':
return (
<aside className="unsupported-block" key={index}>
<strong>{block.sourceKind}</strong>
<span>{block.description}</span>
</aside>
);
}
})
)}
</div>
</article>
);
}

View File

@@ -0,0 +1,133 @@
import type {
OneNotePackagedSectionDto,
OneNotePageSummaryDto,
OneNoteSectionDto,
} from '../onenote/model/dto.js';
interface PageListProps {
pages: OneNotePageSummaryDto[];
selectedPageId?: string;
onSelectPage(pageId: string): void;
}
function PageList({ pages, selectedPageId, onSelectPage }: PageListProps) {
if (pages.length === 0) {
return <p className="navigation-empty">No pages were extracted.</p>;
}
return (
<ul className="page-list">
{pages.map((page) => (
<li
key={page.id}
style={{
paddingInlineStart: `${Math.min(page.level ?? 0, 8) * 0.7}rem`,
}}
>
<button
type="button"
className={page.id === selectedPageId ? 'is-selected' : undefined}
aria-current={page.id === selectedPageId ? 'page' : undefined}
onClick={() => onSelectPage(page.id)}
>
<strong>{page.title || 'Untitled page'}</strong>
{page.textPreview ? <span>{page.textPreview}</span> : null}
</button>
</li>
))}
</ul>
);
}
interface SingleSectionNavigationProps {
section: OneNoteSectionDto;
selectedPageId?: string;
onSelectPage(pageId: string): void;
}
export function SingleSectionNavigation({
section,
selectedPageId,
onSelectPage,
}: SingleSectionNavigationProps) {
return (
<nav className="source-navigation" aria-label="Section pages">
<div className="navigation-heading">
<p className="eyebrow">Section</p>
<h2>{section.sectionName || section.sourceName}</h2>
<p>{section.pages.length} pages</p>
</div>
<PageList
pages={section.pages}
selectedPageId={selectedPageId}
onSelectPage={onSelectPage}
/>
</nav>
);
}
interface PackageNavigationProps {
sections: OneNotePackagedSectionDto[];
selectedSectionId?: string;
selectedPageId?: string;
onSelectSection(sectionId: string): void;
onSelectPage(pageId: string): void;
}
export function PackageNavigation({
sections,
selectedSectionId,
selectedPageId,
onSelectSection,
onSelectPage,
}: PackageNavigationProps) {
const selected = sections.find((section) => section.id === selectedSectionId);
return (
<nav className="source-navigation" aria-label="Notebook sections and pages">
<div className="navigation-heading">
<p className="eyebrow">Sections</p>
<h2>Notebook contents</h2>
<p>Temporary normalized-path order</p>
</div>
{sections.length === 0 ? (
<p className="navigation-empty">No section entries were found.</p>
) : (
<ul className="section-list">
{sections.map((section) => (
<li key={section.id}>
<button
type="button"
aria-label={`${section.displayName}, ${section.parseStatus}`}
className={
section.id === selectedSectionId ? 'is-selected' : undefined
}
aria-current={
section.id === selectedSectionId ? 'true' : undefined
}
onClick={() => onSelectSection(section.id)}
>
<strong>{section.displayName}</strong>
<span className={`status status--${section.parseStatus}`}>
{section.parseStatus}
</span>
</button>
</li>
))}
</ul>
)}
{selected?.section ? (
<PageList
pages={selected.section.pages}
selectedPageId={selectedPageId}
onSelectPage={onSelectPage}
/>
) : selected ? (
<p className="navigation-empty">
This section did not produce a readable page list.
</p>
) : null}
</nav>
);
}

14
src/main.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.js';
import './styles.css';
const root = document.querySelector('#root');
if (!root) throw new Error('Missing #root element');
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -0,0 +1,158 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
import { OneNoteParserError } from '../parser/error.js';
/** A bounded, little-endian reader. Every child view remains inside its range. */
export class BinaryReader {
readonly bytes: Uint8Array;
readonly start: number;
readonly end: number;
readonly structure: string;
private cursor: number;
constructor(
bytes: Uint8Array,
start = 0,
length = bytes.byteLength - start,
structure = 'binary data'
) {
if (
!Number.isSafeInteger(start) ||
!Number.isSafeInteger(length) ||
start < 0 ||
length < 0 ||
start + length > bytes.byteLength
) {
throw new OneNoteParserError(
'BOUNDS_EXCEEDED',
`Invalid ${structure} range (${start}, ${length}) for ${bytes.byteLength} bytes`,
{ offset: start, structure }
);
}
this.bytes = bytes;
this.start = start;
this.end = start + length;
this.cursor = start;
this.structure = structure;
}
get offset(): number {
return this.cursor;
}
get relativeOffset(): number {
return this.cursor - this.start;
}
get remaining(): number {
return this.end - this.cursor;
}
seek(relativeOffset: number): void {
if (
!Number.isSafeInteger(relativeOffset) ||
relativeOffset < 0 ||
relativeOffset > this.end - this.start
) {
this.fail(`Invalid seek to relative offset ${relativeOffset}`);
}
this.cursor = this.start + relativeOffset;
}
skip(length: number): void {
this.ensure(length);
this.cursor += length;
}
u8(): number {
this.ensure(1);
return this.bytes[this.cursor++]!;
}
u16(): number {
this.ensure(2);
const value = this.dataView(2).getUint16(0, true);
this.cursor += 2;
return value;
}
u32(): number {
this.ensure(4);
const value = this.dataView(4).getUint32(0, true);
this.cursor += 4;
return value;
}
u64(): bigint {
this.ensure(8);
const value = this.dataView(8).getBigUint64(0, true);
this.cursor += 8;
return value;
}
read(length: number): Uint8Array {
this.ensure(length);
const value = this.bytes.subarray(this.cursor, this.cursor + length);
this.cursor += length;
return value;
}
viewAt(
absoluteOffset: number,
length: number,
structure = this.structure
): BinaryReader {
if (
!Number.isSafeInteger(absoluteOffset) ||
!Number.isSafeInteger(length) ||
absoluteOffset < 0 ||
length < 0 ||
absoluteOffset + length > this.bytes.byteLength
) {
throw new OneNoteParserError(
'BOUNDS_EXCEEDED',
`${structure} range (${absoluteOffset}, ${length}) exceeds ${this.bytes.byteLength} bytes`,
{ offset: absoluteOffset, structure }
);
}
return new BinaryReader(this.bytes, absoluteOffset, length, structure);
}
subview(length: number, structure = this.structure): BinaryReader {
this.ensure(length);
const view = new BinaryReader(this.bytes, this.cursor, length, structure);
this.cursor += length;
return view;
}
private ensure(length: number): void {
if (
!Number.isSafeInteger(length) ||
length < 0 ||
length > this.remaining
) {
this.fail(
`Tried to read ${String(length)} bytes with ${this.remaining} remaining`
);
}
}
private dataView(length: number): DataView {
return new DataView(
this.bytes.buffer,
this.bytes.byteOffset + this.cursor,
length
);
}
private fail(message: string): never {
throw new OneNoteParserError('BOUNDS_EXCEEDED', message, {
offset: this.cursor,
structure: this.structure,
});
}
}

View File

@@ -0,0 +1,28 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
import type { BinaryReader } from './BinaryReader.js';
const HEX = Array.from({ length: 256 }, (_, value) =>
value.toString(16).padStart(2, '0').toUpperCase()
);
/** Read the mixed-endian GUID layout used by OneStore. */
export function readGuid(reader: BinaryReader): string {
const bytes = reader.read(16);
const order = [3, 2, 1, 0, 5, 4, 7, 6, 8, 9, 10, 11, 12, 13, 14, 15] as const;
const h = order.map((index) => HEX[bytes[index]!]!);
return `{${h.slice(0, 4).join('')}-${h.slice(4, 6).join('')}-${h.slice(6, 8).join('')}-${h.slice(8, 10).join('')}-${h.slice(10).join('')}}`;
}
export function normalizeGuid(value: string): string {
const plain = value.replace(/[{}-]/g, '').toUpperCase();
if (!/^[0-9A-F]{32}$/.test(plain)) {
throw new TypeError(`Invalid GUID: ${value}`);
}
return `{${plain.slice(0, 8)}-${plain.slice(8, 12)}-${plain.slice(12, 16)}-${plain.slice(16, 20)}-${plain.slice(20)}}`;
}
export const NIL_GUID = '{00000000-0000-0000-0000-000000000000}';

View File

@@ -0,0 +1,35 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
import { OneNoteParserError } from '../parser/error.js';
const FILETIME_UNIX_EPOCH_TICKS = 116_444_736_000_000_000n;
const TICKS_PER_MILLISECOND = 10_000n;
const SECONDS_1980_TO_UNIX = 315_532_800;
export function filetimeToIso(value: bigint, structure: string): string {
const unixMilliseconds =
(value - FILETIME_UNIX_EPOCH_TICKS) / TICKS_PER_MILLISECOND;
const numeric = Number(unixMilliseconds);
if (!Number.isSafeInteger(numeric)) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`FILETIME is outside the supported date range: ${value}`,
{ structure }
);
}
const date = new Date(numeric);
if (Number.isNaN(date.getTime())) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Invalid FILETIME: ${value}`,
{ structure }
);
}
return date.toISOString();
}
export function oneNoteTimeToIso(value: number): string {
return new Date((value + SECONDS_1980_TO_UNIX) * 1000).toISOString();
}

15
src/onenote/index.ts Normal file
View File

@@ -0,0 +1,15 @@
export {
detectOneNoteFormat,
type OneNoteFormatDetection,
type OneNoteFormatKind,
} from './parser/detect-format.js';
export { OneNoteParserError } from './parser/error.js';
export {
DEFAULT_ONENOTE_PARSER_LIMITS,
type OneNoteParserLimits,
} from './parser/limits.js';
export {
parseOneNoteSection,
type ParseOneNoteSectionOptions,
} from './parser/parse-section.js';
export type { OneNotePageSummaryDto, OneNoteSectionDto } from './model/dto.js';

View File

@@ -0,0 +1,9 @@
export interface ParserDiagnostic {
severity: 'warning' | 'error';
code: string;
message: string;
offset?: number;
structure?: string;
propertyId?: string;
recoverable: boolean;
}

68
src/onenote/model/dto.ts Normal file
View File

@@ -0,0 +1,68 @@
import type { ParserDiagnostic } from './diagnostics.js';
export interface OneNotePageSummaryDto {
id: string;
title: string;
createdAt?: string;
updatedAt?: string;
level?: number;
/** Complete plain text extracted for this page. */
text: string;
textPreview: string;
}
export type OneNoteContentBlockDto =
| { kind: 'text'; text: string }
| { kind: 'line-break' }
| { kind: 'link'; text: string; href?: string }
| {
kind: 'unsupported';
sourceKind: string;
description: string;
};
export interface OneNotePageDto extends OneNotePageSummaryDto {
blocks: OneNoteContentBlockDto[];
diagnostics: ParserDiagnostic[];
}
export interface OneNoteSectionDto {
format: 'one';
sourceName: string;
sectionName?: string;
pages: OneNotePageSummaryDto[];
diagnostics: ParserDiagnostic[];
parserInfo: {
implementation: 'typescript';
supportedVariant: string;
referenceRevision: string;
};
}
export interface OneNotePackageEntryDto {
path: string;
normalizedPath: string;
kind: 'section' | 'table-of-contents' | 'other';
compressedSize?: number;
uncompressedSize: number;
compressionMethod?: string;
parseStatus: 'not-requested' | 'parsed' | 'unsupported' | 'failed';
}
export interface OneNotePackageDto {
format: 'onepkg';
sourceName: string;
sourceSize: number;
entries: OneNotePackageEntryDto[];
sections: OneNotePackagedSectionDto[];
diagnostics: ParserDiagnostic[];
}
export interface OneNotePackagedSectionDto {
id: string;
path: string;
displayName: string;
parseStatus: OneNotePackageEntryDto['parseStatus'];
section?: OneNoteSectionDto;
diagnostics: ParserDiagnostic[];
}

View File

@@ -0,0 +1,47 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
//
// IDs selected from MS-ONE and onenote.rs (MPL-2.0), revision
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onestore/shared/jcid.rs`,
// `one/property/mod.rs`, and `one/property_set/*.rs`.
export const JCID = {
SectionNode: 0x00060007,
PageSeriesNode: 0x00060008,
PageNode: 0x0006000b,
OutlineNode: 0x0006000c,
OutlineElementNode: 0x0006000d,
RichTextNode: 0x0006000e,
ImageNode: 0x00060011,
InkContainer: 0x00060014,
OutlineGroup: 0x00060019,
TableNode: 0x00060022,
TitleNode: 0x0006002c,
EmbeddedFileNode: 0x00060035,
PageManifestNode: 0x00060037,
PageMetadata: 0x00020030,
SectionMetadata: 0x00020031,
ParagraphStyleObject: 0x0012004d,
} as const;
export const PROPERTY = {
NotebookManagementEntityGuid: 0x1c001c30,
SectionDisplayName: 0x1c00349b,
ElementChildNodes: 0x24001c20,
ChildGraphSpaceElementNodes: 0x2c001d63,
MetaDataObjectsAboveGraphSpace: 0x24003442,
ContentChildNodes: 0x24001c1f,
StructureElementChildNodes: 0x24001d5f,
TopologyCreationTimeStamp: 0x18001c65,
LastModifiedTime: 0x14001d7a,
PageLevel: 0x14001dff,
CachedTitleString: 0x1c001cf3,
CachedTitleStringFromPage: 0x1c001d3c,
RichEditTextUnicode: 0x1c001c22,
TextExtendedAscii: 0x1c003498,
TextRunFormatting: 0x24001e13,
TextRunIndex: 0x1c001e12,
Hidden: 0x08001e16,
} as const;

661
src/onenote/one/section.ts Normal file
View File

@@ -0,0 +1,661 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
//
// Portions adapted from onenote.rs (MPL-2.0), revision
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onenote/{section,page_series,
// page,outline,rich_text}.rs` and the corresponding `one/property_set/*.rs`
// modules. Deviations: title/plain-text traversal only, bounded graph walking,
// serializable DTOs, and diagnostics for unsupported content.
import { BinaryReader } from '../binary/BinaryReader.js';
import { filetimeToIso, oneNoteTimeToIso } from '../binary/time.js';
import type { ParserDiagnostic } from '../model/diagnostics.js';
import type { OneNotePageSummaryDto, OneNoteSectionDto } from '../model/dto.js';
import type { OneNoteParserLimits } from '../parser/limits.js';
import {
countObjectReferences,
countObjectSpaceReferences,
findProperty,
referenceOffset,
} from '../onestore/property-set.js';
import type { DesktopOneStore } from '../onestore/desktop-store.js';
import type {
ExGuid,
ObjectSpace,
PropertyEntry,
StoreObject,
} from '../onestore/types.js';
import { exGuidKey } from '../onestore/types.js';
import { JCID, PROPERTY } from './constants.js';
interface OneContext {
store: DesktopOneStore;
limits: OneNoteParserLimits;
diagnostics: ParserDiagnostic[];
extractedChars: number;
currentPage?: string;
}
type PageWithTextDto = OneNotePageSummaryDto & { text: string };
export function buildSectionDto(
store: DesktopOneStore,
sourceName: string,
limits: OneNoteParserLimits,
diagnostics: ParserDiagnostic[]
): OneNoteSectionDto {
const context: OneContext = {
store,
limits,
diagnostics,
extractedChars: 0,
};
const root = store.rootObjectSpace;
const metadata = rootObject(root, 2, 'section metadata root');
assertJcid(metadata, JCID.SectionMetadata, 'SectionMetadata');
const displayName = optionalString(metadata, PROPERTY.SectionDisplayName);
const section = rootObject(root, 1, 'section content root');
assertJcid(section, JCID.SectionNode, 'SectionNode');
const pageSeriesIds = objectReferences(section, PROPERTY.ElementChildNodes);
const pages: PageWithTextDto[] = [];
for (const seriesId of pageSeriesIds) {
const series = getObject(root, seriesId, 'PageSeriesNode');
assertJcid(series, JCID.PageSeriesNode, 'PageSeriesNode');
const pageSpaceIds = objectSpaceReferences(
series,
PROPERTY.ChildGraphSpaceElementNodes
);
for (const pageSpaceId of pageSpaceIds) {
context.currentPage = undefined;
const pageSpace = store.objectSpaces.get(exGuidKey(pageSpaceId));
if (!pageSpace) {
addDiagnostic(
context,
'MISSING_PAGE_SPACE',
`Page object space ${exGuidKey(pageSpaceId)} is missing`,
'PageSeriesNode'
);
continue;
}
try {
if (pages.length >= limits.maxPages) {
addDiagnostic(
context,
'PAGE_LIMIT_REACHED',
`Page count exceeds ${limits.maxPages}`,
'PageSeriesNode',
'error'
);
break;
}
pages.push(parsePage(context, pageSpace));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
addDiagnostic(
context,
'PAGE_PARSE_FAILED',
`Could not parse page ${exGuidKey(pageSpaceId)}: ${message}`,
'Page',
'error'
);
}
}
if (pages.length >= limits.maxPages) break;
}
return {
format: 'one',
sourceName,
sectionName: displayName ?? stripOneExtension(sourceName),
pages,
diagnostics,
parserInfo: {
implementation: 'typescript',
supportedVariant: 'desktop-revision-store',
referenceRevision: 'onenote.rs@5138a39a3f4e72b840932f9872fecde52fa9da60',
},
};
}
function parsePage(context: OneContext, space: ObjectSpace): PageWithTextDto {
const metadata = rootObject(space, 2, 'page metadata root');
assertJcid(metadata, JCID.PageMetadata, 'PageMetadata');
const pageGuid = requiredGuid(
metadata,
PROPERTY.NotebookManagementEntityGuid,
'PageMetadata entity GUID'
);
context.currentPage = pageGuid;
const created = optionalU64(metadata, PROPERTY.TopologyCreationTimeStamp);
const level = optionalU32(metadata, PROPERTY.PageLevel);
const cachedMetadataTitle = optionalString(
metadata,
PROPERTY.CachedTitleString
);
const manifest = rootObject(space, 1, 'page content root');
assertJcid(manifest, JCID.PageManifestNode, 'PageManifestNode');
const pageId = objectReferences(manifest, PROPERTY.ContentChildNodes)[0];
if (!pageId) throw new Error('PageManifestNode has no page reference');
const page = getObject(space, pageId, 'PageNode');
assertJcid(page, JCID.PageNode, 'PageNode');
const updated = optionalU32(page, PROPERTY.LastModifiedTime);
const cachedPageTitle = optionalString(
page,
PROPERTY.CachedTitleStringFromPage
);
const titleId = objectReferences(
page,
PROPERTY.StructureElementChildNodes
)[0];
const contentIds = objectReferences(page, PROPERTY.ElementChildNodes);
let titleText: string | undefined;
if (titleId) {
try {
const title = getObject(space, titleId, 'TitleNode');
assertJcid(title, JCID.TitleNode, 'TitleNode');
const titleOutlines = objectReferences(title, PROPERTY.ElementChildNodes);
for (const outlineId of titleOutlines) {
const paragraphs = extractGraphTextSafely(
context,
space,
outlineId,
'title'
);
titleText = paragraphs.map(cleanTitle).find((item) => item.length > 0);
if (titleText) break;
}
} catch (error) {
addDiagnostic(
context,
'TITLE_PARSE_FAILED',
error instanceof Error ? error.message : String(error),
'TitleNode'
);
}
}
const bodyParagraphs: string[] = [];
for (const contentId of contentIds) {
bodyParagraphs.push(
...extractGraphTextSafely(context, space, contentId, 'page content')
);
}
let text = bodyParagraphs
.map((paragraph) => paragraph.split('\u000b').join('\n').trimEnd())
.filter((paragraph) => paragraph.length > 0)
.join('\n');
const remaining =
context.limits.maxExtractedTextChars - context.extractedChars;
if (text.length > remaining) {
text = text.slice(0, Math.max(0, remaining));
addDiagnostic(
context,
'TEXT_LIMIT_REACHED',
`Extracted text was truncated at ${context.limits.maxExtractedTextChars} characters`,
`Page ${pageGuid}`
);
}
context.extractedChars += text.length;
const bodyFallback = bodyParagraphs
.map(cleanTitle)
.find((item) => item.length > 0);
const title =
titleText ??
bodyFallback ??
cachedPageTitle ??
cachedMetadataTitle ??
'Untitled page';
return {
id: pageGuid,
title,
createdAt:
created === undefined ? undefined : safeFiletime(context, created),
updatedAt: updated === undefined ? undefined : oneNoteTimeToIso(updated),
level,
text,
textPreview: preview(text),
};
}
function extractGraphTextSafely(
context: OneContext,
space: ObjectSpace,
id: ExGuid,
label: string
): string[] {
try {
return extractGraphText(context, space, id, new Set(), 0);
} catch (error) {
addDiagnostic(
context,
'CONTENT_PARSE_FAILED',
`Could not extract ${label} ${exGuidKey(id)}: ${error instanceof Error ? error.message : String(error)}`,
'MS-ONE content graph'
);
return [];
}
}
function extractGraphText(
context: OneContext,
space: ObjectSpace,
id: ExGuid,
active: Set<string>,
depth: number
): string[] {
if (depth > context.limits.maxGraphDepth) {
addDiagnostic(
context,
'GRAPH_DEPTH_LIMIT',
`Content graph depth exceeds ${context.limits.maxGraphDepth}`,
'MS-ONE content graph'
);
return [];
}
const key = exGuidKey(id);
if (active.has(key)) {
addDiagnostic(
context,
'CONTENT_GRAPH_CYCLE',
`Cycle detected at object ${key}`,
'MS-ONE content graph'
);
return [];
}
const object = space.objects.get(key);
if (!object) {
addDiagnostic(
context,
'MISSING_CONTENT_OBJECT',
`Content object ${key} is missing`,
'MS-ONE content graph'
);
return [];
}
active.add(key);
try {
if (object.jcid === JCID.RichTextNode) {
const text = richText(context, space, object);
return text === undefined ? [] : [text];
}
if (object.jcid === JCID.OutlineNode || object.jcid === JCID.OutlineGroup) {
return traverseReferences(
context,
space,
objectReferences(object, PROPERTY.ElementChildNodes),
active,
depth
);
}
if (object.jcid === JCID.OutlineElementNode) {
const contents = traverseReferences(
context,
space,
objectReferences(object, PROPERTY.ContentChildNodes),
active,
depth
);
const children = traverseReferences(
context,
space,
objectReferences(object, PROPERTY.ElementChildNodes),
active,
depth
);
return [...contents, ...children];
}
addDiagnostic(
context,
unsupportedCode(object.jcid),
`Content object JCID 0x${object.jcid.toString(16)} is not yet rendered`,
`Object ${key}`
);
return [];
} finally {
active.delete(key);
}
}
function traverseReferences(
context: OneContext,
space: ObjectSpace,
ids: ExGuid[],
active: Set<string>,
depth: number
): string[] {
const result: string[] = [];
for (const id of ids) {
result.push(...extractGraphText(context, space, id, active, depth + 1));
}
return result;
}
function richText(
context: OneContext,
space: ObjectSpace,
object: StoreObject
): string | undefined {
let text = optionalString(object, PROPERTY.RichEditTextUnicode);
if (text === undefined)
text = optionalLatin1(object, PROPERTY.TextExtendedAscii);
if (text === undefined) return undefined;
const styles = objectReferences(object, PROPERTY.TextRunFormatting);
if (styles.length === 0) return text.split('\0').join('');
const indices = optionalU32Vector(object, PROPERTY.TextRunIndex) ?? [];
const hidden = styles.map((styleId) => {
const style = space.objects.get(exGuidKey(styleId));
if (!style) {
addDiagnostic(
context,
'MISSING_TEXT_STYLE',
`Text-run style ${exGuidKey(styleId)} is missing`,
'RichTextNode'
);
return false;
}
return optionalBool(style, PROPERTY.Hidden) ?? false;
});
const boundaries = [0, ...indices];
if (boundaries.length === hidden.length) boundaries.push(text.length);
let visible = '';
for (let index = 0; index < hidden.length; index += 1) {
if (hidden[index]) continue;
const start = Math.min(boundaries[index] ?? text.length, text.length);
const end = Math.min(boundaries[index + 1] ?? text.length, text.length);
if (start < end) visible += text.slice(start, end);
}
// OneNote producers sometimes retain UTF-16 terminators inside text vectors.
return visible.split('\0').join('');
}
function objectReferences(object: StoreObject, propertyId: number): ExGuid[] {
const property = findProperty(object.props, propertyId);
if (!property) return [];
let count: number;
if (property.value.kind === 'objectId') count = 1;
else if (property.value.kind === 'objectIds') count = property.value.count;
else
throw new Error(
`Property 0x${propertyId.toString(16)} is not an object reference`
);
const offset = referenceOffset(
object.props,
propertyId,
countObjectReferences
);
return resolveReferenceSlice(object, object.props.objectIds, offset, count);
}
function objectSpaceReferences(
object: StoreObject,
propertyId: number
): ExGuid[] {
const property = findProperty(object.props, propertyId);
if (!property) return [];
let count: number;
if (property.value.kind === 'objectSpaceId') count = 1;
else if (property.value.kind === 'objectSpaceIds')
count = property.value.count;
else {
throw new Error(
`Property 0x${propertyId.toString(16)} is not an object-space reference`
);
}
const offset = referenceOffset(
object.props,
propertyId,
countObjectSpaceReferences
);
return resolveReferenceSlice(
object,
object.props.objectSpaceIds,
offset,
count
);
}
function resolveReferenceSlice(
object: StoreObject,
ids: { guidIndex: number; value: number }[],
offset: number,
count: number
): ExGuid[] {
if (offset + count > ids.length) {
throw new Error(
`Reference range ${offset}+${count} exceeds stream length ${ids.length}`
);
}
return ids.slice(offset, offset + count).map((id) => {
const guid = object.mapping.get(id.guidIndex);
if (!guid) throw new Error(`GUID mapping ${id.guidIndex} is missing`);
return { guid, value: id.value };
});
}
function rootObject(
space: ObjectSpace,
role: number,
structure: string
): StoreObject {
const id = space.roots.get(role);
if (!id) throw new Error(`Object space has no ${structure}`);
return getObject(space, id, structure);
}
function getObject(
space: ObjectSpace,
id: ExGuid,
structure: string
): StoreObject {
const object = space.objects.get(exGuidKey(id));
if (!object)
throw new Error(`${structure} object ${exGuidKey(id)} is missing`);
return object;
}
function assertJcid(
object: StoreObject,
expected: number,
structure: string
): void {
if (object.jcid !== expected) {
throw new Error(
`${structure} has JCID 0x${object.jcid.toString(16)}, expected 0x${expected.toString(16)}`
);
}
}
function optionalEntry(
object: StoreObject,
propertyId: number
): PropertyEntry | undefined {
return findProperty(object.props, propertyId);
}
function optionalString(
object: StoreObject,
propertyId: number
): string | undefined {
const entry = optionalEntry(object, propertyId);
if (!entry) return undefined;
if (entry.value.kind !== 'bytes') {
throw new Error(
`Property 0x${propertyId.toString(16)} is not a byte vector`
);
}
if (entry.value.value.byteLength % 2 !== 0) {
throw new Error(
`UTF-16 property 0x${propertyId.toString(16)} has odd length`
);
}
return new TextDecoder('utf-16le', { fatal: true }).decode(entry.value.value);
}
function optionalLatin1(
object: StoreObject,
propertyId: number
): string | undefined {
const entry = optionalEntry(object, propertyId);
if (!entry) return undefined;
if (entry.value.kind !== 'bytes') {
throw new Error(
`Property 0x${propertyId.toString(16)} is not a byte vector`
);
}
let result = '';
for (const value of entry.value.value) result += String.fromCharCode(value);
return result;
}
function requiredGuid(
object: StoreObject,
propertyId: number,
structure: string
): string {
const entry = optionalEntry(object, propertyId);
if (
!entry ||
entry.value.kind !== 'bytes' ||
entry.value.value.length !== 16
) {
throw new Error(`${structure} is absent or not 16 bytes`);
}
const reader = new BinaryReader(entry.value.value, 0, 16, structure);
const bytes = reader.read(16);
const order = [3, 2, 1, 0, 5, 4, 7, 6, 8, 9, 10, 11, 12, 13, 14, 15];
const hex = order.map((index) => bytes[index]!.toString(16).padStart(2, '0'));
return `{${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}}`.toUpperCase();
}
function optionalBool(
object: StoreObject,
propertyId: number
): boolean | undefined {
const value = optionalEntry(object, propertyId)?.value;
if (!value) return undefined;
if (value.kind !== 'bool') throw new Error('Expected a Boolean property');
return value.value;
}
function optionalU32(
object: StoreObject,
propertyId: number
): number | undefined {
const value = optionalEntry(object, propertyId)?.value;
if (!value) return undefined;
if (value.kind !== 'u32') throw new Error('Expected a u32 property');
return value.value;
}
function optionalU64(
object: StoreObject,
propertyId: number
): bigint | undefined {
const value = optionalEntry(object, propertyId)?.value;
if (!value) return undefined;
if (value.kind !== 'u64') throw new Error('Expected a u64 property');
return value.value;
}
function optionalU32Vector(
object: StoreObject,
propertyId: number
): number[] | undefined {
const value = optionalEntry(object, propertyId)?.value;
if (!value) return undefined;
if (value.kind !== 'bytes' || value.value.byteLength % 4 !== 0) {
throw new Error('Expected a u32 vector property');
}
const reader = new BinaryReader(
value.value,
0,
value.value.byteLength,
'u32 vector'
);
const result: number[] = [];
while (reader.remaining > 0) result.push(reader.u32());
return result;
}
function safeFiletime(context: OneContext, value: bigint): string | undefined {
try {
return filetimeToIso(value, 'PageMetadata creation time');
} catch (error) {
addDiagnostic(
context,
'INVALID_PAGE_TIMESTAMP',
error instanceof Error ? error.message : String(error),
'PageMetadata'
);
return undefined;
}
}
function cleanTitle(text: string): string {
const marker = '\ufddfHYPERLINK "';
let result = text;
while (result.startsWith('\u000b')) result = result.slice(1);
result = result.trim();
let start = result.indexOf(marker);
while (start >= 0) {
const valueStart = start + marker.length;
const quote = result.indexOf('"', valueStart);
result =
quote < 0
? result.slice(0, start)
: result.slice(0, start) + result.slice(quote + 1);
start = result.indexOf(marker);
}
return result.trim();
}
function preview(text: string): string {
const compact = text.replace(/\s+/g, ' ').trim();
return compact.length <= 500 ? compact : `${compact.slice(0, 497)}`;
}
function unsupportedCode(jcid: number): string {
if (jcid === JCID.ImageNode) return 'UNSUPPORTED_IMAGE';
if (jcid === JCID.TableNode) return 'UNSUPPORTED_TABLE';
if (jcid === JCID.EmbeddedFileNode) return 'UNSUPPORTED_EMBEDDED_FILE';
if (jcid === JCID.InkContainer) return 'UNSUPPORTED_INK';
return 'UNSUPPORTED_CONTENT_OBJECT';
}
function addDiagnostic(
context: OneContext,
code: string,
message: string,
structure: string,
severity: 'warning' | 'error' = 'warning'
): void {
if (context.diagnostics.length >= context.limits.maxDiagnostics) return;
context.diagnostics.push({
severity,
code,
message,
structure: context.currentPage
? `${structure} (page ${context.currentPage})`
: structure,
recoverable: true,
});
}
function stripOneExtension(filename: string): string {
return (filename.split(/[\\/]/).at(-1) ?? filename).replace(/\.one$/i, '');
}

View File

@@ -0,0 +1,86 @@
import { fail } from './errors.js';
export class BinaryReader {
readonly bytes: Uint8Array;
readonly end: number;
offset: number;
private readonly view: DataView;
constructor(bytes: Uint8Array, offset = 0, end = bytes.byteLength) {
if (offset < 0 || end < offset || end > bytes.byteLength) {
throw new RangeError('Invalid BinaryReader range');
}
this.bytes = bytes;
this.offset = offset;
this.end = end;
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
}
ensure(length: number, structure: string): void {
if (
!Number.isSafeInteger(length) ||
length < 0 ||
this.offset > this.end - length
) {
fail(
'cab-unexpected-eof',
`Unexpected end of cabinet while reading ${structure}.`,
{ offset: this.offset, structure }
);
}
}
readU8(structure: string): number {
this.ensure(1, structure);
return this.bytes[this.offset++]!;
}
readU16(structure: string): number {
this.ensure(2, structure);
const value = this.view.getUint16(this.offset, true);
this.offset += 2;
return value;
}
readU32(structure: string): number {
this.ensure(4, structure);
const value = this.view.getUint32(this.offset, true);
this.offset += 4;
return value;
}
readBytes(length: number, structure: string): Uint8Array {
this.ensure(length, structure);
const result = this.bytes.subarray(this.offset, this.offset + length);
this.offset += length;
return result;
}
skip(length: number, structure: string): void {
this.ensure(length, structure);
this.offset += length;
}
readNullTerminatedBytes(maxBytes: number, structure: string): Uint8Array {
const start = this.offset;
while (this.offset < this.end && this.bytes[this.offset] !== 0) {
if (this.offset - start >= maxBytes) {
fail(
'cab-name-too-long',
`Cabinet string exceeds the configured ${maxBytes}-byte limit.`,
{ offset: start, structure }
);
}
this.offset += 1;
}
if (this.offset >= this.end) {
fail('cab-unterminated-string', 'Cabinet string is not NUL-terminated.', {
offset: start,
structure,
});
}
const value = this.bytes.subarray(start, this.offset);
this.offset += 1;
return value;
}
}

View File

@@ -0,0 +1,200 @@
import { describe, expect, it } from 'vitest';
import type { OneNoteSectionDto } from '../model/dto.js';
import { enumerateCabinet, extractCabinet } from './cabinet.js';
import { OnePkgError } from './errors.js';
import { LzxDecoder } from './lzx/decoder.js';
import { openOneNotePackage } from './package.js';
/*
* These byte-for-byte CAB fixtures are copied from rust-cab's MIT-licensed
* `src/cabinet.rs` tests at commit
* c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2.
* Copyright (c) 2017 Matthew D. Steele.
*/
function binary(value: string): Uint8Array {
return Uint8Array.from(value, (character) => character.charCodeAt(0));
}
const UNCOMPRESSED_CAB = binary(
'MSCF\0\0\0\0\x59\0\0\0\0\0\0\0' +
'\x2c\0\0\0\0\0\0\0\x03\x01\x01\0\x01\0\0\0\x34\x12\0\0' +
'\x43\0\0\0\x01\0\0\0' +
'\x0e\0\0\0\0\0\0\0\0\0\x6c\x22\xba\x59\x01\0hi.txt\0' +
'\x4c\x1a\x2e\x7f\x0e\0\x0e\0Hello, world!\n'
);
const TWO_FILE_UNCOMPRESSED_CAB = binary(
'MSCF\0\0\0\0\x80\0\0\0\0\0\0\0' +
'\x2c\0\0\0\0\0\0\0\x03\x01\x01\0\x02\0\0\0\x34\x12\0\0' +
'\x5b\0\0\0\x01\0\0\0' +
'\x0e\0\0\0\0\0\0\0\0\0\x6c\x22\xe7\x59\x01\0hi.txt\0' +
'\x0f\0\0\0\x0e\0\0\0\0\0\x6c\x22\xe7\x59\x01\0bye.txt\0' +
'\0\0\0\0\x1d\0\x1d\0Hello, world!\nSee you later!\n'
);
const LZX_CAB = binary(
'\x4d\x53\x43\x46\x00\x00\x00\x00\x97\x00\x00\x00\x00\x00\x00' +
'\x00\x2c\x00\x00\x00\x00\x00\x00\x00\x03\x01\x01\x00\x02\x00' +
'\x00\x00\x2d\x05\x00\x00\x5b\x00\x00\x00\x01\x00\x03\x13\x0f' +
'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x21\x53\x0d\xb2\x20\x00' +
'\x68\x69\x2e\x74\x78\x74\x00\x10\x00\x00\x00\x0f\x00\x00\x00' +
'\x00\x00\x21\x53\x0b\xb2\x20\x00\x62\x79\x65\x2e\x74\x78\x74' +
'\x00\x5c\xef\x2a\xc7\x34\x00\x1f\x00\x5b\x80\x80\x8d\x00\x30' +
'\xf0\x01\x10\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x48' +
'\x65\x6c\x6c\x6f\x2c\x20\x77\x6f\x72\x6c\x64\x21\x0d\x0a\x53' +
'\x65\x65\x20\x79\x6f\x75\x20\x6c\x61\x74\x65\x72\x21\x0d\x0a' +
'\x00'
);
function findAscii(bytes: Uint8Array, value: string): number {
const needle = new TextEncoder().encode(value);
outer: for (
let offset = 0;
offset <= bytes.length - needle.length;
offset += 1
) {
for (let index = 0; index < needle.length; index += 1) {
if (bytes[offset + index] !== needle[index]) continue outer;
}
return offset;
}
throw new Error(`Could not find ${value}`);
}
function expectCode(action: () => unknown, code: string): void {
try {
action();
} catch (error) {
expect(error).toBeInstanceOf(OnePkgError);
expect((error as OnePkgError).diagnostic.code).toBe(code);
return;
}
throw new Error(`Expected OnePkgError ${code}`);
}
describe('CAB enumeration and extraction', () => {
it('enumerates and extracts rust-cab uncompressed data', async () => {
const listing = enumerateCabinet(UNCOMPRESSED_CAB);
expect(listing.entries).toHaveLength(1);
expect(listing.entries[0]).toMatchObject({
normalizedPath: 'hi.txt',
uncompressedSize: 14,
compression: { kind: 'none' },
});
const extracted = await extractCabinet(UNCOMPRESSED_CAB);
expect(new TextDecoder().decode(extracted.extractedEntries[0]!.bytes)).toBe(
'Hello, world!\n'
);
});
it('extracts the rust-cab LZX 0x1303 fixture', async () => {
const listing = enumerateCabinet(LZX_CAB);
expect(listing.folders[0]!.compression).toMatchObject({
kind: 'lzx',
raw: 0x1303,
windowBits: 19,
});
const extracted = await extractCabinet(LZX_CAB);
expect(
extracted.extractedEntries.map((entry) => [
entry.normalizedPath,
new TextDecoder().decode(entry.bytes),
])
).toEqual([
['hi.txt', 'Hello, world!\r\n'],
['bye.txt', 'See you later!\r\n'],
]);
});
it('retains an uncompressed LZX block across CAB chunks', () => {
const decoder = new LzxDecoder(18, { maxOperations: 100 });
const first = binary(
'\x00\x30\x70\x00' + '\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00abc'
);
expect(new TextDecoder().decode(decoder.decompressNext(first, 3))).toBe(
'abc'
);
expect(
new TextDecoder().decode(decoder.decompressNext(binary('defg'), 4))
).toBe('defg');
decoder.finish();
});
it('rejects checksum corruption', async () => {
const corrupted = UNCOMPRESSED_CAB.slice();
corrupted[corrupted.length - 1] = corrupted[corrupted.length - 1]! ^ 1;
await expect(extractCabinet(corrupted)).rejects.toMatchObject({
diagnostic: { code: 'cab-checksum-mismatch' },
});
});
it('rejects traversal paths and case-insensitive duplicate paths', () => {
const traversal = UNCOMPRESSED_CAB.slice();
traversal.set(binary('../x.x'), findAscii(traversal, 'hi.txt'));
expectCode(() => enumerateCabinet(traversal), 'cab-path-invalid-segment');
const duplicate = TWO_FILE_UNCOMPRESSED_CAB.slice();
duplicate.set(binary('HI.TXT\0\0'), findAscii(duplicate, 'bye.txt'));
expectCode(() => enumerateCabinet(duplicate), 'cab-duplicate-path');
});
it('reports an out-of-range folder data offset as a CAB diagnostic', () => {
const malformed = UNCOMPRESSED_CAB.slice();
new DataView(
malformed.buffer,
malformed.byteOffset,
malformed.byteLength
).setUint32(36, malformed.length + 1, true);
expectCode(() => enumerateCabinet(malformed), 'cab-invalid-data-offset');
});
it('enforces compression-ratio limits and cancellation', () => {
expectCode(
() =>
enumerateCabinet(UNCOMPRESSED_CAB, {
limits: { maxCompressionRatio: 0.5 },
}),
'cab-compression-ratio-limit'
);
expectCode(
() =>
enumerateCabinet(UNCOMPRESSED_CAB, {
cancellation: { isCancelled: () => true },
}),
'onepkg-cancelled'
);
});
it('keeps section parsing behind the injected callback boundary', async () => {
const oneCab = UNCOMPRESSED_CAB.slice();
oneCab.set(binary('hi.one'), findAscii(oneCab, 'hi.txt'));
const section: OneNoteSectionDto = {
format: 'one',
sourceName: 'hi.one',
pages: [],
diagnostics: [],
parserInfo: {
implementation: 'typescript',
supportedVariant: 'test',
referenceRevision: 'test',
},
};
const result = await openOneNotePackage(oneCab, {
sourceName: 'fixture.onepkg',
parseSection: ({ normalizedPath, bytes }) => {
expect(normalizedPath).toBe('hi.one');
expect(bytes).toHaveLength(14);
return { status: 'parsed', value: section };
},
});
expect(result.package).toMatchObject({
sourceName: 'fixture.onepkg',
sourceSize: oneCab.length,
entries: [{ parseStatus: 'parsed' }],
sections: [{ path: 'hi.one', section }],
});
});
});

View File

@@ -0,0 +1,727 @@
/*
* SPDX-License-Identifier: MIT
*
* CAB structure parsing and extraction translated from rust-cab 0.6.0.
* rust-cab commit c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2
* Copyright (c) 2017 Matthew D. Steele, MIT License.
* Modified for immutable browser Uint8Array input, strict archive-path handling,
* explicit resource limits, cancellation, and whole-folder extraction.
*/
import { BinaryReader } from './binary-reader.js';
import { checkCancellation, yieldForCancellation } from './cancellation.js';
import { calculateDataBlockChecksum } from './checksum.js';
import { fail, warning } from './errors.js';
import { LzxDecoder } from './lzx/decoder.js';
import { decodeCabFileName, normalizeCabPath } from './path.js';
import {
resolveLimits,
type CabCompressionMethod,
type CabEnumerationResult,
type CabExtractionResult,
type CabFileEntry,
type CabOpenOptions,
type OnePkgLimits,
} from './types.js';
const CAB_SIGNATURE = 0x4643_534d;
const FLAG_PREVIOUS_CABINET = 0x0001;
const FLAG_NEXT_CABINET = 0x0002;
const FLAG_RESERVE_PRESENT = 0x0004;
const ATTRIBUTE_NAME_IS_UTF8 = 0x0080;
const MAX_CAB_BLOCK_OUTPUT = 32 * 1024;
interface CabDataBlock {
headerOffset: number;
checksum: number;
compressedSize: number;
uncompressedSize: number;
reserve: Uint8Array;
dataOffset: number;
}
interface CabFolderInternal {
index: number;
firstDataBlockOffset: number;
dataBlockCount: number;
compression: CabCompressionMethod;
blocks: CabDataBlock[];
compressedSize: number;
uncompressedSize: number;
dataEnd: number;
}
interface ParsedCabinet {
bytes: Uint8Array;
limits: OnePkgLimits;
folders: CabFolderInternal[];
result: CabEnumerationResult;
}
function checkedAdd(
left: number,
right: number,
code: string,
structure: string
): number {
const sum = left + right;
if (!Number.isSafeInteger(sum)) {
fail(code, `Integer overflow while reading ${structure}.`, { structure });
}
return sum;
}
function parseCompression(
raw: number,
limits: OnePkgLimits,
offset: number
): CabCompressionMethod {
const kind = raw & 0x000f;
if (kind === 0) {
if (raw !== 0) {
fail(
'cab-invalid-compression',
`Invalid uncompressed type 0x${raw.toString(16)}.`,
{
offset,
structure: 'CFFOLDER.typeCompress',
}
);
}
return { kind: 'none', raw, label: 'None' };
}
if (kind === 1) {
if (raw !== 1) {
fail(
'cab-invalid-compression',
`Invalid MSZIP type 0x${raw.toString(16)}.`,
{
offset,
structure: 'CFFOLDER.typeCompress',
}
);
}
return { kind: 'mszip', raw, label: 'MSZIP' };
}
if (kind === 2) {
const level = (raw & 0x00f0) >>> 4;
const memoryBits = (raw & 0x1f00) >>> 8;
if (level < 1 || level > 7 || memoryBits < 10 || memoryBits > 21) {
fail(
'cab-invalid-compression',
`Invalid Quantum type 0x${raw.toString(16)}.`,
{
offset,
structure: 'CFFOLDER.typeCompress',
}
);
}
return { kind: 'quantum', raw, label: 'Quantum', level, memoryBits };
}
if (kind === 3) {
if ((raw & ~0x1f0f) !== 0) {
fail(
'cab-invalid-compression',
`Invalid LZX type 0x${raw.toString(16)}.`,
{
offset,
structure: 'CFFOLDER.typeCompress',
}
);
}
const windowBits = (raw & 0x1f00) >>> 8;
if (windowBits < 15 || windowBits > 25) {
fail('cab-invalid-lzx-window', `Invalid LZX window 2^${windowBits}.`, {
offset,
structure: 'CFFOLDER.typeCompress',
});
}
const windowBytes = 2 ** windowBits;
if (windowBytes > limits.maxLzxWindowBytes) {
fail(
'cab-lzx-window-limit',
`LZX window ${windowBytes} exceeds the configured ${limits.maxLzxWindowBytes}-byte limit.`,
{ offset, structure: 'CFFOLDER.typeCompress' }
);
}
return { kind: 'lzx', raw, label: 'LZX', windowBits, windowBytes };
}
fail(
'cab-invalid-compression',
`Unknown CAB compression type 0x${raw.toString(16)}.`,
{
offset,
structure: 'CFFOLDER.typeCompress',
}
);
}
function parseCabinet(
bytes: Uint8Array,
options: CabOpenOptions
): ParsedCabinet {
if (!(bytes instanceof Uint8Array)) {
throw new TypeError('CAB input must be a Uint8Array');
}
const limits = resolveLimits(options.limits);
if (bytes.byteLength > limits.maxCabinetBytes) {
fail(
'cab-input-limit',
`Cabinet is ${bytes.byteLength} bytes; configured limit is ${limits.maxCabinetBytes}.`,
{ structure: 'CFHEADER' }
);
}
checkCancellation(options.cancellation);
const header = new BinaryReader(bytes);
if (header.readU32('CFHEADER.signature') !== CAB_SIGNATURE) {
fail(
'cab-invalid-signature',
'Input does not begin with the MSCF CAB signature.',
{
offset: 0,
structure: 'CFHEADER.signature',
}
);
}
const reserved1 = header.readU32('CFHEADER.reserved1');
const declaredSize = header.readU32('CFHEADER.cbCabinet');
const reserved2 = header.readU32('CFHEADER.reserved2');
const firstFileOffset = header.readU32('CFHEADER.coffFiles');
const reserved3 = header.readU32('CFHEADER.reserved3');
const minorVersion = header.readU8('CFHEADER.versionMinor');
const majorVersion = header.readU8('CFHEADER.versionMajor');
const folderCount = header.readU16('CFHEADER.cFolders');
const fileCount = header.readU16('CFHEADER.cFiles');
const flags = header.readU16('CFHEADER.flags');
const cabinetSetId = header.readU16('CFHEADER.setID');
const cabinetSetIndex = header.readU16('CFHEADER.iCabinet');
if (declaredSize < 36 || declaredSize > bytes.byteLength) {
fail(
'cab-invalid-size',
`Declared cabinet size ${declaredSize} is outside the available ${bytes.byteLength} bytes.`,
{ offset: 8, structure: 'CFHEADER.cbCabinet' }
);
}
if (declaredSize > limits.maxCabinetBytes) {
fail(
'cab-input-limit',
'Declared cabinet size exceeds the configured limit.',
{
offset: 8,
structure: 'CFHEADER.cbCabinet',
}
);
}
if (majorVersion > 1 || (majorVersion === 1 && minorVersion > 3)) {
fail(
'cab-version-unsupported',
`CAB version ${majorVersion}.${minorVersion} is not supported.`,
{ offset: 24, structure: 'CFHEADER.version' }
);
}
if ((flags & ~0x0007) !== 0) {
fail(
'cab-invalid-flags',
`Unknown CAB header flags 0x${flags.toString(16)}.`,
{
offset: 30,
structure: 'CFHEADER.flags',
}
);
}
if ((flags & (FLAG_PREVIOUS_CABINET | FLAG_NEXT_CABINET)) !== 0) {
fail(
'cab-multipart-unsupported',
'Multi-cabinet archives are not supported for local .onepkg files.',
{ offset: 30, structure: 'CFHEADER.flags' }
);
}
if (folderCount > limits.maxFolders || fileCount > limits.maxFiles) {
fail(
'cab-entry-count-limit',
`Cabinet contains ${folderCount} folders and ${fileCount} files, exceeding configured limits.`,
{ structure: 'CFHEADER' }
);
}
// Re-bind reads to the trusted cbCabinet boundary before parsing variable data.
const reader = new BinaryReader(bytes, header.offset, declaredSize);
let folderReserveSize = 0;
let dataReserveSize = 0;
if ((flags & FLAG_RESERVE_PRESENT) !== 0) {
const headerReserveSize = reader.readU16('CFHEADER.cbCFHeader');
folderReserveSize = reader.readU8('CFHEADER.cbCFFolder');
dataReserveSize = reader.readU8('CFHEADER.cbCFData');
reader.skip(headerReserveSize, 'CFHEADER.abReserve');
}
const folders: CabFolderInternal[] = [];
let totalDataBlocks = 0;
for (let index = 0; index < folderCount; index += 1) {
if ((index & 0xff) === 0) checkCancellation(options.cancellation);
const folderOffset = reader.offset;
const firstDataBlockOffset = reader.readU32('CFFOLDER.coffCabStart');
const dataBlockCount = reader.readU16('CFFOLDER.cCFData');
const compressionRawOffset = reader.offset;
const compression = parseCompression(
reader.readU16('CFFOLDER.typeCompress'),
limits,
compressionRawOffset
);
reader.skip(folderReserveSize, 'CFFOLDER.abReserve');
totalDataBlocks = checkedAdd(
totalDataBlocks,
dataBlockCount,
'cab-data-block-count-overflow',
'CFFOLDER.cCFData'
);
if (totalDataBlocks > limits.maxDataBlocks) {
fail(
'cab-data-block-count-limit',
`Cabinet exceeds the configured ${limits.maxDataBlocks}-block limit.`,
{ offset: folderOffset, structure: 'CFFOLDER.cCFData' }
);
}
folders.push({
index,
firstDataBlockOffset,
dataBlockCount,
compression,
blocks: [],
compressedSize: 0,
uncompressedSize: 0,
dataEnd: firstDataBlockOffset,
});
}
if (firstFileOffset < reader.offset || firstFileOffset >= declaredSize) {
fail('cab-invalid-file-table-offset', 'CAB file table offset is invalid.', {
offset: 16,
structure: 'CFHEADER.coffFiles',
});
}
const fileReader = new BinaryReader(bytes, firstFileOffset, declaredSize);
const entries: CabFileEntry[] = [];
const diagnostics = [];
const duplicatePaths = new Set<string>();
let extractedByteTotal = 0;
for (let index = 0; index < fileCount; index += 1) {
if ((index & 0xff) === 0) checkCancellation(options.cancellation);
const entryOffset = fileReader.offset;
const uncompressedSize = fileReader.readU32('CFFILE.cbFile');
const uncompressedOffset = fileReader.readU32('CFFILE.uoffFolderStart');
const folderIndex = fileReader.readU16('CFFILE.iFolder');
fileReader.readU16('CFFILE.date');
fileReader.readU16('CFFILE.time');
const attributes = fileReader.readU16('CFFILE.attribs');
const nameOffset = fileReader.offset;
const nameBytes = fileReader.readNullTerminatedBytes(
limits.maxNameBytes,
'CFFILE.szName'
);
if (folderIndex >= 0xfffd) {
fail(
'cab-multipart-file-unsupported',
'CAB file spans another cabinet; multi-cabinet files are unsupported.',
{ offset: entryOffset + 8, structure: 'CFFILE.iFolder' }
);
}
const folder = folders[folderIndex];
if (folder === undefined) {
fail(
'cab-folder-index-out-of-range',
'CAB file folder index is out of range.',
{
offset: entryOffset + 8,
structure: 'CFFILE.iFolder',
}
);
}
if (uncompressedSize > limits.maxFileBytes) {
fail(
'cab-file-size-limit',
`CAB file size ${uncompressedSize} exceeds the configured ${limits.maxFileBytes}-byte limit.`,
{ offset: entryOffset, structure: 'CFFILE.cbFile' }
);
}
extractedByteTotal = checkedAdd(
extractedByteTotal,
uncompressedSize,
'cab-extracted-size-overflow',
'CFFILE.cbFile'
);
if (extractedByteTotal > limits.maxExtractedBytes) {
fail(
'cab-extracted-size-limit',
`Extracted files exceed the configured ${limits.maxExtractedBytes}-byte limit.`,
{ offset: entryOffset, structure: 'CFFILE.cbFile' }
);
}
const path = decodeCabFileName(
nameBytes,
(attributes & ATTRIBUTE_NAME_IS_UTF8) !== 0,
nameOffset
);
if (
(attributes & ATTRIBUTE_NAME_IS_UTF8) === 0 &&
nameBytes.some((byte) => byte > 0x7f)
) {
diagnostics.push(
warning(
'cab-unmarked-utf8-name',
`Decoded unmarked non-ASCII CAB filename as strict UTF-8: ${path}`,
{ offset: nameOffset, structure: 'CFFILE.szName' }
)
);
}
const normalized = normalizeCabPath(path, limits, nameOffset);
if (duplicatePaths.has(normalized.duplicateKey)) {
fail(
'cab-duplicate-path',
`CAB contains a duplicate normalized path: ${normalized.normalizedPath}`,
{ offset: nameOffset, structure: 'CFFILE.szName' }
);
}
duplicatePaths.add(normalized.duplicateKey);
entries.push({
index,
path: normalized.path,
normalizedPath: normalized.normalizedPath,
folderIndex,
uncompressedOffset,
uncompressedSize,
attributes,
compression: folder.compression,
});
}
const metadataEnd = fileReader.offset;
let totalFolderOutput = 0;
let totalLzxOutput = 0;
for (const folder of folders) {
if (folder.firstDataBlockOffset > declaredSize) {
fail(
'cab-invalid-data-offset',
'CAB folder data offset is beyond the declared cabinet.',
{
offset: folder.firstDataBlockOffset,
structure: 'CFFOLDER.coffCabStart',
}
);
}
if (
folder.dataBlockCount > 0 &&
folder.firstDataBlockOffset < metadataEnd
) {
fail(
'cab-data-overlaps-metadata',
'CAB data blocks overlap the file table.',
{
offset: folder.firstDataBlockOffset,
structure: 'CFFOLDER.coffCabStart',
}
);
}
const blockReader = new BinaryReader(
bytes,
folder.firstDataBlockOffset,
declaredSize
);
for (
let blockIndex = 0;
blockIndex < folder.dataBlockCount;
blockIndex += 1
) {
if ((blockIndex & 0xff) === 0) checkCancellation(options.cancellation);
const headerOffset = blockReader.offset;
const checksum = blockReader.readU32('CFDATA.csum');
const compressedSize = blockReader.readU16('CFDATA.cbData');
const uncompressedSize = blockReader.readU16('CFDATA.cbUncomp');
const reserve = blockReader.readBytes(
dataReserveSize,
'CFDATA.abReserve'
);
const dataOffset = blockReader.offset;
blockReader.skip(compressedSize, 'CFDATA.ab');
if (
compressedSize === 0 ||
uncompressedSize === 0 ||
uncompressedSize > MAX_CAB_BLOCK_OUTPUT
) {
fail(
'cab-invalid-data-block-size',
`Invalid CAB data block sizes ${compressedSize}/${uncompressedSize}.`,
{ offset: headerOffset + 4, structure: 'CFDATA' }
);
}
if (
folder.compression.kind === 'none' &&
compressedSize !== uncompressedSize
) {
fail(
'cab-uncompressed-size-mismatch',
'Uncompressed CAB block has different compressed and output sizes.',
{ offset: headerOffset + 4, structure: 'CFDATA' }
);
}
folder.compressedSize = checkedAdd(
folder.compressedSize,
compressedSize,
'cab-folder-size-overflow',
'CFDATA.cbData'
);
folder.uncompressedSize = checkedAdd(
folder.uncompressedSize,
uncompressedSize,
'cab-folder-size-overflow',
'CFDATA.cbUncomp'
);
if (folder.uncompressedSize > limits.maxFolderUncompressedBytes) {
fail(
'cab-folder-size-limit',
`CAB folder exceeds the configured ${limits.maxFolderUncompressedBytes}-byte output limit.`,
{ offset: headerOffset + 6, structure: 'CFDATA.cbUncomp' }
);
}
folder.blocks.push({
headerOffset,
checksum,
compressedSize,
uncompressedSize,
reserve,
dataOffset,
});
}
folder.dataEnd = blockReader.offset;
if (
folder.uncompressedSize / folder.compressedSize >
limits.maxCompressionRatio
) {
fail(
'cab-compression-ratio-limit',
`CAB folder compression ratio exceeds the configured ${limits.maxCompressionRatio}:1 limit.`,
{ offset: folder.firstDataBlockOffset, structure: 'CFFOLDER/CFDATA' }
);
}
totalFolderOutput = checkedAdd(
totalFolderOutput,
folder.uncompressedSize,
'cab-total-size-overflow',
'CFDATA.cbUncomp'
);
if (folder.compression.kind === 'lzx') {
totalLzxOutput = checkedAdd(
totalLzxOutput,
folder.uncompressedSize,
'lzx-operation-limit',
'CFDATA.cbUncomp'
);
}
}
if (totalFolderOutput > limits.maxTotalUncompressedBytes) {
fail(
'cab-total-size-limit',
`CAB output exceeds the configured ${limits.maxTotalUncompressedBytes}-byte limit.`,
{ structure: 'CFDATA.cbUncomp' }
);
}
if (totalLzxOutput > limits.maxDecodeOperations) {
fail(
'lzx-operation-limit',
`LZX output exceeds the configured ${limits.maxDecodeOperations}-operation limit.`,
{ structure: 'CFDATA.cbUncomp' }
);
}
const dataRanges = folders
.filter((folder) => folder.dataBlockCount > 0)
.map((folder) => ({
start: folder.firstDataBlockOffset,
end: folder.dataEnd,
}))
.sort((left, right) => left.start - right.start);
for (let index = 1; index < dataRanges.length; index += 1) {
if (dataRanges[index]!.start < dataRanges[index - 1]!.end) {
fail('cab-overlapping-folders', 'CAB folder data ranges overlap.', {
offset: dataRanges[index]!.start,
structure: 'CFFOLDER.coffCabStart',
});
}
}
for (const entry of entries) {
const folder = folders[entry.folderIndex]!;
const fileEnd = checkedAdd(
entry.uncompressedOffset,
entry.uncompressedSize,
'cab-file-range-overflow',
'CFFILE'
);
if (fileEnd > folder.uncompressedSize) {
fail(
'cab-file-range-out-of-bounds',
`CAB file ${entry.normalizedPath} extends beyond its folder data.`,
{ structure: 'CFFILE' }
);
}
}
if (bytes.byteLength > declaredSize) {
diagnostics.push(
warning(
'cab-trailing-data',
`Ignored ${bytes.byteLength - declaredSize} bytes after the declared cabinet.`,
{ offset: declaredSize, structure: 'CFHEADER.cbCabinet' }
)
);
}
if (reserved1 !== 0 || reserved2 !== 0 || reserved3 !== 0) {
diagnostics.push(
warning(
'cab-reserved-header-data',
'CAB header contains non-zero reserved fields.',
{ structure: 'CFHEADER' }
)
);
}
for (const folder of folders) {
if (
folder.compression.kind === 'mszip' ||
folder.compression.kind === 'quantum'
) {
diagnostics.push(
warning(
'cab-compression-unsupported',
`${folder.compression.label} metadata can be listed, but this build cannot extract it.`,
{ structure: `CFFOLDER[${folder.index}].typeCompress` }
)
);
}
}
return {
bytes,
limits,
folders,
result: {
cabinetSetId,
cabinetSetIndex,
declaredSize,
entries,
folders: folders.map((folder) => ({
index: folder.index,
dataBlockCount: folder.dataBlockCount,
compressedSize: folder.compressedSize,
uncompressedSize: folder.uncompressedSize,
compression: folder.compression,
})),
diagnostics,
},
};
}
export function enumerateCabinet(
bytes: Uint8Array,
options: CabOpenOptions = {}
): CabEnumerationResult {
return parseCabinet(bytes, options).result;
}
export async function extractCabinet(
bytes: Uint8Array,
options: CabOpenOptions = {}
): Promise<CabExtractionResult> {
const parsed = parseCabinet(bytes, options);
const byFolder = new Map<number, CabFileEntry[]>();
for (const entry of parsed.result.entries) {
const current = byFolder.get(entry.folderIndex);
if (current === undefined) byFolder.set(entry.folderIndex, [entry]);
else current.push(entry);
}
const extractedEntries = [];
for (const folder of parsed.folders) {
checkCancellation(options.cancellation);
if (
folder.compression.kind === 'mszip' ||
folder.compression.kind === 'quantum'
) {
fail(
'cab-compression-unsupported',
`${folder.compression.label} extraction is not implemented.`,
{ structure: `CFFOLDER[${folder.index}].typeCompress` }
);
}
const folderOutput = new Uint8Array(folder.uncompressedSize);
const decoder =
folder.compression.kind === 'lzx'
? new LzxDecoder(folder.compression.windowBits, {
maxOperations: parsed.limits.maxDecodeOperations,
checkCancellation: () => checkCancellation(options.cancellation),
})
: undefined;
let outputOffset = 0;
for (const block of folder.blocks) {
checkCancellation(options.cancellation);
const compressed = parsed.bytes.subarray(
block.dataOffset,
block.dataOffset + block.compressedSize
);
if (block.checksum !== 0) {
const actual = calculateDataBlockChecksum(
block.reserve,
compressed,
block.compressedSize,
block.uncompressedSize
);
if (actual !== block.checksum) {
fail(
'cab-checksum-mismatch',
`CAB data block checksum mismatch: expected 0x${block.checksum.toString(16).padStart(8, '0')}, got 0x${actual.toString(16).padStart(8, '0')}.`,
{ offset: block.headerOffset, structure: 'CFDATA.csum' }
);
}
}
const output =
decoder === undefined
? compressed
: decoder.decompressNext(compressed, block.uncompressedSize);
if (output.length !== block.uncompressedSize) {
fail(
'cab-output-size-mismatch',
`CAB decoder returned ${output.length} bytes; expected ${block.uncompressedSize}.`,
{ offset: block.headerOffset + 6, structure: 'CFDATA.cbUncomp' }
);
}
folderOutput.set(output, outputOffset);
outputOffset += output.length;
await yieldForCancellation(options.cancellation);
}
decoder?.finish();
if (outputOffset !== folderOutput.length) {
fail(
'cab-folder-output-size-mismatch',
'CAB folder output is incomplete.',
{
structure: `CFFOLDER[${folder.index}]`,
}
);
}
for (const entry of byFolder.get(folder.index) ?? []) {
const start = entry.uncompressedOffset;
extractedEntries.push({
...entry,
bytes: folderOutput.slice(start, start + entry.uncompressedSize),
});
}
}
extractedEntries.sort((left, right) => left.index - right.index);
return { ...parsed.result, extractedEntries };
}

View File

@@ -0,0 +1,20 @@
import { fail } from './errors.js';
import type { OnePkgCancellation } from './types.js';
export function checkCancellation(
cancellation: OnePkgCancellation | undefined
): void {
if (cancellation?.signal?.aborted || cancellation?.isCancelled?.()) {
fail('onepkg-cancelled', 'Opening the OneNote package was cancelled.', {
structure: 'onepkg',
});
}
}
export async function yieldForCancellation(
cancellation: OnePkgCancellation | undefined
): Promise<void> {
checkCancellation(cancellation);
await cancellation?.yield?.();
checkCancellation(cancellation);
}

View File

@@ -0,0 +1,68 @@
/*
* SPDX-License-Identifier: MIT
*
* Direct TypeScript translation of rust-cab's checksum state machine.
* rust-cab commit c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2
* Copyright (c) 2017 Matthew D. Steele, MIT License.
* Modified for bounded Uint8Array input and CAB CFDATA verification.
*/
export class CabChecksum {
private checksum = 0;
private remainder = 0;
private remainderShift = 0;
update(bytes: Uint8Array): void {
for (const byte of bytes) {
this.remainder = (this.remainder | (byte << this.remainderShift)) >>> 0;
if (this.remainderShift === 24) {
this.checksum = (this.checksum ^ this.remainder) >>> 0;
this.remainder = 0;
this.remainderShift = 0;
} else {
this.remainderShift += 8;
}
}
}
value(): number {
switch (this.remainderShift) {
case 0:
return this.checksum >>> 0;
case 8:
return (this.checksum ^ this.remainder) >>> 0;
case 16:
return (
(this.checksum ^
(this.remainder >>> 8) ^
((this.remainder & 0xff) << 8)) >>>
0
);
case 24:
return (
(this.checksum ^
(this.remainder >>> 16) ^
(this.remainder & 0xff00) ^
((this.remainder & 0xff) << 16)) >>>
0
);
default:
throw new Error('Invalid CAB checksum state');
}
}
}
export function calculateDataBlockChecksum(
reserve: Uint8Array,
compressed: Uint8Array,
compressedSize: number,
uncompressedSize: number
): number {
const checksum = new CabChecksum();
checksum.update(reserve);
checksum.update(compressed);
return (
(checksum.value() ^ ((compressedSize | (uncompressedSize << 16)) >>> 0)) >>>
0
);
}

View File

@@ -0,0 +1,64 @@
import type { ParserDiagnostic } from '../model/diagnostics.js';
export class OnePkgError extends Error {
readonly diagnostic: ParserDiagnostic;
constructor(diagnostic: ParserDiagnostic, options?: ErrorOptions) {
super(diagnostic.message, options);
this.name = 'OnePkgError';
this.diagnostic = diagnostic;
}
}
export interface DiagnosticLocation {
offset?: number;
structure?: string;
}
export function fail(
code: string,
message: string,
location: DiagnosticLocation = {}
): never {
throw new OnePkgError({
severity: 'error',
code,
message,
...location,
recoverable: false,
});
}
export function warning(
code: string,
message: string,
location: DiagnosticLocation = {}
): ParserDiagnostic {
return {
severity: 'warning',
code,
message,
...location,
recoverable: true,
};
}
export function diagnosticFromUnknown(
error: unknown,
code: string,
message: string,
structure?: string
): ParserDiagnostic {
if (error instanceof OnePkgError) {
return error.diagnostic;
}
const detail = error instanceof Error ? `: ${error.message}` : '';
return {
severity: 'error',
code,
message: `${message}${detail}`,
structure,
recoverable: true,
};
}

View File

@@ -0,0 +1,23 @@
export { enumerateCabinet, extractCabinet } from './cabinet.js';
export { OnePkgError } from './errors.js';
export { openOneNotePackage } from './package.js';
export type {
OneNotePackageOpenOptions,
OneNotePackageOpenResult,
OneNotePackageSectionParseOutcome,
OneNotePackageSectionParseRequest,
OneNotePackageSectionParser,
ParsedOneNotePackageSection,
} from './package.js';
export {
DEFAULT_ONEPKG_LIMITS,
type CabCompressionMethod,
type CabEnumerationResult,
type CabExtractionResult,
type CabFileEntry,
type CabFolderSummary,
type CabOpenOptions,
type ExtractedCabFile,
type OnePkgCancellation,
type OnePkgLimits,
} from './types.js';

View File

@@ -0,0 +1,114 @@
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { parseOneNoteSection } from '../parser/parse-section.js';
import { enumerateCabinet, extractCabinet } from './cabinet.js';
import { openOneNotePackage } from './package.js';
/*
* Joplin CLI test fixture, AGPL-3.0-or-later. See tests/fixtures/README.md.
* Pinned upstream revision: 1e73aad7eb08fde5d9e4cb533df40052a5cd32d7
*/
const encodedFixture = readFileSync(
resolve(process.cwd(), 'tests/fixtures/joplin-test.onepkg.base64'),
'utf8'
);
const JOPLIN_ONEPKG = Uint8Array.from(
Buffer.from(encodedFixture.replaceAll(/\s/gu, ''), 'base64')
);
const ONE_MAGIC = Uint8Array.from([
0xe4, 0x52, 0x5c, 0x7b, 0x8c, 0xd8, 0xa7, 0x4d, 0xae, 0xb1, 0x53, 0x78, 0xd0,
0x29, 0x96, 0xd3,
]);
const EXPECTED_PATHS = [
'Another section.one',
'Open Notebook.onetoc2',
'Section group/A.one',
'Section group/B.one',
'Section group/Open Notebook.onetoc2',
'Tést!.one',
'⅀⸨ Unicode ⸩.one',
];
describe('pinned Joplin .onepkg fixture', () => {
it('retains the reviewed upstream bytes', () => {
expect(JOPLIN_ONEPKG).toHaveLength(11_066);
expect(createHash('sha256').update(JOPLIN_ONEPKG).digest('hex')).toBe(
'83cb622d40f0ab127038b629553b6926be3e2a6ce642f169df0e2614c13e8469'
);
});
it('enumerates all seven UTF-8/nested entries using LZX 0x1203', () => {
const listing = enumerateCabinet(JOPLIN_ONEPKG);
expect(listing.entries.map((entry) => entry.normalizedPath)).toEqual(
EXPECTED_PATHS
);
expect(listing.folders).toHaveLength(1);
expect(listing.folders[0]).toMatchObject({
dataBlockCount: 3,
compression: { kind: 'lzx', raw: 0x1203, windowBits: 18 },
});
expect(
listing.diagnostics.filter(
(diagnostic) => diagnostic.code === 'cab-unmarked-utf8-name'
)
).toHaveLength(2);
});
it('extracts every entry and preserves native .one headers', async () => {
const extraction = await extractCabinet(JOPLIN_ONEPKG);
expect(
extraction.extractedEntries.map((entry) => entry.normalizedPath)
).toEqual(EXPECTED_PATHS);
const sections = extraction.extractedEntries.filter((entry) =>
entry.normalizedPath.toLowerCase().endsWith('.one')
);
expect(sections).toHaveLength(5);
for (const section of sections) {
expect(section.bytes.subarray(0, ONE_MAGIC.length)).toEqual(ONE_MAGIC);
}
const opened = await openOneNotePackage(JOPLIN_ONEPKG, {
sourceName: 'test.onepkg',
});
expect(opened.package.entries).toHaveLength(7);
expect(opened.package.sections).toHaveLength(5);
expect(
opened.package.sections.every(
(section) => section.parseStatus === 'not-requested'
)
).toBe(true);
});
it('opens and parses all five packaged sections end to end', async () => {
const opened = await openOneNotePackage(JOPLIN_ONEPKG, {
sourceName: 'test.onepkg',
parseSection: ({ bytes, normalizedPath }) => ({
status: 'parsed',
value: parseOneNoteSection(bytes, { sourceName: normalizedPath }),
}),
});
expect(opened.package.sections).toHaveLength(5);
expect(
opened.package.sections.map((section) => section.parseStatus)
).toEqual(['parsed', 'parsed', 'parsed', 'parsed', 'parsed']);
expect(
opened.package.entries.some((entry) => entry.parseStatus === 'failed')
).toBe(false);
const testSection = opened.package.sections.find(
(section) => section.path === 'Tést!.one'
);
expect(testSection?.section?.sectionName).toBe('Tést!');
expect(testSection?.section?.pages[0]).toMatchObject({
title: 'Testing…',
text: 'Link to page: Page 2',
});
});
});

View File

@@ -0,0 +1,141 @@
/*
* SPDX-License-Identifier: MIT
*
* Direct TypeScript port of lzxd 0.2.5 `bitstream.rs`.
* lzxd commit 4748e43594e3e30cff2ace3a6ad7a376c9816fdd
* Copyright (c) 2020 Lonami, MIT OR Apache-2.0 (MIT selected here).
* Modified to use checked browser-native Uint8Array reads.
*/
import { fail } from '../errors.js';
export class LzxBitReader {
private byteOffset = 0;
private word = 0;
private bitsRemaining = 0;
constructor(private readonly bytes: Uint8Array) {}
get offset(): number {
return this.byteOffset;
}
get remainingRawBytes(): number {
return this.bytes.length - this.byteOffset;
}
private loadWord(): void {
if (this.byteOffset > this.bytes.length - 2) {
fail('lzx-unexpected-eof', 'Unexpected end of LZX chunk.', {
offset: this.byteOffset,
structure: 'LZX bitstream',
});
}
this.word =
this.bytes[this.byteOffset]! | (this.bytes[this.byteOffset + 1]! << 8);
this.byteOffset += 2;
this.bitsRemaining = 16;
}
readBits(bitCount: number): number {
if (!Number.isInteger(bitCount) || bitCount < 0 || bitCount > 32) {
throw new RangeError('LZX bit reads must contain between 0 and 32 bits');
}
let result = 0;
let remaining = bitCount;
while (remaining > 0) {
if (this.bitsRemaining === 0) {
this.loadWord();
}
const take = Math.min(remaining, this.bitsRemaining);
const shift = this.bitsRemaining - take;
const part = (this.word >>> shift) & (2 ** take - 1);
result = result * 2 ** take + part;
this.bitsRemaining -= take;
remaining -= take;
}
return result;
}
readBit(): number {
return this.readBits(1);
}
/**
* Huffman lookup may look beyond the end of a chunk. The reference decoder
* pads that look-ahead with zeroes; consuming the actual code remains strict.
*/
peekBits(bitCount: number): number {
if (!Number.isInteger(bitCount) || bitCount < 0 || bitCount > 32) {
throw new RangeError('LZX bit peeks must contain between 0 and 32 bits');
}
let offset = this.byteOffset;
let word = this.word;
let wordRemaining = this.bitsRemaining;
let result = 0;
let remaining = bitCount;
while (remaining > 0) {
if (wordRemaining === 0) {
if (offset > this.bytes.length - 2) {
word = 0;
} else {
word = this.bytes[offset]! | (this.bytes[offset + 1]! << 8);
offset += 2;
}
wordRemaining = 16;
}
const take = Math.min(remaining, wordRemaining);
const shift = wordRemaining - take;
result = result * 2 ** take + ((word >>> shift) & (2 ** take - 1));
wordRemaining -= take;
remaining -= take;
}
return result;
}
readU32LittleEndian(): number {
const low = this.readBits(16);
const high = this.readBits(16);
return (low + high * 0x1_0000) >>> 0;
}
readU24BigEndianBits(): number {
return this.readBits(16) * 0x100 + this.readBits(8);
}
/** Align exactly as lzxd 0.2.5 does before an uncompressed block. */
alignToWord(): void {
if (this.bitsRemaining === 0) {
this.readBits(16);
} else {
this.bitsRemaining = 0;
}
}
readRawByte(): number {
if (this.byteOffset >= this.bytes.length) {
fail('lzx-unexpected-eof', 'Missing LZX uncompressed-block padding.', {
offset: this.byteOffset,
structure: 'LZX uncompressed block',
});
}
return this.bytes[this.byteOffset++]!;
}
readRaw(length: number): Uint8Array {
if (length < 0 || this.byteOffset > this.bytes.length - length) {
fail('lzx-unexpected-eof', 'Unexpected end of LZX raw block.', {
offset: this.byteOffset,
structure: 'LZX uncompressed block',
});
}
const value = this.bytes.subarray(
this.byteOffset,
this.byteOffset + length
);
this.byteOffset += length;
return value;
}
}

View File

@@ -0,0 +1,430 @@
/*
* SPDX-License-Identifier: MIT
*
* Direct TypeScript port of lzxd 0.2.5 `lib.rs`, `block.rs`, and `window.rs`.
* lzxd commit 4748e43594e3e30cff2ace3a6ad7a376c9816fdd
* Copyright (c) 2020 Lonami, MIT OR Apache-2.0 (MIT selected here).
* Modified for CAB chunks, bounded allocation/operations, cancellation, and
* strict errors instead of assertions.
*/
import { fail } from '../errors.js';
import { LzxBitReader } from './bit-reader.js';
import { LzxCanonicalTree, LzxHuffmanTree } from './huffman.js';
const MAX_CHUNK_SIZE = 32 * 1024;
const POSITION_SLOTS: Readonly<Record<number, number>> = {
15: 30,
16: 32,
17: 34,
18: 36,
19: 38,
20: 42,
21: 50,
22: 66,
23: 98,
24: 162,
25: 290,
};
interface VerbatimBlock {
kind: 'verbatim';
size: number;
remaining: number;
mainTree: LzxHuffmanTree;
lengthTree?: LzxHuffmanTree;
}
interface AlignedBlock {
kind: 'aligned';
size: number;
remaining: number;
alignedTree: LzxHuffmanTree;
mainTree: LzxHuffmanTree;
lengthTree?: LzxHuffmanTree;
}
interface UncompressedBlock {
kind: 'uncompressed';
size: number;
remaining: number;
}
type LzxBlock = VerbatimBlock | AlignedBlock | UncompressedBlock;
export interface LzxDecoderOptions {
maxOperations: number;
checkCancellation?: () => void;
}
function footerBits(positionSlot: number): number {
if (positionSlot < 4) return 0;
if (positionSlot >= 36) return 17;
return Math.floor((positionSlot - 2) / 2);
}
function basePosition(positionSlot: number): number {
if (positionSlot < 4) return positionSlot;
if (positionSlot >= 36) return (positionSlot - 34) * 0x2_0000;
return (2 + (positionSlot & 1)) * 2 ** footerBits(positionSlot);
}
export class LzxDecoder {
private readonly window: Uint8Array;
private readonly windowMask: number;
private windowPosition = 0;
private readonly mainTree: LzxCanonicalTree;
private readonly lengthTree = new LzxCanonicalTree(249);
private readonly positionSlotCount: number;
private repeatedOffsets = [1, 1, 1];
private firstChunkRead = false;
private currentBlock?: LzxBlock;
private e8TranslationSize?: number;
private totalOutput = 0;
private operations = 0;
private nextCancellationCheck = 4_096;
constructor(
windowBits: number,
private readonly options: LzxDecoderOptions
) {
const positionSlotCount = POSITION_SLOTS[windowBits];
if (positionSlotCount === undefined) {
fail(
'lzx-invalid-window',
`Unsupported LZX window size 2^${windowBits}.`,
{
structure: 'CFFOLDER.typeCompress',
}
);
}
const windowSize = 2 ** windowBits;
this.window = new Uint8Array(windowSize);
this.windowMask = windowSize - 1;
this.positionSlotCount = positionSlotCount;
this.mainTree = new LzxCanonicalTree(256 + 8 * positionSlotCount);
}
decompressNext(chunk: Uint8Array, outputLength: number): Uint8Array {
if (
!Number.isInteger(outputLength) ||
outputLength <= 0 ||
outputLength > MAX_CHUNK_SIZE
) {
fail(
'lzx-invalid-chunk-size',
`LZX CAB chunk output size ${outputLength} is outside 1..${MAX_CHUNK_SIZE}.`,
{ structure: 'CFDATA.cbUncomp' }
);
}
const reader = new LzxBitReader(chunk);
if (!this.firstChunkRead) {
this.firstChunkRead = true;
if (reader.readBit() !== 0) {
this.e8TranslationSize = reader.readBits(32) | 0;
}
}
const output = new Uint8Array(outputLength);
let outputOffset = 0;
while (outputOffset < output.length) {
this.checkWork();
if (
this.currentBlock === undefined ||
this.currentBlock.remaining === 0
) {
if (
this.currentBlock?.kind === 'uncompressed' &&
this.currentBlock.size % 2 !== 0
) {
reader.readRawByte();
}
this.currentBlock = this.readBlock(reader);
}
const block = this.currentBlock;
if (block.kind === 'uncompressed') {
// An LZX uncompressed block may continue in the next CAB CFDATA
// chunk. Consume only the raw bytes present in this compressed chunk.
const length = Math.min(
block.remaining,
output.length - outputOffset,
reader.remainingRawBytes
);
if (length === 0) {
fail(
'lzx-unexpected-eof',
'LZX chunk ended before producing its declared CAB output size.',
{ offset: reader.offset, structure: 'LZX uncompressed block' }
);
}
const raw = reader.readRaw(length);
for (let index = 0; index < raw.length; index += 1) {
this.writeByte(raw[index]!, output, outputOffset + index);
}
outputOffset += length;
this.advanceBlock(block, length);
continue;
}
const mainElement = block.mainTree.decode(reader);
if (mainElement < 256) {
this.ensureAdvance(block, output, outputOffset, 1);
this.writeByte(mainElement, output, outputOffset);
outputOffset += 1;
this.advanceBlock(block, 1);
continue;
}
const lengthHeader = (mainElement - 256) & 7;
const matchLength =
lengthHeader === 7
? (block.lengthTree ?? this.missingLengthTree()).decode(reader) + 9
: lengthHeader + 2;
const positionSlot = (mainElement - 256) >>> 3;
if (positionSlot >= this.positionSlotCount) {
fail(
'lzx-invalid-position-slot',
'LZX match position is out of range.',
{
offset: reader.offset,
structure: 'LZX token',
}
);
}
const matchOffset = this.decodeMatchOffset(
reader,
positionSlot,
block.kind === 'aligned' ? block.alignedTree : undefined
);
this.ensureAdvance(block, output, outputOffset, matchLength);
if (
matchOffset <= 0 ||
matchOffset > this.window.length ||
matchOffset > this.totalOutput + outputOffset
) {
fail(
'lzx-invalid-match-offset',
`LZX match offset ${matchOffset} is outside the populated window.`,
{ offset: reader.offset, structure: 'LZX token' }
);
}
for (let index = 0; index < matchLength; index += 1) {
const value =
this.window[
(this.windowPosition - matchOffset + this.window.length) &
this.windowMask
]!;
this.writeByte(value, output, outputOffset + index);
}
outputOffset += matchLength;
this.advanceBlock(block, matchLength);
}
const chunkOffset = this.totalOutput;
this.totalOutput += output.length;
if (
this.e8TranslationSize !== undefined &&
chunkOffset < 0x4000_0000 &&
output.length > 10
) {
this.postprocessE8(output, chunkOffset, this.e8TranslationSize);
}
return output;
}
finish(): void {
if (this.currentBlock !== undefined && this.currentBlock.remaining !== 0) {
fail(
'lzx-incomplete-block',
`LZX stream ended with ${this.currentBlock.remaining} uncompressed block bytes missing.`,
{ structure: 'LZX block' }
);
}
}
private readBlock(reader: LzxBitReader): LzxBlock {
const kind = reader.readBits(3);
const size = reader.readU24BigEndianBits();
if (size === 0) {
fail('lzx-invalid-block-size', 'LZX block size cannot be zero.', {
offset: reader.offset,
structure: 'LZX block header',
});
}
if (kind === 1 || kind === 2) {
let alignedTree: LzxHuffmanTree | undefined;
if (kind === 2) {
const alignedLengths = new Uint8Array(8);
for (let index = 0; index < alignedLengths.length; index += 1) {
alignedLengths[index] = reader.readBits(3);
}
alignedTree = LzxHuffmanTree.fromPathLengths(alignedLengths);
}
this.mainTree.updateWithPretree(reader, 0, 256);
this.mainTree.updateWithPretree(
reader,
256,
256 + 8 * this.positionSlotCount
);
this.lengthTree.updateWithPretree(reader, 0, 249);
const mainTree = this.mainTree.create()!;
const lengthTree = this.lengthTree.create(true);
if (kind === 2) {
return {
kind: 'aligned',
size,
remaining: size,
alignedTree: alignedTree!,
mainTree,
lengthTree,
};
}
return { kind: 'verbatim', size, remaining: size, mainTree, lengthTree };
}
if (kind === 3) {
reader.alignToWord();
this.repeatedOffsets = [
reader.readU32LittleEndian(),
reader.readU32LittleEndian(),
reader.readU32LittleEndian(),
];
return { kind: 'uncompressed', size, remaining: size };
}
fail('lzx-invalid-block-type', `Invalid LZX block type ${kind}.`, {
offset: reader.offset,
structure: 'LZX block header',
});
}
private decodeMatchOffset(
reader: LzxBitReader,
positionSlot: number,
alignedTree: LzxHuffmanTree | undefined
): number {
if (positionSlot === 0) return this.repeatedOffsets[0]!;
if (positionSlot === 1) {
const offset = this.repeatedOffsets[1]!;
[this.repeatedOffsets[0], this.repeatedOffsets[1]] = [
this.repeatedOffsets[1]!,
this.repeatedOffsets[0]!,
];
return offset;
}
if (positionSlot === 2) {
const offset = this.repeatedOffsets[2]!;
[this.repeatedOffsets[0], this.repeatedOffsets[2]] = [
this.repeatedOffsets[2]!,
this.repeatedOffsets[0]!,
];
return offset;
}
const bitCount = footerBits(positionSlot);
let formattedOffset: number;
if (alignedTree !== undefined && bitCount >= 3) {
formattedOffset =
basePosition(positionSlot) +
reader.readBits(bitCount - 3) * 8 +
alignedTree.decode(reader);
} else {
formattedOffset = basePosition(positionSlot) + reader.readBits(bitCount);
}
const offset = formattedOffset - 2;
this.repeatedOffsets[2] = this.repeatedOffsets[1]!;
this.repeatedOffsets[1] = this.repeatedOffsets[0]!;
this.repeatedOffsets[0] = offset;
return offset;
}
private missingLengthTree(): never {
fail(
'lzx-empty-length-tree',
'LZX token needs a length footer but the length tree is empty.',
{ structure: 'LZX length tree' }
);
}
private ensureAdvance(
block: LzxBlock,
output: Uint8Array,
outputOffset: number,
length: number
): void {
if (length <= 0 || length > block.remaining) {
fail('lzx-block-overread', 'LZX token exceeds its declared block size.', {
structure: 'LZX block',
});
}
if (length > output.length - outputOffset) {
fail(
'lzx-chunk-overread',
'LZX token crosses the declared CAB chunk output boundary.',
{ structure: 'CFDATA.cbUncomp' }
);
}
}
private advanceBlock(block: LzxBlock, length: number): void {
if (length <= 0 || length > block.remaining) {
fail('lzx-block-overread', 'LZX data exceeds its declared block size.', {
structure: 'LZX block',
});
}
block.remaining -= length;
this.operations += length;
}
private writeByte(
value: number,
output: Uint8Array,
outputOffset: number
): void {
this.window[this.windowPosition] = value;
this.windowPosition = (this.windowPosition + 1) & this.windowMask;
output[outputOffset] = value;
}
private checkWork(): void {
if (this.operations > this.options.maxOperations) {
fail(
'lzx-operation-limit',
`LZX decoding exceeded the configured ${this.options.maxOperations}-operation limit.`,
{ structure: 'LZX stream' }
);
}
if (this.operations >= this.nextCancellationCheck) {
this.options.checkCancellation?.();
this.nextCancellationCheck = this.operations + 4_096;
}
}
private postprocessE8(
data: Uint8Array,
chunkOffset: number,
translationSize: number
): void {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
let processed = 0;
while (processed < data.length) {
const relative = data.subarray(processed).indexOf(0xe8);
if (relative < 0) return;
const position = processed + relative;
if (data.length - position <= 10) return;
const currentPointer = chunkOffset + position;
const absoluteValue = view.getInt32(position + 1, true);
if (absoluteValue >= -currentPointer && absoluteValue < translationSize) {
const relativeValue =
absoluteValue > 0
? absoluteValue - currentPointer
: absoluteValue + translationSize;
view.setInt32(position + 1, relativeValue, true);
}
processed = position + 5;
}
}
}

View File

@@ -0,0 +1,161 @@
/*
* SPDX-License-Identifier: MIT
*
* Direct TypeScript port of lzxd 0.2.5 `tree.rs`.
* lzxd commit 4748e43594e3e30cff2ace3a6ad7a376c9816fdd
* Copyright (c) 2020 Lonami, MIT OR Apache-2.0 (MIT selected here).
* Modified to use typed arrays and explicit malformed-tree diagnostics.
*/
import { fail } from '../errors.js';
import { LzxBitReader } from './bit-reader.js';
export class LzxCanonicalTree {
private readonly pathLengths: Uint8Array;
constructor(elementCount: number) {
this.pathLengths = new Uint8Array(elementCount);
}
create(allowEmpty = false): LzxHuffmanTree | undefined {
let largestLength = 0;
for (const length of this.pathLengths) {
largestLength = Math.max(largestLength, length);
}
if (largestLength === 0) {
if (allowEmpty) return undefined;
fail('lzx-empty-tree', 'LZX stream requires a non-empty Huffman tree.', {
structure: 'LZX Huffman tree',
});
}
return LzxHuffmanTree.fromPathLengths(this.pathLengths, largestLength);
}
updateWithPretree(reader: LzxBitReader, start: number, end: number): void {
if (start < 0 || end < start || end > this.pathLengths.length) {
throw new RangeError('Invalid LZX tree update range');
}
const pretreeLengths = new Uint8Array(20);
for (let index = 0; index < pretreeLengths.length; index += 1) {
pretreeLengths[index] = reader.readBits(4);
}
const pretree = LzxHuffmanTree.fromPathLengths(pretreeLengths);
let index = start;
while (index < end) {
const code = pretree.decode(reader);
if (code <= 16) {
this.pathLengths[index] = (17 + this.pathLengths[index]! - code) % 17;
index += 1;
continue;
}
if (code === 17 || code === 18) {
const run =
code === 17 ? reader.readBits(4) + 4 : reader.readBits(5) + 20;
if (index + run > end) {
fail(
'lzx-invalid-pretree-rle',
'LZX zero path-length run exceeds its tree range.',
{ offset: reader.offset, structure: 'LZX pretree' }
);
}
this.pathLengths.fill(0, index, index + run);
index += run;
continue;
}
if (code === 19) {
const run = reader.readBits(1) + 4;
const delta = pretree.decode(reader);
if (delta > 16 || index + run > end) {
fail(
'lzx-invalid-pretree-rle',
'LZX repeated path-length run is invalid.',
{ offset: reader.offset, structure: 'LZX pretree' }
);
}
const value = (17 + this.pathLengths[index]! - delta) % 17;
this.pathLengths.fill(value, index, index + run);
index += run;
continue;
}
fail('lzx-invalid-pretree-element', `Invalid LZX pretree code ${code}.`, {
offset: reader.offset,
structure: 'LZX pretree',
});
}
}
}
export class LzxHuffmanTree {
private constructor(
private readonly pathLengths: Uint8Array,
private readonly largestLength: number,
private readonly decodeTable: Uint16Array
) {}
static fromPathLengths(
source: Uint8Array,
knownLargestLength?: number
): LzxHuffmanTree {
const pathLengths = source.slice();
let largestLength = knownLargestLength ?? 0;
if (knownLargestLength === undefined) {
for (const length of pathLengths) {
largestLength = Math.max(largestLength, length);
}
}
if (largestLength === 0 || largestLength > 16) {
fail(
largestLength === 0 ? 'lzx-empty-tree' : 'lzx-invalid-path-lengths',
largestLength === 0
? 'LZX stream contains an empty Huffman tree.'
: `LZX Huffman path length ${largestLength} is invalid.`,
{ structure: 'LZX Huffman tree' }
);
}
const decodeTable = new Uint16Array(2 ** largestLength);
let position = 0;
for (let bitLength = 1; bitLength <= largestLength; bitLength += 1) {
const amount = 2 ** (largestLength - bitLength);
for (let symbol = 0; symbol < pathLengths.length; symbol += 1) {
if (pathLengths[symbol] !== bitLength) continue;
if (position > decodeTable.length - amount) {
fail(
'lzx-invalid-path-lengths',
'LZX Huffman path lengths are over-subscribed.',
{ structure: 'LZX Huffman tree' }
);
}
decodeTable.fill(symbol, position, position + amount);
position += amount;
}
}
if (position !== decodeTable.length) {
fail(
'lzx-invalid-path-lengths',
'LZX Huffman path lengths do not form a complete tree.',
{ structure: 'LZX Huffman tree' }
);
}
return new LzxHuffmanTree(pathLengths, largestLength, decodeTable);
}
decode(reader: LzxBitReader): number {
const symbol = this.decodeTable[reader.peekBits(this.largestLength)]!;
const bitLength = this.pathLengths[symbol]!;
if (bitLength === 0) {
fail('lzx-invalid-huffman-code', 'Invalid LZX Huffman code.', {
offset: reader.offset,
structure: 'LZX Huffman tree',
});
}
reader.readBits(bitLength);
return symbol;
}
}

View File

@@ -0,0 +1,177 @@
import type {
OneNotePackageDto,
OneNotePackageEntryDto,
OneNotePackagedSectionDto,
OneNoteSectionDto,
} from '../model/dto.js';
import type { ParserDiagnostic } from '../model/diagnostics.js';
import { checkCancellation } from './cancellation.js';
import { extractCabinet } from './cabinet.js';
import { diagnosticFromUnknown, OnePkgError, warning } from './errors.js';
import type {
CabOpenOptions,
ExtractedCabFile,
OnePkgCancellation,
} from './types.js';
export interface OneNotePackageSectionParseRequest {
sourceName: string;
path: string;
normalizedPath: string;
bytes: Uint8Array;
cancellation?: OnePkgCancellation;
}
export type OneNotePackageSectionParseOutcome =
| {
status: 'parsed';
value: OneNoteSectionDto;
diagnostics?: ParserDiagnostic[];
}
| { status: 'unsupported'; diagnostics?: ParserDiagnostic[] };
/**
* Deliberately independent from the CAB implementation: a `.one` parser is
* injected and receives only one extracted section plus cancellation context.
*/
export type OneNotePackageSectionParser = (
request: OneNotePackageSectionParseRequest
) =>
| OneNotePackageSectionParseOutcome
| Promise<OneNotePackageSectionParseOutcome>;
export interface OneNotePackageOpenOptions extends CabOpenOptions {
sourceName?: string;
parseSection?: OneNotePackageSectionParser;
}
export interface ParsedOneNotePackageSection {
normalizedPath: string;
value: OneNoteSectionDto;
}
export interface OneNotePackageOpenResult {
package: OneNotePackageDto;
/** Extracted in memory only. CAB paths are never written to the filesystem. */
extractedEntries: ExtractedCabFile[];
parsedSections: ParsedOneNotePackageSection[];
}
function entryKind(path: string): OneNotePackageEntryDto['kind'] {
const lower = path.toLowerCase();
if (lower.endsWith('.one')) return 'section';
if (lower.endsWith('.onetoc2')) return 'table-of-contents';
return 'other';
}
function compressionLabel(entry: ExtractedCabFile): string {
return entry.compression.kind === 'lzx'
? `${entry.compression.label} 2^${entry.compression.windowBits}`
: entry.compression.label;
}
function sectionDisplayName(path: string): string {
const fileName = path.slice(path.lastIndexOf('/') + 1);
return fileName.toLowerCase().endsWith('.one')
? fileName.slice(0, -4)
: fileName;
}
export async function openOneNotePackage(
bytes: Uint8Array,
options: OneNotePackageOpenOptions = {}
): Promise<OneNotePackageOpenResult> {
const sourceName = options.sourceName ?? 'notebook.onepkg';
const extraction = await extractCabinet(bytes, options);
const diagnostics = [...extraction.diagnostics];
const packageEntries: OneNotePackageEntryDto[] =
extraction.extractedEntries.map((entry) => ({
path: entry.path,
normalizedPath: entry.normalizedPath,
kind: entryKind(entry.normalizedPath),
uncompressedSize: entry.uncompressedSize,
compressionMethod: compressionLabel(entry),
parseStatus: 'not-requested',
}));
const parsedSections: ParsedOneNotePackageSection[] = [];
const packagedSections: OneNotePackagedSectionDto[] = [];
for (let index = 0; index < extraction.extractedEntries.length; index += 1) {
const entry = extraction.extractedEntries[index]!;
const packageEntry = packageEntries[index]!;
if (packageEntry.kind !== 'section') continue;
const sectionDiagnostics: ParserDiagnostic[] = [];
let parsedSection: OneNoteSectionDto | undefined;
if (options.parseSection !== undefined) {
checkCancellation(options.cancellation);
try {
const outcome = await options.parseSection({
sourceName: entry.normalizedPath,
path: entry.path,
normalizedPath: entry.normalizedPath,
bytes: entry.bytes,
cancellation: options.cancellation,
});
if (outcome.status === 'parsed') {
packageEntry.parseStatus = 'parsed';
parsedSection = outcome.value;
parsedSections.push({
normalizedPath: entry.normalizedPath,
value: outcome.value,
});
} else {
packageEntry.parseStatus = 'unsupported';
if (outcome.diagnostics === undefined) {
sectionDiagnostics.push(
warning(
'one-section-unsupported',
`Section parser does not support ${entry.normalizedPath}.`,
{ structure: entry.normalizedPath }
)
);
}
}
sectionDiagnostics.push(...(outcome.diagnostics ?? []));
} catch (error) {
if (
error instanceof OnePkgError &&
error.diagnostic.code === 'onepkg-cancelled'
) {
throw error;
}
packageEntry.parseStatus = 'failed';
sectionDiagnostics.push({
...diagnosticFromUnknown(
error,
'one-section-parse-failed',
`Could not parse ${entry.normalizedPath}`,
entry.normalizedPath
),
recoverable: true,
});
}
}
packagedSections.push({
id: entry.normalizedPath,
path: entry.normalizedPath,
displayName: sectionDisplayName(entry.normalizedPath),
parseStatus: packageEntry.parseStatus,
section: parsedSection,
diagnostics: sectionDiagnostics,
});
}
return {
package: {
format: 'onepkg',
sourceName,
sourceSize: bytes.byteLength,
entries: packageEntries,
sections: packagedSections,
diagnostics,
},
extractedEntries: extraction.extractedEntries,
parsedSections,
};
}

View File

@@ -0,0 +1,99 @@
import { fail } from './errors.js';
import type { OnePkgLimits } from './types.js';
const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true });
export function decodeCabFileName(
bytes: Uint8Array,
utf8: boolean,
offset: number
): string {
try {
// OneNote/Joplin exports are observed to store UTF-8 names without setting
// ATTR_NAME_IS_UTF. Strict UTF-8 is the only deterministic compatibility
// fallback: malformed input is rejected and no legacy code page is guessed.
return STRICT_UTF8.decode(bytes);
} catch (error) {
fail(
utf8 ? 'cab-name-invalid-utf8' : 'cab-name-encoding-unsupported',
utf8
? `CAB filename is not valid UTF-8${error instanceof Error ? `: ${error.message}` : '.'}`
: 'A non-ASCII CAB filename is not valid UTF-8; no legacy code-page guess was made.',
{ offset, structure: 'CFFILE.szName' }
);
}
}
export interface NormalizedCabPath {
path: string;
normalizedPath: string;
duplicateKey: string;
}
export function normalizeCabPath(
path: string,
limits: OnePkgLimits,
offset: number
): NormalizedCabPath {
if (path.length === 0) {
fail('cab-path-empty', 'CAB entry has an empty path.', {
offset,
structure: 'CFFILE.szName',
});
}
if (path.length > limits.maxPathCharacters) {
fail(
'cab-path-too-long',
`CAB path exceeds the configured ${limits.maxPathCharacters}-character limit.`,
{ offset, structure: 'CFFILE.szName' }
);
}
if (/^[\\/]/u.test(path) || /^[A-Za-z]:/u.test(path)) {
fail('cab-path-absolute', `Absolute CAB path is not allowed: ${path}`, {
offset,
structure: 'CFFILE.szName',
});
}
const slashPath = path.replaceAll('\\', '/');
const rawSegments = slashPath.split('/');
if (rawSegments.length > limits.maxPathDepth) {
fail(
'cab-path-too-deep',
`CAB path exceeds the configured ${limits.maxPathDepth}-segment limit.`,
{ offset, structure: 'CFFILE.szName' }
);
}
const segments = rawSegments.map((segment) => {
if (segment.length === 0 || segment === '.' || segment === '..') {
fail(
'cab-path-invalid-segment',
`CAB path contains an unsafe or empty segment: ${path}`,
{ offset, structure: 'CFFILE.szName' }
);
}
if (
[...segment].some((character) => {
const codePoint = character.codePointAt(0)!;
return codePoint <= 0x1f || codePoint === 0x7f;
})
) {
fail(
'cab-path-control-character',
`CAB path contains a control character: ${path}`,
{ offset, structure: 'CFFILE.szName' }
);
}
return segment.normalize('NFC');
});
const normalizedPath = segments.join('/');
return {
path,
normalizedPath,
// CAB paths have Windows semantics. NFC + lower-case catches the common
// duplicate aliases without writing any archive path to the filesystem.
duplicateKey: normalizedPath.toLowerCase(),
};
}

120
src/onenote/onepkg/types.ts Normal file
View File

@@ -0,0 +1,120 @@
import type { ParserDiagnostic } from '../model/diagnostics.js';
export type CabCompressionMethod =
| { kind: 'none'; raw: number; label: 'None' }
| { kind: 'mszip'; raw: number; label: 'MSZIP' }
| {
kind: 'quantum';
raw: number;
label: 'Quantum';
level: number;
memoryBits: number;
}
| {
kind: 'lzx';
raw: number;
label: 'LZX';
windowBits: number;
windowBytes: number;
};
export interface CabFileEntry {
index: number;
path: string;
normalizedPath: string;
folderIndex: number;
uncompressedOffset: number;
uncompressedSize: number;
attributes: number;
compression: CabCompressionMethod;
}
export interface CabFolderSummary {
index: number;
dataBlockCount: number;
compressedSize: number;
uncompressedSize: number;
compression: CabCompressionMethod;
}
export interface CabEnumerationResult {
cabinetSetId: number;
cabinetSetIndex: number;
declaredSize: number;
entries: CabFileEntry[];
folders: CabFolderSummary[];
diagnostics: ParserDiagnostic[];
}
export interface ExtractedCabFile extends CabFileEntry {
bytes: Uint8Array;
}
export interface CabExtractionResult extends CabEnumerationResult {
extractedEntries: ExtractedCabFile[];
}
export interface OnePkgCancellation {
signal?: AbortSignal;
isCancelled?: () => boolean;
/** Called between CAB data blocks so a worker can yield to its event loop. */
yield?: () => void | Promise<void>;
}
export interface OnePkgLimits {
maxCabinetBytes: number;
maxFolders: number;
maxFiles: number;
maxDataBlocks: number;
maxNameBytes: number;
maxPathCharacters: number;
maxPathDepth: number;
maxFileBytes: number;
maxFolderUncompressedBytes: number;
maxTotalUncompressedBytes: number;
maxExtractedBytes: number;
/** Maximum per-folder uncompressed CFDATA bytes / compressed payload bytes. */
maxCompressionRatio: number;
maxLzxWindowBytes: number;
maxDecodeOperations: number;
}
export const DEFAULT_ONEPKG_LIMITS: Readonly<OnePkgLimits> = {
maxCabinetBytes: 512 * 1024 * 1024,
maxFolders: 1_024,
maxFiles: 10_000,
maxDataBlocks: 131_072,
maxNameBytes: 255,
maxPathCharacters: 4_096,
maxPathDepth: 64,
maxFileBytes: 256 * 1024 * 1024,
maxFolderUncompressedBytes: 512 * 1024 * 1024,
maxTotalUncompressedBytes: 512 * 1024 * 1024,
maxExtractedBytes: 512 * 1024 * 1024,
maxCompressionRatio: 200,
maxLzxWindowBytes: 8 * 1024 * 1024,
maxDecodeOperations: 1_000_000_000,
};
export interface CabOpenOptions {
limits?: Partial<OnePkgLimits>;
cancellation?: OnePkgCancellation;
}
export function resolveLimits(
limits: Partial<OnePkgLimits> | undefined
): OnePkgLimits {
const resolved = { ...DEFAULT_ONEPKG_LIMITS, ...limits };
for (const [name, value] of Object.entries(resolved)) {
if (name === 'maxCompressionRatio') {
if (!Number.isFinite(value) || value <= 0) {
throw new TypeError(`${name} must be a positive finite number`);
}
continue;
}
if (!Number.isSafeInteger(value) || value <= 0) {
throw new TypeError(`${name} must be a positive safe integer`);
}
}
return resolved;
}

View File

@@ -0,0 +1,560 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
//
// Portions adapted from onenote.rs (MPL-2.0), revision
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onestore/desktop/
// one_store_file.rs` and `onestore/desktop/objects/{global_id_table,object,
// object_group_list,object_space,revision,revision_manifest_list,
// root_file_node_list}.rs`. Deviations: selected section nodes only,
// serializable diagnostics, explicit limits, and attachment payloads skipped.
import { BinaryReader } from '../binary/BinaryReader.js';
import { readGuid } from '../binary/guid.js';
import type { ParserDiagnostic } from '../model/diagnostics.js';
import {
DESKTOP_REVISION_STORE_FORMAT,
ONE_SECTION_FILE_TYPE,
detectOneNoteFormat,
} from '../parser/detect-format.js';
import { OneNoteParserError } from '../parser/error.js';
import type { OneNoteParserLimits } from '../parser/limits.js';
import {
DesktopFileNodeParser,
readChunkReference64x32,
readTransactionNodeCounts,
} from './file-node.js';
import { parseObjectPropSet } from './property-set.js';
import type {
CompactId,
ExGuid,
FileNode,
FileNodeList,
ObjectSpace,
StoreObject,
} from './types.js';
import { exGuidKey } from './types.js';
export interface DesktopOneStore {
rootObjectSpace: ObjectSpace;
objectSpaces: Map<string, ObjectSpace>;
}
interface ParseContext {
bytes: Uint8Array;
limits: OneNoteParserLimits;
diagnostics: ParserDiagnostic[];
objectCount: number;
}
export function parseDesktopOneStore(
bytes: Uint8Array,
limits: OneNoteParserLimits,
diagnostics: ParserDiagnostic[]
): DesktopOneStore {
if (bytes.byteLength > limits.maxFileBytes) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`OneNote section is ${bytes.byteLength} bytes (limit ${limits.maxFileBytes})`,
{ structure: 'OneStore file' }
);
}
const detected = detectOneNoteFormat(bytes);
if (detected.kind !== 'desktop-one') {
throw new OneNoteParserError(
detected.kind === 'not-onenote' ? 'INVALID_FORMAT' : 'UNSUPPORTED_FORMAT',
detected.kind === 'fsshttp-one'
? 'This OneNote section uses FSSHTTP packaging, which is not yet supported'
: `Expected a desktop .one section; detected ${detected.kind}`,
{ structure: 'OneStore header' }
);
}
if (bytes.byteLength < 1024) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'OneStore header is shorter than 1024 bytes',
{ structure: 'OneStore header' }
);
}
const header = new BinaryReader(bytes, 0, 1024, 'OneStore header');
const fileType = readGuid(header);
header.skip(32);
const fileFormat = readGuid(header);
if (
fileType !== ONE_SECTION_FILE_TYPE ||
fileFormat !== DESKTOP_REVISION_STORE_FORMAT
) {
throw new OneNoteParserError(
'INVALID_FORMAT',
'The OneStore header changed during format validation',
{ structure: 'OneStore header' }
);
}
header.seek(160);
const transactionLogReference = readChunkReference64x32(header);
const rootReference = readChunkReference64x32(header);
const nodeCounts = readTransactionNodeCounts(
bytes,
transactionLogReference,
limits
);
const fileNodeParser = new DesktopFileNodeParser(bytes, limits, nodeCounts);
const rootList = fileNodeParser.parseList(rootReference);
const context: ParseContext = {
bytes,
limits,
diagnostics,
objectCount: 0,
};
let rootObjectSpaceId: ExGuid | undefined;
const objectSpaces = new Map<string, ObjectSpace>();
for (const node of rootList.nodes) {
if (node.id === 0x004) {
rootObjectSpaceId = readExGuid(nodeReader(context, node));
} else if (node.id === 0x008) {
const objectSpace = parseObjectSpace(context, node);
objectSpaces.set(exGuidKey(objectSpace.id), objectSpace);
} else if (node.id !== 0x090 && node.id !== 0x0ff) {
diagnostic(
context,
'UNSUPPORTED_ROOT_NODE',
`Skipped unsupported root FileNode 0x${node.id.toString(16)}`,
node
);
}
}
if (!rootObjectSpaceId) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Root FileNodeList has no ObjectSpaceManifestRootFND',
{ structure: 'RootFileNodeList' }
);
}
const rootObjectSpace = objectSpaces.get(exGuidKey(rootObjectSpaceId));
if (!rootObjectSpace) {
throw new OneNoteParserError(
'MISSING_REFERENCE',
`Root object space ${exGuidKey(rootObjectSpaceId)} is absent`,
{ structure: 'RootFileNodeList' }
);
}
return { rootObjectSpace, objectSpaces };
}
function parseObjectSpace(context: ParseContext, node: FileNode): ObjectSpace {
const id = readExGuid(nodeReader(context, node));
const manifest = requiredChildList(
node,
'ObjectSpaceManifestListReferenceFND'
);
const revisions = manifest.nodes.filter((item) => item.id === 0x010);
const lastRevision = revisions.at(-1);
if (!lastRevision) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Object-space manifest has no revision-manifest reference',
{ offset: node.offset, structure: 'ObjectSpaceManifestList' }
);
}
const roots = new Map<number, ExGuid>();
const objects = new Map<string, StoreObject>();
parseRevisionManifestList(
context,
requiredChildList(lastRevision, 'RevisionManifestListReferenceFND'),
id,
roots,
objects
);
return { id, roots, objects };
}
function parseRevisionManifestList(
context: ParseContext,
list: FileNodeList,
contextId: ExGuid,
roots: Map<number, ExGuid>,
objects: Map<string, StoreObject>
): void {
if (list.nodes[0]?.id !== 0x014) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'RevisionManifestList does not begin with RevisionManifestListStartFND',
{ structure: 'RevisionManifestList' }
);
}
let index = 1;
while (index < list.nodes.length) {
const node = list.nodes[index]!;
if (node.id === 0x01c) {
index += 1;
} else if (node.id === 0x05c || node.id === 0x05d) {
index += 1;
} else if (node.id === 0x01b || node.id === 0x01e || node.id === 0x01f) {
index = parseRevision(
context,
list.nodes,
index + 1,
contextId,
roots,
objects
);
} else {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Unexpected FileNode 0x${node.id.toString(16)} in RevisionManifestList`,
{ offset: node.offset, structure: 'RevisionManifestList' }
);
}
}
}
function parseRevision(
context: ParseContext,
nodes: FileNode[],
startIndex: number,
contextId: ExGuid,
roots: Map<number, ExGuid>,
objects: Map<string, StoreObject>
): number {
let index = startIndex;
let mostRecentMapping: ReadonlyMap<number, string> | undefined;
while (index < nodes.length) {
const node = nodes[index]!;
if (node.id === 0x01c) return index + 1;
if (node.id === 0x0b0) {
parseObjectGroup(
context,
requiredChildList(node, 'ObjectGroupListReferenceFND'),
contextId,
objects
);
index += 1;
if (nodes[index]?.id === 0x084) index += 1;
continue;
}
if (node.id === 0x0b4) {
index = parseInlineObjectGroup(context, nodes, index, contextId, objects);
continue;
}
if (node.id === 0x021 || node.id === 0x022) {
const result = parseGlobalIdTable(context, nodes, index);
index = result.nextIndex;
mostRecentMapping = result.mapping;
while (index < nodes.length && isObjectDeclaration(nodes[index]!.id)) {
parseObjectDeclaration(
context,
nodes[index]!,
contextId,
mostRecentMapping,
objects
);
index += 1;
if (nodes[index]?.id === 0x084) index += 1;
}
continue;
}
if (node.id === 0x05a) {
const reader = nodeReader(context, node);
const objectId = readExGuid(reader);
const role = reader.u32();
if (role !== 1 && role !== 2 && role !== 4) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Invalid object-space root role ${role}`,
{ offset: node.offset, structure: 'RootObjectReference3FND' }
);
}
if (!roots.has(role)) roots.set(role, objectId);
index += 1;
continue;
}
if (node.id === 0x059) {
if (!mostRecentMapping) {
throw new OneNoteParserError(
'MISSING_REFERENCE',
'Compact root reference appears before a GlobalIdTable',
{ offset: node.offset, structure: 'RootObjectReference2FNDX' }
);
}
const reader = nodeReader(context, node);
const objectId = resolveCompactId(
readCompactId(reader),
mostRecentMapping
);
const role = reader.u32();
if (!roots.has(role)) roots.set(role, objectId);
index += 1;
continue;
}
if (node.id === 0x08c || node.id === 0x084) {
index += 1;
continue;
}
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Unexpected FileNode 0x${node.id.toString(16)} in revision`,
{ offset: node.offset, structure: 'Revision' }
);
}
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Revision has no RevisionManifestEndFND',
{ structure: 'Revision' }
);
}
function parseObjectGroup(
context: ParseContext,
list: FileNodeList,
contextId: ExGuid,
objects: Map<string, StoreObject>
): void {
const consumed = parseInlineObjectGroup(
context,
list.nodes,
0,
contextId,
objects
);
if (consumed !== list.nodes.length) {
const node = list.nodes[consumed];
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'ObjectGroupList contains data after ObjectGroupEndFND',
{ offset: node?.offset, structure: 'ObjectGroupList' }
);
}
}
function parseInlineObjectGroup(
context: ParseContext,
nodes: FileNode[],
startIndex: number,
contextId: ExGuid,
objects: Map<string, StoreObject>
): number {
if (nodes[startIndex]?.id !== 0x0b4) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'ObjectGroupList does not begin with ObjectGroupStartFND',
{ structure: 'ObjectGroupList' }
);
}
const table = parseGlobalIdTable(context, nodes, startIndex + 1);
let index = table.nextIndex;
while (index < nodes.length && nodes[index]!.id !== 0x0b8) {
const node = nodes[index]!;
if (isObjectDeclaration(node.id)) {
parseObjectDeclaration(context, node, contextId, table.mapping, objects);
} else if (node.id !== 0x08c) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Unexpected FileNode 0x${node.id.toString(16)} in ObjectGroupList`,
{ offset: node.offset, structure: 'ObjectGroupList' }
);
}
index += 1;
}
if (nodes[index]?.id !== 0x0b8) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'ObjectGroupList has no ObjectGroupEndFND',
{ structure: 'ObjectGroupList' }
);
}
return index + 1;
}
function parseGlobalIdTable(
context: ParseContext,
nodes: FileNode[],
startIndex: number
): { mapping: ReadonlyMap<number, string>; nextIndex: number } {
const start = nodes[startIndex];
if (!start || (start.id !== 0x021 && start.id !== 0x022)) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Expected a GlobalIdTable start node',
{ structure: 'GlobalIdTable' }
);
}
const mapping = new Map<number, string>();
let index = startIndex + 1;
while (index < nodes.length) {
const node = nodes[index]!;
if (node.id === 0x028) {
return { mapping, nextIndex: index + 1 };
}
if (node.id === 0x024) {
const reader = nodeReader(context, node);
mapping.set(reader.u32(), readGuid(reader));
} else if (node.id === 0x025 || node.id === 0x026) {
diagnostic(
context,
'UNSUPPORTED_GLOBAL_ID_MAPPING',
`Skipped dependent GlobalIdTable entry 0x${node.id.toString(16)}`,
node
);
} else {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Unexpected node 0x${node.id.toString(16)} in GlobalIdTable`,
{ offset: node.offset, structure: 'GlobalIdTable' }
);
}
index += 1;
}
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'GlobalIdTable has no end node',
{ structure: 'GlobalIdTable' }
);
}
function parseObjectDeclaration(
context: ParseContext,
node: FileNode,
contextId: ExGuid,
mapping: ReadonlyMap<number, string>,
objects: Map<string, StoreObject>
): void {
if (node.id === 0x072 || node.id === 0x073) {
diagnostic(
context,
'UNSUPPORTED_ATTACHMENT_DECLARATION',
'Attachment object metadata is not extracted in this parser phase',
node
);
return;
}
if (!node.dataReference) {
throw new OneNoteParserError(
'MISSING_REFERENCE',
'Object declaration has no property-set reference',
{ offset: node.offset, structure: 'ObjectDeclaration2RefCountFND' }
);
}
const reader = nodeReader(context, node);
const compactId = readCompactId(reader);
const jcid = reader.u32();
reader.u8(); // reference-stream presence flags
if (node.id === 0x0a4) reader.u8();
else if (node.id === 0x0a5) reader.u32();
else if (node.id === 0x0c4) {
reader.u8();
reader.skip(16);
} else if (node.id === 0x0c5) {
reader.u32();
reader.skip(16);
}
const id = resolveCompactId(compactId, mapping);
const props = parseObjectPropSet(
context.bytes,
node.dataReference,
context.limits
);
objects.set(exGuidKey(id), {
id,
jcid,
props,
mapping: new Map(mapping),
contextId,
});
context.objectCount += 1;
if (context.objectCount > context.limits.maxObjects) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Object count exceeds ${context.limits.maxObjects}`,
{ offset: node.offset, structure: 'ObjectDeclaration' }
);
}
}
function isObjectDeclaration(nodeId: number): boolean {
return (
nodeId === 0x0a4 ||
nodeId === 0x0a5 ||
nodeId === 0x0c4 ||
nodeId === 0x0c5 ||
nodeId === 0x072 ||
nodeId === 0x073
);
}
function resolveCompactId(
compact: CompactId,
mapping: ReadonlyMap<number, string>
): ExGuid {
const guid = mapping.get(compact.guidIndex);
if (!guid) {
throw new OneNoteParserError(
'MISSING_REFERENCE',
`GlobalIdTable has no GUID at index ${compact.guidIndex}`,
{ structure: 'CompactId' }
);
}
return { guid, value: compact.value };
}
function readCompactId(reader: BinaryReader): CompactId {
const raw = reader.u32();
return { value: raw & 0xff, guidIndex: raw >>> 8 };
}
function readExGuid(reader: BinaryReader): ExGuid {
return { guid: readGuid(reader), value: reader.u32() };
}
function requiredChildList(node: FileNode, structure: string): FileNodeList {
if (!node.childList) {
throw new OneNoteParserError(
'MISSING_REFERENCE',
`${structure} does not point to a FileNodeList`,
{ offset: node.offset, structure }
);
}
return node.childList;
}
function nodeReader(context: ParseContext, node: FileNode): BinaryReader {
return new BinaryReader(
context.bytes,
node.payloadOffset,
node.payloadLength,
`FileNode 0x${node.id.toString(16)}`
);
}
function diagnostic(
context: ParseContext,
code: string,
message: string,
node: FileNode
): void {
if (context.diagnostics.length >= context.limits.maxDiagnostics) return;
context.diagnostics.push({
severity: 'warning',
code,
message,
offset: node.offset,
structure: `FileNode 0x${node.id.toString(16)}`,
recoverable: true,
});
}

View File

@@ -0,0 +1,440 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
//
// Portions adapted from onenote.rs (MPL-2.0), revision
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onestore/desktop/file_structure/
// {file_node_list_fragment,parse_context,transaction_log_fragment}.rs`,
// `onestore/desktop/file_node/{mod,file_node_chunk_reference}.rs`, and
// `onestore/desktop/common/file_chunk_reference.rs`. Deviations: strict byte
// bounds, cycle/depth/count limits, and no eager decoding of unrelated data.
import { BinaryReader } from '../binary/BinaryReader.js';
import { OneNoteParserError } from '../parser/error.js';
import type { OneNoteParserLimits } from '../parser/limits.js';
import type { ChunkReference, FileNode, FileNodeList } from './types.js';
const FILE_NODE_LIST_MAGIC = 0xa4567ab1f5f7f4c4n;
const FILE_NODE_LIST_FOOTER = 0x8bc215c38233ba4bn;
const NIL_U64 = 0xffffffffffffffffn;
export interface OneStoreHeader {
fileType: string;
legacyFileVersion: string;
fileFormat: string;
rootList: ChunkReference;
}
interface ListParseState {
fragments: number;
nodes: number;
}
export class DesktopFileNodeParser {
private readonly root: BinaryReader;
private readonly limits: OneNoteParserLimits;
private readonly listCache = new Map<string, FileNodeList>();
private readonly activeFragments = new Set<string>();
private readonly state: ListParseState = { fragments: 0, nodes: 0 };
private readonly remainingNodeCounts: Map<number, number>;
constructor(
bytes: Uint8Array,
limits: OneNoteParserLimits,
nodeCounts: ReadonlyMap<number, number> = new Map()
) {
this.root = new BinaryReader(bytes, 0, bytes.byteLength, 'OneStore file');
this.limits = limits;
this.remainingNodeCounts = new Map(nodeCounts);
}
parseList(reference: ChunkReference, depth = 0): FileNodeList {
if (reference.nil || reference.zero || reference.size === 0) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'A required FileNodeList reference is nil or empty',
{ offset: reference.offset, structure: 'FileNodeList' }
);
}
if (depth > this.limits.maxListDepth) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`FileNodeList nesting exceeds ${this.limits.maxListDepth}`,
{ offset: reference.offset, structure: 'FileNodeList' }
);
}
const initialKey = referenceKey(reference);
const cached = this.listCache.get(initialKey);
if (cached) return cached;
const nodes: FileNode[] = [];
const seenFragments = new Set<string>();
let current = reference;
let listId: number | undefined;
let expectedSequence = 0;
while (!current.nil && !current.zero && current.size !== 0) {
this.state.fragments += 1;
if (this.state.fragments > this.limits.maxListFragments) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`FileNodeList fragment count exceeds ${this.limits.maxListFragments}`,
{ offset: current.offset, structure: 'FileNodeList' }
);
}
const key = referenceKey(current);
if (seenFragments.has(key)) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Cycle detected in a FileNodeList fragment chain',
{ offset: current.offset, structure: 'FileNodeList' }
);
}
seenFragments.add(key);
if (this.activeFragments.has(key)) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Cycle detected in FileNodeList fragments',
{ offset: current.offset, structure: 'FileNodeList' }
);
}
this.activeFragments.add(key);
try {
const fragment = this.root.viewAt(
current.offset,
current.size,
'FileNodeListFragment'
);
if (fragment.remaining < 36) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'FileNodeListFragment is shorter than its 36-byte framing',
{ offset: current.offset, structure: 'FileNodeListFragment' }
);
}
if (fragment.u64() !== FILE_NODE_LIST_MAGIC) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Invalid FileNodeListFragment magic',
{ offset: current.offset, structure: 'FileNodeListFragment' }
);
}
const fragmentListId = fragment.u32();
const sequence = fragment.u32();
if (listId === undefined) listId = fragmentListId;
if (fragmentListId !== listId || sequence !== expectedSequence) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Inconsistent FileNodeList fragment identity or sequence',
{ offset: current.offset, structure: 'FileNodeListFragment' }
);
}
expectedSequence += 1;
let remainingNodeCount = this.remainingNodeCounts.get(fragmentListId);
const nodeRegionEnd = fragment.end - 20;
while (
fragment.offset + 4 <= nodeRegionEnd &&
(remainingNodeCount === undefined || remainingNodeCount > 0)
) {
const firstWord = new DataView(
fragment.bytes.buffer,
fragment.bytes.byteOffset + fragment.offset,
4
).getUint32(0, true);
if (firstWord === 0) {
fragment.skip(4);
continue;
}
const node = this.parseNode(fragment, nodeRegionEnd, depth);
this.state.nodes += 1;
if (this.state.nodes > this.limits.maxFileNodes) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`FileNode count exceeds ${this.limits.maxFileNodes}`,
{ offset: node.offset, structure: 'FileNode' }
);
}
if (node.id === 0x0ff) {
// ChunkTerminatorFND ends logical decoding. Remaining bytes in the
// node region are opaque padding and need not contain zero words.
fragment.skip(nodeRegionEnd - fragment.offset);
break;
}
if (node.id !== 0) {
nodes.push(node);
if (remainingNodeCount !== undefined) remainingNodeCount -= 1;
}
}
if (remainingNodeCount !== undefined)
this.remainingNodeCounts.set(fragmentListId, remainingNodeCount);
// The fragment format permits up to three trailing padding bytes.
if (fragment.offset < nodeRegionEnd)
fragment.skip(nodeRegionEnd - fragment.offset);
current = readChunkReference64x32(fragment);
if (fragment.u64() !== FILE_NODE_LIST_FOOTER) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Invalid FileNodeListFragment footer',
{ offset: fragment.offset - 8, structure: 'FileNodeListFragment' }
);
}
} finally {
this.activeFragments.delete(key);
}
}
const result: FileNodeList = { id: listId ?? 0, nodes };
this.listCache.set(initialKey, result);
return result;
}
private parseNode(
reader: BinaryReader,
nodeRegionEnd: number,
depth: number
): FileNode {
const offset = reader.offset;
const firstWord = reader.u32();
const id = firstWord & 0x3ff;
const declaredSize = (firstWord >>> 10) & 0x1fff;
const stpFormat = (firstWord >>> 23) & 0x3;
const cbFormat = (firstWord >>> 25) & 0x3;
const baseType = (firstWord >>> 27) & 0xf;
if (id === 0) {
return {
id,
declaredSize,
offset,
payloadOffset: reader.offset,
payloadLength: 0,
};
}
if (declaredSize < 4 || offset + declaredSize > nodeRegionEnd) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`FileNode 0x${id.toString(16)} has invalid declared size ${declaredSize}`,
{ offset, structure: 'FileNode' }
);
}
let dataReference: ChunkReference | undefined;
let childList: FileNodeList | undefined;
if (baseType === 1 || baseType === 2) {
dataReference = readNodeChunkReference(reader, stpFormat, cbFormat);
if (baseType === 2) {
childList = this.parseList(dataReference, depth + 1);
}
} else if (baseType !== 0) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Unsupported FileNode base type ${baseType}`,
{ offset, structure: 'FileNode' }
);
}
const consumed = reader.offset - offset;
const payloadLength = declaredSize - consumed;
if (payloadLength < 0) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'FileNode reference exceeds the declared node size',
{ offset, structure: 'FileNode' }
);
}
const payloadOffset = reader.offset;
reader.skip(payloadLength);
return {
id,
declaredSize,
offset,
payloadOffset,
payloadLength,
dataReference,
childList,
};
}
}
export function readChunkReference64x32(reader: BinaryReader): ChunkReference {
const rawOffset = reader.u64();
const rawSize = reader.u32();
return chunkReference(rawOffset, BigInt(rawSize), rawOffset === NIL_U64);
}
/** Read transaction-log node counts used to delimit opaque fragment padding. */
export function readTransactionNodeCounts(
bytes: Uint8Array,
reference: ChunkReference,
limits: OneNoteParserLimits
): Map<number, number> {
const root = new BinaryReader(bytes, 0, bytes.byteLength, 'OneStore file');
const result = new Map<number, number>();
const seen = new Set<string>();
let current = reference;
let fragments = 0;
let entries = 0;
while (!current.nil && !current.zero && current.size !== 0) {
fragments += 1;
if (fragments > limits.maxTransactionFragments) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Transaction-log fragment count exceeds ${limits.maxTransactionFragments}`,
{ offset: current.offset, structure: 'TransactionLog' }
);
}
const key = referenceKey(current);
if (seen.has(key)) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Cycle detected in transaction-log fragments',
{ offset: current.offset, structure: 'TransactionLog' }
);
}
seen.add(key);
const fragment = root.viewAt(
current.offset,
current.size,
'TransactionLogFragment'
);
if (fragment.remaining < 12) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'TransactionLogFragment has an invalid size',
{ offset: current.offset, structure: 'TransactionLogFragment' }
);
}
const count = Math.floor((fragment.remaining - 12) / 8);
entries += count;
if (entries > limits.maxTransactionEntries) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Transaction-log entry count exceeds ${limits.maxTransactionEntries}`,
{ offset: current.offset, structure: 'TransactionLog' }
);
}
for (let index = 0; index < count; index += 1) {
const listId = fragment.u32();
const nodeCount = fragment.u32();
if (listId === 1) continue; // TransactionEntry sentinel.
result.set(listId, Math.max(result.get(listId) ?? 0, nodeCount));
}
current = readChunkReference64x32(fragment);
// Transaction fragments are allocation-aligned and can carry up to seven
// unused bytes after the next-fragment reference.
if (fragment.remaining > 0) fragment.skip(fragment.remaining);
}
return result;
}
function readNodeChunkReference(
reader: BinaryReader,
stpFormat: number,
cbFormat: number
): ChunkReference {
let rawOffset: bigint;
let offsetNil: boolean;
switch (stpFormat) {
case 0:
rawOffset = reader.u64();
offsetNil = rawOffset === NIL_U64;
break;
case 1: {
const value = reader.u32();
rawOffset = BigInt(value);
offsetNil = value === 0xffffffff;
break;
}
case 2: {
const value = reader.u16();
rawOffset = BigInt(value) * 8n;
offsetNil = value === 0xffff;
break;
}
case 3: {
const value = reader.u32();
rawOffset = BigInt(value) * 8n;
offsetNil = value === 0xffffffff;
break;
}
default:
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Invalid FileNode stp format ${stpFormat}`,
{ offset: reader.offset, structure: 'FileNodeChunkReference' }
);
}
let rawSize: bigint;
switch (cbFormat) {
case 0:
rawSize = BigInt(reader.u32());
break;
case 1:
rawSize = reader.u64();
break;
case 2:
rawSize = BigInt(reader.u8()) * 8n;
break;
case 3:
rawSize = BigInt(reader.u16()) * 8n;
break;
default:
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Invalid FileNode cb format ${cbFormat}`,
{ offset: reader.offset, structure: 'FileNodeChunkReference' }
);
}
return chunkReference(rawOffset, rawSize, offsetNil);
}
function chunkReference(
rawOffset: bigint,
rawSize: bigint,
nil: boolean
): ChunkReference {
if (nil && rawSize === 0n) {
return { offset: 0, size: 0, nil: true, zero: false };
}
if (rawOffset > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new OneNoteParserError(
'BOUNDS_EXCEEDED',
`Chunk offset is too large: ${rawOffset}`,
{ structure: 'FileChunkReference' }
);
}
if (rawSize > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new OneNoteParserError(
'BOUNDS_EXCEEDED',
`Chunk size is too large: ${rawSize}`,
{ structure: 'FileChunkReference' }
);
}
return {
offset: Number(rawOffset),
size: Number(rawSize),
nil: nil && rawSize === 0n,
zero: rawOffset === 0n && rawSize === 0n,
};
}
function referenceKey(reference: ChunkReference): string {
return `${reference.offset}:${reference.size}`;
}

View File

@@ -0,0 +1,290 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
//
// Portions adapted from onenote.rs (MPL-2.0), revision
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onestore/shared/
// {object_prop_set,object_stream_header,prop_set,property}.rs`. Deviations:
// recursive/count/vector reads are bounded and unresolved references are
// never silently discarded.
import { BinaryReader } from '../binary/BinaryReader.js';
import { OneNoteParserError } from '../parser/error.js';
import type { OneNoteParserLimits } from '../parser/limits.js';
import type {
ChunkReference,
CompactId,
ObjectPropSet,
PropertyEntry,
PropertySet,
PropertyValue,
} from './types.js';
interface ObjectStreamHeader {
count: number;
extendedStreamsPresent: boolean;
osidStreamNotPresent: boolean;
}
export function parseObjectPropSet(
bytes: Uint8Array,
reference: ChunkReference,
limits: OneNoteParserLimits
): ObjectPropSet {
if (reference.nil || reference.zero) {
throw new OneNoteParserError(
'INVALID_STRUCTURE',
'Object declaration has a nil property-set reference',
{ offset: reference.offset, structure: 'ObjectPropSet' }
);
}
const reader = new BinaryReader(
bytes,
reference.offset,
reference.size,
'ObjectPropSet'
);
const oidHeader = readStreamHeader(reader, limits);
const objectIds = readCompactIds(reader, oidHeader.count);
let objectSpaceIds: CompactId[] = [];
let contextIds: CompactId[] = [];
if (!oidHeader.osidStreamNotPresent) {
const osidHeader = readStreamHeader(reader, limits);
objectSpaceIds = readCompactIds(reader, osidHeader.count);
if (osidHeader.extendedStreamsPresent) {
const contextHeader = readStreamHeader(reader, limits);
contextIds = readCompactIds(reader, contextHeader.count);
}
}
const properties = readPropertySet(reader, limits, 0);
return { objectIds, objectSpaceIds, contextIds, properties };
}
export function findProperty(
props: ObjectPropSet | PropertySet,
rawPropertyId: number
): PropertyEntry | undefined {
const set = 'properties' in props ? props.properties : props;
const id = rawPropertyId & 0x03ffffff;
return set.entries.find((entry) => entry.id === id);
}
export function countObjectReferences(value: PropertyValue): number {
switch (value.kind) {
case 'objectId':
return 1;
case 'objectIds':
return value.count;
case 'propertyValues':
return value.sets.reduce(
(total, set) =>
total + countReferencesInSet(set, countObjectReferences),
0
);
case 'propertySet':
return countReferencesInSet(value.set, countObjectReferences);
default:
return 0;
}
}
export function countObjectSpaceReferences(value: PropertyValue): number {
switch (value.kind) {
case 'objectSpaceId':
return 1;
case 'objectSpaceIds':
return value.count;
case 'propertyValues':
return value.sets.reduce(
(total, set) =>
total + countReferencesInSet(set, countObjectSpaceReferences),
0
);
case 'propertySet':
return countReferencesInSet(value.set, countObjectSpaceReferences);
default:
return 0;
}
}
export function referenceOffset(
props: ObjectPropSet,
rawPropertyId: number,
counter: (value: PropertyValue) => number
): number {
const targetId = rawPropertyId & 0x03ffffff;
const index = props.properties.entries.findIndex(
(entry) => entry.id === targetId
);
if (index < 0) {
throw new OneNoteParserError(
'MISSING_REFERENCE',
`Property 0x${rawPropertyId.toString(16)} is absent`,
{ structure: 'ObjectPropSet' }
);
}
return props.properties.entries
.slice(0, index)
.reduce((total, entry) => total + counter(entry.value), 0);
}
function readStreamHeader(
reader: BinaryReader,
limits: OneNoteParserLimits
): ObjectStreamHeader {
const raw = reader.u32();
const count = raw & 0x00ffffff;
if (count > limits.maxStreamIds) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Object stream contains ${count} IDs (limit ${limits.maxStreamIds})`,
{ offset: reader.offset - 4, structure: 'ObjectStreamHeader' }
);
}
return {
count,
extendedStreamsPresent: ((raw >>> 30) & 1) !== 0,
osidStreamNotPresent: raw >>> 31 !== 0,
};
}
function readCompactIds(reader: BinaryReader, count: number): CompactId[] {
const ids: CompactId[] = [];
for (let index = 0; index < count; index += 1) {
const raw = reader.u32();
ids.push({ value: raw & 0xff, guidIndex: raw >>> 8 });
}
return ids;
}
function readPropertySet(
reader: BinaryReader,
limits: OneNoteParserLimits,
depth: number
): PropertySet {
if (depth > limits.maxPropertyDepth) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Nested property depth exceeds ${limits.maxPropertyDepth}`,
{ offset: reader.offset, structure: 'PropertySet' }
);
}
const count = reader.u16();
if (count > limits.maxPropertiesPerSet) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`PropertySet contains ${count} properties`,
{ offset: reader.offset - 2, structure: 'PropertySet' }
);
}
const rawIds: number[] = [];
for (let index = 0; index < count; index += 1) rawIds.push(reader.u32());
const entries = rawIds.map((rawId) => {
const type = (rawId >>> 26) & 0x1f;
return {
rawId,
id: rawId & 0x03ffffff,
type,
value: readPropertyValue(reader, rawId, type, limits, depth),
} satisfies PropertyEntry;
});
return { entries };
}
function readPropertyValue(
reader: BinaryReader,
rawId: number,
type: number,
limits: OneNoteParserLimits,
depth: number
): PropertyValue {
switch (type) {
case 0x1:
return { kind: 'empty' };
case 0x2:
return { kind: 'bool', value: rawId >>> 31 === 1 };
case 0x3:
return { kind: 'u8', value: reader.u8() };
case 0x4:
return { kind: 'u16', value: reader.u16() };
case 0x5:
return { kind: 'u32', value: reader.u32() };
case 0x6:
return { kind: 'u64', value: reader.u64() };
case 0x7: {
const length = reader.u32();
if (length > limits.maxVectorBytes) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Property vector is ${length} bytes`,
{ offset: reader.offset - 4, structure: 'PropertyValue' }
);
}
return { kind: 'bytes', value: reader.read(length) };
}
case 0x8:
return { kind: 'objectId' };
case 0x9:
return { kind: 'objectIds', count: boundedCount(reader, limits) };
case 0xa:
return { kind: 'objectSpaceId' };
case 0xb:
return {
kind: 'objectSpaceIds',
count: boundedCount(reader, limits),
};
case 0xc:
return { kind: 'contextId' };
case 0xd:
return { kind: 'contextIds', count: boundedCount(reader, limits) };
case 0x10: {
const count = boundedCount(reader, limits);
const id = reader.u32();
const sets: PropertySet[] = [];
for (let index = 0; index < count; index += 1) {
sets.push(readPropertySet(reader, limits, depth + 1));
}
return { kind: 'propertyValues', id, sets };
}
case 0x11:
return {
kind: 'propertySet',
set: readPropertySet(reader, limits, depth + 1),
};
default:
throw new OneNoteParserError(
'INVALID_STRUCTURE',
`Unexpected property type 0x${type.toString(16)}`,
{ offset: reader.offset, structure: 'PropertyValue' }
);
}
}
function boundedCount(
reader: BinaryReader,
limits: OneNoteParserLimits
): number {
const count = reader.u32();
if (count > limits.maxObjectReferences) {
throw new OneNoteParserError(
'LIMIT_EXCEEDED',
`Reference count ${count} exceeds ${limits.maxObjectReferences}`,
{ offset: reader.offset - 4, structure: 'PropertyValue' }
);
}
return count;
}
function countReferencesInSet(
set: PropertySet,
counter: (value: PropertyValue) => number
): number {
return set.entries.reduce((total, entry) => total + counter(entry.value), 0);
}

View File

@@ -0,0 +1,95 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
//
// Portions adapted from onenote.rs (MPL-2.0), revision
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onestore/desktop/common/
// {exguid,file_chunk_reference,object_space_object_stream_header}.rs`,
// `onestore/shared/{compact_id,jcid,object_prop_set}.rs`, and desktop object
// modules. This port uses serializable records and explicit limits.
export interface ExGuid {
guid: string;
value: number;
}
export interface CompactId {
guidIndex: number;
value: number;
}
export interface ChunkReference {
offset: number;
size: number;
nil: boolean;
zero: boolean;
}
export interface FileNodeList {
id: number;
nodes: FileNode[];
}
export interface FileNode {
id: number;
declaredSize: number;
offset: number;
payloadOffset: number;
payloadLength: number;
dataReference?: ChunkReference;
childList?: FileNodeList;
}
export type PropertyValue =
| { kind: 'empty' }
| { kind: 'bool'; value: boolean }
| { kind: 'u8'; value: number }
| { kind: 'u16'; value: number }
| { kind: 'u32'; value: number }
| { kind: 'u64'; value: bigint }
| { kind: 'bytes'; value: Uint8Array }
| { kind: 'objectId' }
| { kind: 'objectIds'; count: number }
| { kind: 'objectSpaceId' }
| { kind: 'objectSpaceIds'; count: number }
| { kind: 'contextId' }
| { kind: 'contextIds'; count: number }
| { kind: 'propertyValues'; id: number; sets: PropertySet[] }
| { kind: 'propertySet'; set: PropertySet };
export interface PropertyEntry {
rawId: number;
id: number;
type: number;
value: PropertyValue;
}
export interface PropertySet {
entries: PropertyEntry[];
}
export interface ObjectPropSet {
objectIds: CompactId[];
objectSpaceIds: CompactId[];
contextIds: CompactId[];
properties: PropertySet;
}
export interface StoreObject {
id: ExGuid;
jcid: number;
props: ObjectPropSet;
mapping: ReadonlyMap<number, string>;
contextId: ExGuid;
}
export interface ObjectSpace {
id: ExGuid;
roots: Map<number, ExGuid>;
objects: Map<string, StoreObject>;
}
export function exGuidKey(id: ExGuid): string {
return `${id.guid}:${id.value}`;
}

View File

@@ -0,0 +1,70 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
import { BinaryReader } from '../binary/BinaryReader.js';
import { NIL_GUID, readGuid } from '../binary/guid.js';
export const ONE_SECTION_FILE_TYPE = '{7B5C52E4-D88C-4DA7-AEB1-5378D02996D3}';
export const ONE_TOC_FILE_TYPE = '{43FF2FA1-EFD9-4C76-9EE2-10EA5722765F}';
export const DESKTOP_REVISION_STORE_FORMAT =
'{109ADD3F-911B-49F5-A5D0-1791EDC8AED8}';
export const FSSHTTP_PACKAGING_FORMAT =
'{638DE92F-A6D4-4BC1-9A36-B3FC2511A5B7}';
export type OneNoteFormatKind =
| 'desktop-one'
| 'desktop-onetoc2'
| 'fsshttp-one'
| 'not-onenote'
| 'unknown-onenote';
export interface OneNoteFormatDetection {
kind: OneNoteFormatKind;
fileType?: string;
legacyFileVersion?: string;
fileFormat?: string;
}
export function detectOneNoteFormat(
input: ArrayBuffer | Uint8Array
): OneNoteFormatDetection {
const bytes = toBytes(input);
if (bytes.byteLength < 64) return { kind: 'not-onenote' };
const reader = new BinaryReader(bytes, 0, 64, 'OneStore signature');
const fileType = readGuid(reader);
reader.skip(16);
const legacyFileVersion = readGuid(reader);
const fileFormat = readGuid(reader);
if (fileType !== ONE_SECTION_FILE_TYPE && fileType !== ONE_TOC_FILE_TYPE) {
return { kind: 'not-onenote', fileType, legacyFileVersion, fileFormat };
}
if (
fileFormat === FSSHTTP_PACKAGING_FORMAT ||
legacyFileVersion !== NIL_GUID
) {
return { kind: 'fsshttp-one', fileType, legacyFileVersion, fileFormat };
}
if (fileFormat !== DESKTOP_REVISION_STORE_FORMAT) {
return {
kind: 'unknown-onenote',
fileType,
legacyFileVersion,
fileFormat,
};
}
return {
kind:
fileType === ONE_SECTION_FILE_TYPE ? 'desktop-one' : 'desktop-onetoc2',
fileType,
legacyFileVersion,
fileFormat,
};
}
export function toBytes(input: ArrayBuffer | Uint8Array): Uint8Array {
return input instanceof Uint8Array ? input : new Uint8Array(input);
}

View File

@@ -0,0 +1,51 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
export type OneNoteParserErrorCode =
| 'BOUNDS_EXCEEDED'
| 'INVALID_FORMAT'
| 'INVALID_STRUCTURE'
| 'LIMIT_EXCEEDED'
| 'MISSING_REFERENCE'
| 'UNSUPPORTED_FORMAT';
export interface OneNoteParserErrorOptions {
offset?: number;
structure?: string;
cause?: unknown;
}
/** A controlled parse failure safe to serialize across a worker boundary. */
export class OneNoteParserError extends Error {
readonly code: OneNoteParserErrorCode;
readonly offset?: number;
readonly structure?: string;
constructor(
code: OneNoteParserErrorCode,
message: string,
options: OneNoteParserErrorOptions = {}
) {
super(message, { cause: options.cause });
this.name = 'OneNoteParserError';
this.code = code;
this.offset = options.offset;
this.structure = options.structure;
}
}
export function asParserError(
error: unknown,
structure: string
): OneNoteParserError {
if (error instanceof OneNoteParserError) {
return error;
}
return new OneNoteParserError(
'INVALID_STRUCTURE',
error instanceof Error ? error.message : String(error),
{ cause: error, structure }
);
}

View File

@@ -0,0 +1,55 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
export interface OneNoteParserLimits {
maxFileBytes: number;
maxTransactionFragments: number;
maxTransactionEntries: number;
maxListFragments: number;
maxListDepth: number;
maxFileNodes: number;
maxObjects: number;
maxPropertiesPerSet: number;
maxPropertyDepth: number;
maxStreamIds: number;
maxVectorBytes: number;
maxObjectReferences: number;
maxGraphDepth: number;
maxPages: number;
maxDiagnostics: number;
maxExtractedTextChars: number;
}
export const DEFAULT_ONENOTE_PARSER_LIMITS: Readonly<OneNoteParserLimits> = {
maxFileBytes: 512 * 1024 * 1024,
maxTransactionFragments: 16_384,
maxTransactionEntries: 1_000_000,
maxListFragments: 16_384,
maxListDepth: 64,
maxFileNodes: 1_000_000,
maxObjects: 500_000,
maxPropertiesPerSet: 16_384,
maxPropertyDepth: 64,
maxStreamIds: 1_000_000,
maxVectorBytes: 64 * 1024 * 1024,
maxObjectReferences: 1_000_000,
maxGraphDepth: 256,
maxPages: 100_000,
maxDiagnostics: 10_000,
maxExtractedTextChars: 2_000_000,
};
export function resolveParserLimits(
limits: Partial<OneNoteParserLimits> = {}
): OneNoteParserLimits {
const resolved = { ...DEFAULT_ONENOTE_PARSER_LIMITS, ...limits };
for (const [name, value] of Object.entries(resolved)) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new TypeError(`${name} must be a positive safe integer`);
}
}
return resolved;
}

View File

@@ -0,0 +1,32 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
import type { OneNoteSectionDto } from '../model/dto.js';
import { buildSectionDto } from '../one/section.js';
import { parseDesktopOneStore } from '../onestore/desktop-store.js';
import { toBytes } from './detect-format.js';
import type { OneNoteParserLimits } from './limits.js';
import { resolveParserLimits } from './limits.js';
export interface ParseOneNoteSectionOptions {
sourceName?: string;
limits?: Partial<OneNoteParserLimits>;
}
/** Parse a desktop revision-store `.one` section without network access. */
export function parseOneNoteSection(
input: ArrayBuffer | Uint8Array,
options: ParseOneNoteSectionOptions = {}
): OneNoteSectionDto {
const bytes = toBytes(input);
const limits = resolveParserLimits(options.limits);
const diagnostics: OneNoteSectionDto['diagnostics'] = [];
const store = parseDesktopOneStore(bytes, limits, diagnostics);
return buildSectionDto(
store,
options.sourceName ?? 'Untitled.one',
limits,
diagnostics
);
}

10
src/source-file.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { SourceKind } from './worker/onenote.client.js';
export const MAX_SOURCE_FILE_BYTES = 512 * 1024 * 1024;
export function sourceKindFromFileName(fileName: string): SourceKind | null {
const normalized = fileName.toLocaleLowerCase('en-US');
if (normalized.endsWith('.onepkg')) return 'onepkg';
if (normalized.endsWith('.one')) return 'one';
return null;
}

627
src/styles.css Normal file
View File

@@ -0,0 +1,627 @@
:root {
color: #241b2b;
background: #f6f3f8;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
input {
font: inherit;
}
button {
border: 1px solid #704294;
border-radius: 0.55rem;
padding: 0.55rem 0.9rem;
color: #4c1f6b;
background: #fff;
cursor: pointer;
}
button:hover:not(:disabled) {
background: #f1e8f8;
}
button:focus-visible,
summary:focus-visible {
outline: 3px solid #b788d4;
outline-offset: 2px;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
code {
border-radius: 0.25rem;
padding: 0.08rem 0.25rem;
overflow-wrap: anywhere;
background: #eee7f3;
}
h1,
h2,
p {
overflow-wrap: anywhere;
}
.application {
display: grid;
gap: 1.25rem;
width: min(76rem, calc(100% - 2rem));
margin: 2rem auto;
}
.drop-zone,
.status-card,
.source-toolbar,
.package-inspector,
.diagnostics {
border: 1px solid #c9b9d5;
border-radius: 1rem;
background: #fff;
box-shadow: 0 0.75rem 2.5rem rgb(62 31 85 / 8%);
}
.drop-zone,
.status-card {
padding: clamp(1.5rem, 4vw, 3rem);
}
.drop-zone {
border-style: dashed;
text-align: center;
}
.drop-zone--active {
border-color: #6b368f;
background: #f7effd;
}
.drop-zone h2,
.status-card h2 {
margin: 0.25rem 0 0.75rem;
}
.drop-zone__eyebrow,
.status-card__label,
.eyebrow {
margin: 0;
color: #6b368f;
font-size: 0.76rem;
font-weight: 750;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.primary-button {
margin-top: 0.75rem;
color: #fff;
background: #5b2c83;
}
.primary-button:hover:not(:disabled) {
background: #472164;
}
.drop-zone__hint {
display: block;
margin-top: 0.75rem;
color: #6f6476;
font-size: 0.9rem;
}
.status-card--error {
border-color: #b65252;
}
.status-card--error > div {
margin-top: 1rem;
}
.source-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.15rem 1.35rem;
}
.source-toolbar h2 {
margin: 0.15rem 0 0;
font-size: clamp(1.2rem, 3vw, 1.65rem);
}
.reader-workspace {
display: grid;
grid-template-columns: minmax(16rem, 0.78fr) minmax(0, 2fr);
min-height: 30rem;
overflow: hidden;
border: 1px solid #c9b9d5;
border-radius: 1rem;
background: #fff;
box-shadow: 0 0.75rem 2.5rem rgb(62 31 85 / 8%);
}
.source-navigation {
overflow: auto;
border-right: 1px solid #d9cedf;
background: #faf7fc;
}
.navigation-heading {
position: sticky;
z-index: 1;
top: 0;
padding: 1.2rem;
border-bottom: 1px solid #ded5e3;
background: #faf7fc;
}
.navigation-heading h2 {
margin: 0.15rem 0 0;
font-size: 1.05rem;
}
.navigation-heading > p:last-child {
margin: 0.35rem 0 0;
color: #716675;
font-size: 0.8rem;
}
.page-list,
.section-list,
.diagnostic-list {
margin: 0;
padding: 0;
list-style: none;
}
.page-list {
padding: 0.55rem;
}
.page-list button,
.section-list button {
display: grid;
gap: 0.25rem;
width: 100%;
border-color: transparent;
padding: 0.72rem;
text-align: left;
color: inherit;
background: transparent;
}
.page-list button span {
display: -webkit-box;
overflow: hidden;
color: #716675;
font-size: 0.78rem;
font-weight: 400;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.page-list button.is-selected,
.section-list button.is-selected {
border-color: #af8bc6;
color: #3e145c;
background: #eee3f5;
}
.section-list {
padding: 0.55rem 0.55rem 0;
}
.section-list button {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
.section-list + .page-list {
margin: 0.55rem;
border-top: 1px solid #ded5e3;
padding-top: 0.85rem;
}
.navigation-empty,
.empty-message {
margin: 0;
padding: 1rem 1.2rem;
color: #716675;
}
.page-reader {
min-width: 0;
padding: clamp(1.4rem, 4vw, 3rem);
background: #fff;
}
.page-reader--empty {
display: grid;
place-items: center;
min-height: 20rem;
color: #716675;
text-align: center;
}
.page-reader--error {
border-left: 0.25rem solid #b65252;
}
.page-reader__header {
margin-bottom: 2rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid #e1d9e5;
}
.page-reader__header h2 {
margin: 0.3rem 0 0;
font-size: clamp(1.55rem, 4vw, 2.4rem);
line-height: 1.12;
}
.page-metadata {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 1rem;
margin: 1rem 0 0;
color: #716675;
font-size: 0.82rem;
}
.page-metadata dt {
font-weight: 700;
}
.page-metadata dd {
margin: 0 1rem 0 0;
}
.page-content {
max-width: 48rem;
line-height: 1.65;
}
.extracted-text {
margin: 0 0 1em;
white-space: pre-wrap;
}
.unsupported-block {
display: grid;
gap: 0.2rem;
margin: 0.8rem 0;
border-left: 0.2rem solid #9872ae;
padding: 0.65rem 0.85rem;
color: #5e5362;
background: #f5eff8;
}
.package-inspector,
.diagnostics {
padding: 1.25rem;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.panel-heading h2 {
margin: 0.15rem 0 0;
font-size: 1.2rem;
}
.count-badge {
display: inline-grid;
min-width: 2rem;
min-height: 2rem;
place-items: center;
border-radius: 999px;
color: #552477;
background: #eee3f5;
font-weight: 700;
}
.package-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.7rem;
margin: 1rem 0;
}
.package-summary > div {
border-radius: 0.65rem;
padding: 0.75rem;
background: #f6f1f8;
}
.package-summary dt {
color: #716675;
font-size: 0.75rem;
}
.package-summary dd {
margin: 0.2rem 0 0;
font-size: 1.1rem;
font-weight: 700;
}
.entry-inspector {
border-top: 1px solid #ded5e3;
padding-top: 0.9rem;
}
.entry-inspector summary {
width: fit-content;
color: #5b2c83;
cursor: pointer;
font-weight: 700;
}
.entry-table-wrap {
max-height: 28rem;
margin-top: 0.85rem;
overflow: auto;
}
.entry-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
text-align: left;
}
.entry-table th,
.entry-table td {
padding: 0.62rem;
border-bottom: 1px solid #e5dee9;
vertical-align: top;
}
.entry-table th {
position: sticky;
top: 0;
background: #fff;
}
.entry-table small {
display: block;
margin-top: 0.3rem;
color: #716675;
}
.status {
width: fit-content;
border-radius: 999px;
padding: 0.18rem 0.46rem;
color: #655769;
background: #ebe7ed;
font-size: 0.68rem;
font-weight: 750;
text-transform: uppercase;
}
.status--parsed {
color: #1c6645;
background: #daf0e4;
}
.status--failed {
color: #872d2d;
background: #f6dddd;
}
.status--unsupported {
color: #77510d;
background: #f6e9c8;
}
.diagnostic-list {
display: grid;
gap: 0.65rem;
margin-top: 1rem;
}
.diagnostic {
border-left: 0.22rem solid #b18124;
padding: 0.7rem 0.85rem;
background: #fbf4e6;
}
.diagnostic--error {
border-left-color: #b65252;
background: #faeaea;
}
.diagnostic__summary {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.diagnostic__summary span {
font-size: 0.72rem;
text-transform: uppercase;
}
.diagnostic p {
margin: 0.35rem 0 0;
}
.diagnostic__context {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.15rem 0.65rem;
margin: 0.65rem 0 0;
font-size: 0.76rem;
}
.diagnostic__context dt {
font-weight: 700;
}
.diagnostic__context dd {
margin: 0;
}
.privacy-note {
border-left: 0.25rem solid #7d4aa0;
padding: 0.85rem 1rem;
color: #4c4054;
background: #eee7f3;
}
.dialog-backdrop {
position: fixed;
z-index: 10;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
background: rgb(25 14 31 / 58%);
}
.help-dialog {
width: min(32rem, 100%);
border-radius: 0.8rem;
padding: 1.5rem;
color: #241b2b;
background: #fff;
box-shadow: 0 1rem 3rem rgb(0 0 0 / 25%);
}
.help-dialog h2 {
margin-top: 0;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
padding: 0;
}
@media (max-width: 760px) {
.application {
width: min(100% - 1rem, 76rem);
margin: 0.75rem auto;
}
.reader-workspace {
grid-template-columns: 1fr;
}
.source-navigation {
max-height: 20rem;
border-right: 0;
border-bottom: 1px solid #d9cedf;
}
.package-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.entry-table {
min-width: 42rem;
}
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6effa;
background: #1f1923;
}
.drop-zone,
.status-card,
.source-toolbar,
.package-inspector,
.diagnostics,
.reader-workspace,
.page-reader,
.help-dialog,
.entry-table th {
color: #f6effa;
background: #2b2231;
}
.source-navigation,
.navigation-heading {
background: #251d2a;
}
.drop-zone--active,
button:hover:not(:disabled),
.page-list button.is-selected,
.section-list button.is-selected {
color: #f6effa;
background: #3c2b48;
}
button {
color: #f4e7fc;
background: #35283d;
}
code,
.privacy-note,
.package-summary > div {
color: #eadff0;
background: #3a3040;
}
.drop-zone__hint,
.navigation-heading > p:last-child,
.navigation-empty,
.empty-message,
.page-metadata,
.page-list button span,
.package-summary dt,
.entry-table small {
color: #cfc2d6;
}
.unsupported-block {
color: #e4dbe9;
background: #3a3040;
}
.diagnostic {
color: #f5e8cd;
background: #493b23;
}
.diagnostic--error {
color: #f8dfdf;
background: #4d2929;
}
}

7
src/test/setup.ts Normal file
View File

@@ -0,0 +1,7 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});

View File

@@ -0,0 +1,30 @@
{
"id": "de.add-ideas.onenote-tools",
"name": "OneNote Tools",
"description": "Local-first reader for tested desktop revision-store .one sections and LZX .onepkg packages; other OneNote formats are not implemented.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["documents", "onenote"],
"tags": ["local", "read-only", "one", "onepkg", "cab", "lzx"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": true,
"indexedDb": false,
"crossOriginIsolated": false
},
"privacy": {
"processing": "local",
"fileUploads": false,
"telemetry": false,
"label": "Local only · no uploads · no storage · no telemetry"
},
"source": {
"repository": "https://git.add-ideas.de/zemion/onenote-tools",
"license": "MPL-2.0"
}
}

View File

@@ -0,0 +1,22 @@
import { parseToolboxApp } from '@add-ideas/toolbox-contract';
import { describe, expect, it } from 'vitest';
import generatedManifest from '../../public/toolbox-app.json';
import { toolboxApp } from './manifest.js';
describe('toolbox manifest', () => {
it('matches the generated and validated public manifest', () => {
expect(toolboxApp).toEqual(parseToolboxApp(generatedManifest));
});
it('does not advertise unimplemented format support', () => {
expect(toolboxApp.description).toContain('not implemented');
expect(toolboxApp.privacy).toEqual(
expect.objectContaining({
processing: 'local',
fileUploads: false,
telemetry: false,
})
);
});
});

12
src/toolbox/manifest.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineToolboxApp, parseToolboxApp } from '@add-ideas/toolbox-contract';
import definition from './manifest.definition.json';
import { APP_VERSION } from '../version.js';
export const toolboxApp = defineToolboxApp(
parseToolboxApp({
schemaVersion: 1,
...definition,
version: APP_VERSION,
})
);

1
src/version.ts Normal file
View File

@@ -0,0 +1 @@
export const APP_VERSION = '0.1.0';

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest';
import {
OneNoteWorkerClient,
WorkerClientError,
type WorkerPort,
} from './onenote.client.js';
import type { WorkerRequest, WorkerResponse } from './worker-protocol.js';
class FakeWorker implements WorkerPort {
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
onerror: ((event: ErrorEvent) => void) | null = null;
lastMessage: WorkerRequest | null = null;
lastTransfer: Transferable[] = [];
terminated = false;
postMessage(message: WorkerRequest, transfer: Transferable[] = []): void {
this.lastMessage = message;
this.lastTransfer = transfer;
}
terminate(): void {
this.terminated = true;
}
respond(response: WorkerResponse): void {
this.onmessage?.(new MessageEvent('message', { data: response }));
}
}
describe('OneNoteWorkerClient', () => {
it('uses the typed parse-onepkg protocol and transfers the source buffer', async () => {
const worker = new FakeWorker();
const client = new OneNoteWorkerClient(() => worker);
const bytes = new ArrayBuffer(16);
const result = client.parseSource('onepkg', 'Notebook.onepkg', bytes);
expect(worker.lastMessage).toEqual(
expect.objectContaining({
type: 'parse-onepkg',
fileName: 'Notebook.onepkg',
bytes,
})
);
expect(worker.lastTransfer).toEqual([bytes]);
const requestId =
worker.lastMessage && 'requestId' in worker.lastMessage
? worker.lastMessage.requestId
: undefined;
expect(requestId).toBeDefined();
worker.respond({
type: 'failure',
requestId: requestId ?? 'missing',
error: {
code: 'unsupported-format',
message: 'Parser pending.',
recoverable: false,
},
});
await expect(result).resolves.toEqual(
expect.objectContaining({ type: 'failure' })
);
});
it('requests a page from the retained parser session', async () => {
const worker = new FakeWorker();
const client = new OneNoteWorkerClient(() => worker);
const result = client.getPage('session-1', 'page-7');
expect(worker.lastMessage).toEqual({
type: 'get-page',
requestId: 'page-1',
sessionId: 'session-1',
pageId: 'page-7',
});
worker.respond({
type: 'page',
requestId: 'page-1',
sessionId: 'session-1',
page: {
id: 'page-7',
title: 'Page seven',
text: 'Body',
textPreview: 'Body',
blocks: [{ kind: 'text', text: 'Body' }],
diagnostics: [],
},
});
await expect(result).resolves.toEqual(
expect.objectContaining({ type: 'page' })
);
});
it('rejects outstanding work when the worker is terminated', async () => {
const worker = new FakeWorker();
const client = new OneNoteWorkerClient(() => worker);
const result = client.parseSource('one', 'Section.one', new ArrayBuffer(8));
client.terminate();
await expect(result).rejects.toEqual(
expect.objectContaining<Partial<WorkerClientError>>({
code: 'worker-failure',
})
);
expect(worker.terminated).toBe(true);
});
});

View File

@@ -0,0 +1,137 @@
import type {
PageWorkerResponse,
ParseWorkerRequest,
ParseWorkerResponse,
WorkerRequest,
WorkerFailure,
WorkerResponse,
} from './worker-protocol.js';
export type SourceKind = 'one' | 'onepkg';
export interface WorkerPort {
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null;
onerror: ((event: ErrorEvent) => void) | null;
postMessage(message: WorkerRequest, transfer?: Transferable[]): void;
terminate(): void;
}
type PendingRequest = {
resolve: (response: ParseWorkerResponse | PageWorkerResponse) => void;
reject: (error: WorkerClientError) => void;
};
export class WorkerClientError extends Error {
constructor(
readonly code: WorkerFailure['code'],
message: string
) {
super(message);
this.name = 'WorkerClientError';
}
}
function createModuleWorker(): WorkerPort {
return new Worker(new URL('./onenote.worker.ts', import.meta.url), {
type: 'module',
}) as WorkerPort;
}
export class OneNoteWorkerClient {
private readonly worker: WorkerPort;
private readonly pending = new Map<string, PendingRequest>();
private requestSequence = 0;
private terminated = false;
constructor(workerFactory: () => WorkerPort = createModuleWorker) {
this.worker = workerFactory();
this.worker.onmessage = ({ data }) => {
if (!('requestId' in data)) return;
const pending = this.pending.get(data.requestId);
if (!pending) return;
this.pending.delete(data.requestId);
pending.resolve(data);
};
this.worker.onerror = () => {
const error = new WorkerClientError(
'worker-failure',
'The local parser worker stopped unexpectedly.'
);
for (const request of this.pending.values()) request.reject(error);
this.pending.clear();
};
}
parseSource(
kind: SourceKind,
fileName: string,
bytes: ArrayBuffer
): Promise<ParseWorkerResponse> {
if (this.terminated) {
return Promise.reject(
new WorkerClientError(
'worker-failure',
'The local parser worker has already been terminated.'
)
);
}
const requestId = `parse-${++this.requestSequence}`;
const request: ParseWorkerRequest = {
type: kind === 'one' ? 'parse-one' : 'parse-onepkg',
requestId,
fileName,
bytes,
};
return new Promise((resolve, reject) => {
this.pending.set(requestId, {
resolve: (response) => resolve(response as ParseWorkerResponse),
reject,
});
this.worker.postMessage(request, [bytes]);
});
}
getPage(
sessionId: string,
pageId: string,
sectionId?: string
): Promise<PageWorkerResponse> {
if (this.terminated) {
return Promise.reject(
new WorkerClientError(
'worker-failure',
'The local parser worker has already been terminated.'
)
);
}
const requestId = `page-${++this.requestSequence}`;
return new Promise((resolve, reject) => {
this.pending.set(requestId, {
resolve: (response) => resolve(response as PageWorkerResponse),
reject,
});
this.worker.postMessage({
type: 'get-page',
requestId,
sessionId,
pageId,
...(sectionId === undefined ? {} : { sectionId }),
});
});
}
terminate(): void {
if (this.terminated) return;
this.terminated = true;
this.worker.terminate();
const error = new WorkerClientError(
'worker-failure',
'The local parser request was cancelled.'
);
for (const request of this.pending.values()) request.reject(error);
this.pending.clear();
}
}

View File

@@ -0,0 +1,122 @@
import type { OneNotePageDto } from '../onenote/model/dto.js';
import {
failureFromUnknown,
openPackageForWorker,
openSectionForWorker,
workerPageKey,
} from './parser-adapter.js';
import type { WorkerRequest, WorkerResponse } from './worker-protocol.js';
interface ModuleWorkerScope {
onmessage: ((event: MessageEvent<WorkerRequest>) => void) | null;
postMessage(response: WorkerResponse): void;
}
interface ParserSession {
pages: Map<string, OneNotePageDto>;
}
const workerScope = globalThis as unknown as ModuleWorkerScope;
const sessions = new Map<string, ParserSession>();
let sessionSequence = 0;
function createSession(pages: Map<string, OneNotePageDto>): string {
const id = `session-${++sessionSequence}`;
sessions.set(id, { pages });
return id;
}
async function handleRequest(data: WorkerRequest): Promise<void> {
switch (data.type) {
case 'parse-one': {
try {
const parsed = openSectionForWorker(
new Uint8Array(data.bytes),
data.fileName
);
const sessionId = createSession(parsed.pages);
workerScope.postMessage({
type: 'section',
requestId: data.requestId,
sessionId,
section: parsed.section,
});
} catch (error) {
workerScope.postMessage({
type: 'failure',
requestId: data.requestId,
error: failureFromUnknown(error),
});
}
return;
}
case 'parse-onepkg':
try {
const parsed = await openPackageForWorker(
new Uint8Array(data.bytes),
data.fileName
);
const sessionId = createSession(parsed.pages);
workerScope.postMessage({
type: 'package',
requestId: data.requestId,
sessionId,
notebook: parsed.notebook,
});
} catch (error) {
workerScope.postMessage({
type: 'failure',
requestId: data.requestId,
error: failureFromUnknown(error),
});
}
return;
case 'get-page': {
const session = sessions.get(data.sessionId);
if (!session) {
workerScope.postMessage({
type: 'failure',
requestId: data.requestId,
error: {
code: 'session-not-available',
message: 'The parser session is no longer available.',
recoverable: false,
},
});
return;
}
const page = session.pages.get(
workerPageKey(data.pageId, data.sectionId)
);
if (!page) {
workerScope.postMessage({
type: 'failure',
requestId: data.requestId,
error: {
code: 'page-not-available',
message: 'The selected page is not available in this source.',
recoverable: true,
},
});
return;
}
workerScope.postMessage({
type: 'page',
requestId: data.requestId,
sessionId: data.sessionId,
page,
});
return;
}
case 'dispose':
sessions.delete(data.sessionId);
workerScope.postMessage({
type: 'disposed',
sessionId: data.sessionId,
});
}
}
workerScope.onmessage = ({ data }) => {
void handleRequest(data);
};

View File

@@ -0,0 +1,71 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import {
failureFromUnknown,
openPackageForWorker,
openSectionForWorker,
workerPageKey,
} from './parser-adapter.js';
function fixtureBytes(path: string): Uint8Array {
const encoded = readFileSync(new URL(path, import.meta.url), 'utf8');
return Uint8Array.from(Buffer.from(encoded.replaceAll(/\s/gu, ''), 'base64'));
}
describe('worker parser adapter', () => {
it('opens a real section and retains stable page identifiers', () => {
const result = openSectionForWorker(
fixtureBytes('../../tests/fixtures/onenote_desktop.one.b64'),
'Section.one'
);
expect(result.section.pages).toHaveLength(3);
const first = result.section.pages[0]!;
expect(result.pages.get(workerPageKey(first.id))).toMatchObject({
id: first.id,
title: 'Test',
text: expect.stringContaining('This notebook should have three pages.'),
});
});
it('opens a real ONEPKG and addresses page IDs without rewriting them', async () => {
const result = await openPackageForWorker(
fixtureBytes('../../tests/fixtures/joplin-test.onepkg.base64'),
'Notebook.onepkg'
);
expect(result.notebook.entries).toHaveLength(7);
expect(result.notebook.sections).toHaveLength(5);
expect(
result.notebook.sections.every(
(section) => section.parseStatus === 'parsed'
)
).toBe(true);
expect(
result.notebook.diagnostics.map((diagnostic) => diagnostic.code)
).toContain('PACKAGE_TOC_ORDER_NOT_INTERPRETED');
for (const packagedSection of result.notebook.sections) {
for (const page of packagedSection.section?.pages ?? []) {
expect(
result.pages.get(workerPageKey(page.id, packagedSection.id))?.id
).toBe(page.id);
}
}
});
it('converts malformed section input into a controlled worker failure', () => {
try {
openSectionForWorker(new Uint8Array(20), 'broken.one');
} catch (error) {
expect(failureFromUnknown(error)).toMatchObject({
code: 'invalid-format',
recoverable: false,
});
return;
}
throw new Error('Expected malformed input to fail');
});
});

View File

@@ -0,0 +1,164 @@
import { OneNoteParserError, parseOneNoteSection } from '../onenote/index.js';
import type {
OneNotePackageDto,
OneNotePageDto,
OneNotePageSummaryDto,
OneNoteSectionDto,
} from '../onenote/model/dto.js';
import { OnePkgError, openOneNotePackage } from '../onenote/onepkg/index.js';
import type { WorkerFailure } from './worker-protocol.js';
export interface ParsedWorkerSection {
section: OneNoteSectionDto;
pages: Map<string, OneNotePageDto>;
}
export interface ParsedWorkerPackage {
notebook: OneNotePackageDto;
pages: Map<string, OneNotePageDto>;
}
function pageDto(page: OneNotePageSummaryDto): OneNotePageDto {
return {
...page,
blocks: page.text ? [{ kind: 'text', text: page.text }] : [],
diagnostics: [],
};
}
export function workerPageKey(pageId: string, sectionId?: string): string {
return JSON.stringify([sectionId ?? null, pageId]);
}
export function openSectionForWorker(
bytes: Uint8Array,
sourceName: string
): ParsedWorkerSection {
const section = parseOneNoteSection(bytes, { sourceName });
return {
section,
pages: new Map(
section.pages.map((page) => [workerPageKey(page.id), pageDto(page)])
),
};
}
export async function openPackageForWorker(
bytes: Uint8Array,
sourceName: string
): Promise<ParsedWorkerPackage> {
const result = await openOneNotePackage(bytes, {
sourceName,
cancellation: {
yield: () => new Promise((resolve) => setTimeout(resolve, 0)),
},
parseSection: ({ sourceName: sectionName, bytes: sectionBytes }) => {
try {
return {
status: 'parsed',
value: parseOneNoteSection(sectionBytes, { sourceName: sectionName }),
};
} catch (error) {
if (
error instanceof OneNoteParserError &&
error.code === 'UNSUPPORTED_FORMAT'
) {
return {
status: 'unsupported',
diagnostics: [
{
severity: 'warning',
code: error.code,
message: error.message,
offset: error.offset,
structure: error.structure,
recoverable: true,
},
],
};
}
throw error;
}
},
});
const pages = new Map<string, OneNotePageDto>();
const sections = [...result.package.sections]
.sort((left, right) => left.path.localeCompare(right.path))
.map((packagedSection) => {
if (!packagedSection.section) return packagedSection;
const section = {
...packagedSection.section,
pages: packagedSection.section.pages.map((page) => {
pages.set(workerPageKey(page.id, packagedSection.id), pageDto(page));
return page;
}),
};
return { ...packagedSection, section };
});
return {
notebook: {
...result.package,
sections,
diagnostics: [
...result.package.diagnostics,
...(sections.length > 0
? [
{
severity: 'warning' as const,
code: 'PACKAGE_TOC_ORDER_NOT_INTERPRETED',
message:
'OneNote notebook hierarchy and TOC ordering are not interpreted yet; sections are shown in normalized package-path order.',
structure: 'OneNote package section navigation',
recoverable: true,
},
]
: []),
],
},
pages,
};
}
export function failureFromUnknown(error: unknown): WorkerFailure {
if (error instanceof OneNoteParserError) {
const code =
error.code === 'LIMIT_EXCEEDED'
? 'limit-exceeded'
: error.code === 'UNSUPPORTED_FORMAT'
? 'unsupported-format'
: 'invalid-format';
return {
code,
message: error.message,
recoverable: false,
offset: error.offset,
structure: error.structure,
};
}
if (error instanceof OnePkgError) {
const parserCode = error.diagnostic.code.toLowerCase();
return {
code: parserCode.includes('limit')
? 'limit-exceeded'
: parserCode.includes('unsupported')
? 'unsupported-format'
: 'invalid-format',
message: error.diagnostic.message,
recoverable: error.diagnostic.recoverable,
offset: error.diagnostic.offset,
structure: error.diagnostic.structure,
};
}
return {
code: 'worker-failure',
message:
error instanceof Error
? error.message
: 'An unexpected parser worker error occurred.',
recoverable: false,
};
}

View File

@@ -0,0 +1,87 @@
import type {
OneNotePackageDto,
OneNotePageDto,
OneNoteSectionDto,
} from '../onenote/model/dto.js';
export type WorkerRequest =
| {
type: 'parse-one';
requestId: string;
fileName: string;
bytes: ArrayBuffer;
}
| {
type: 'parse-onepkg';
requestId: string;
fileName: string;
bytes: ArrayBuffer;
}
| {
type: 'get-page';
requestId: string;
sessionId: string;
pageId: string;
sectionId?: string;
}
| {
type: 'dispose';
sessionId: string;
};
export interface WorkerFailure {
code:
| 'invalid-format'
| 'unsupported-format'
| 'limit-exceeded'
| 'page-not-available'
| 'session-not-available'
| 'worker-failure';
message: string;
recoverable: boolean;
offset?: number;
structure?: string;
}
export type WorkerResponse =
| {
type: 'section';
requestId: string;
sessionId: string;
section: OneNoteSectionDto;
}
| {
type: 'package';
requestId: string;
sessionId: string;
notebook: OneNotePackageDto;
}
| {
type: 'page';
requestId: string;
sessionId: string;
page: OneNotePageDto;
}
| {
type: 'failure';
requestId: string;
error: WorkerFailure;
}
| {
type: 'disposed';
sessionId: string;
};
export type ParseWorkerRequest = Extract<
WorkerRequest,
{ type: 'parse-one' | 'parse-onepkg' }
>;
export type ParseWorkerResponse = Extract<
WorkerResponse,
{ type: 'section' | 'package' | 'failure' }
>;
export type PageWorkerResponse = Extract<
WorkerResponse,
{ type: 'page' | 'failure' }
>;

View File

@@ -0,0 +1,74 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
import { describe, expect, it } from 'vitest';
import { BinaryReader } from '../src/onenote/binary/BinaryReader.js';
import { parseObjectPropSet } from '../src/onenote/onestore/property-set.js';
import { OneNoteParserError } from '../src/onenote/parser/error.js';
import { resolveParserLimits } from '../src/onenote/parser/limits.js';
describe('bounded OneStore primitives', () => {
it('reports an absolute offset instead of reading beyond its view', () => {
const reader = new BinaryReader(
Uint8Array.from([0, 1, 2, 3, 4, 5]),
2,
3,
'test view'
);
expect(reader.u16()).toBe(0x0302);
try {
reader.u16();
expect.unreachable('the read should fail');
} catch (error) {
expect(error).toBeInstanceOf(OneNoteParserError);
expect(error).toMatchObject({
code: 'BOUNDS_EXCEEDED',
offset: 4,
structure: 'test view',
});
}
});
it('decodes a bounded object property set without reading Boolean bytes', () => {
const bytes = Uint8Array.from([
0x00,
0x00,
0x00,
0x80, // OID stream: zero IDs, OSID stream absent
0x02,
0x00, // two properties
0x01,
0x00,
0x00,
0x88, // Boolean true
0x02,
0x00,
0x00,
0x1c, // byte vector
0x03,
0x00,
0x00,
0x00,
0xaa,
0xbb,
0xcc,
]);
const parsed = parseObjectPropSet(
bytes,
{ offset: 0, size: bytes.length, nil: false, zero: false },
resolveParserLimits()
);
expect(parsed.properties.entries[0]?.value).toEqual({
kind: 'bool',
value: true,
});
expect(parsed.properties.entries[1]?.value).toMatchObject({
kind: 'bytes',
value: Uint8Array.from([0xaa, 0xbb, 0xcc]),
});
});
});

View File

@@ -0,0 +1,83 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
OneNoteParserError,
detectOneNoteFormat,
parseOneNoteSection,
} from '../src/onenote/index.js';
const fixture = Buffer.from(
readFileSync(
join(process.cwd(), 'tests/fixtures/onenote_desktop.one.b64'),
'utf8'
).replace(/\s/g, ''),
'base64'
);
describe('desktop OneStore section parser', () => {
it('parses the pinned Joplin golden file into serializable page text', () => {
expect(fixture).toHaveLength(34_904);
expect(createHash('sha256').update(fixture).digest('hex')).toBe(
'7c43e85e2169b4bc3236db73a24c8f20d5c284b74563c07baf1406887d20eb5e'
);
expect(detectOneNoteFormat(fixture).kind).toBe('desktop-one');
const parsed = parseOneNoteSection(fixture, {
sourceName: 'onenote_desktop.one',
});
expect(structuredClone(parsed)).toEqual(parsed);
expect(parsed.pages).toHaveLength(3);
expect(parsed.pages[0]).toMatchObject({
id: '{27CDD549-7E76-45B0-B18D-5A9BCF261CCB}',
title: 'Test',
createdAt: '2025-10-17T20:59:14.797Z',
updatedAt: '2025-10-17T21:38:35.000Z',
level: 1,
});
expect(parsed.pages[0]!.text).toContain('Test!');
expect(parsed.pages[0]!.text).toContain('3+3=6');
expect(parsed.pages[0]!.text).toContain(
'This notebook should have three pages.'
);
expect(parsed.pages[1]).toMatchObject({
id: '{A7185C1A-6895-48FA-B9DD-380B511D0F82}',
title: 'Another page',
createdAt: '2025-10-17T21:00:24.848Z',
updatedAt: '2025-10-17T21:00:57.000Z',
level: 1,
});
expect(parsed.pages[1]!.text).toContain('Testing!');
expect(parsed.pages[2]).toMatchObject({
id: '{1A5CF75B-BF4A-4F37-B48C-F5493F4ED828}',
title: 'Page 3',
createdAt: '2025-10-17T21:38:42.795Z',
updatedAt: '2025-10-17T21:38:49.000Z',
level: 1,
});
expect(parsed.pages[2]!.text).toContain(
'This page is nearly empty. It has a single text box and not much else.'
);
const codes = parsed.diagnostics.map(({ code }) => code);
expect(codes).toContain('UNSUPPORTED_IMAGE');
expect(codes).toContain('UNSUPPORTED_TABLE');
expect(codes).toContain('UNSUPPORTED_INK');
});
it('fails malformed input with a controlled parser error', () => {
expect(() => parseOneNoteSection(new Uint8Array(63))).toThrowError(
OneNoteParserError
);
});
});

93
tests/file-node.test.ts Normal file
View File

@@ -0,0 +1,93 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
import { describe, expect, it } from 'vitest';
import { DesktopFileNodeParser } from '../src/onenote/onestore/file-node.js';
import { OneNoteParserError } from '../src/onenote/parser/error.js';
import { resolveParserLimits } from '../src/onenote/parser/limits.js';
const MAGIC = 0xa4567ab1f5f7f4c4n;
const FOOTER = 0x8bc215c38233ba4bn;
describe('DesktopFileNodeParser', () => {
it('rejects a multi-fragment cycle immediately', () => {
const bytes = new Uint8Array(72);
writeFragment(bytes, 0, 0x10, 0, 36);
writeFragment(bytes, 36, 0x10, 1, 0);
const parser = new DesktopFileNodeParser(bytes, resolveParserLimits());
const parse = () =>
parser.parseList({ offset: 0, size: 36, nil: false, zero: false });
expect(parse).toThrowError(OneNoteParserError);
expect(parse).toThrow(/cycle/i);
});
it('stops decoding at ChunkTerminatorFND and permits an opaque tail', () => {
const bytes = new Uint8Array(52);
const view = new DataView(bytes.buffer);
view.setBigUint64(0, MAGIC, true);
view.setUint32(8, 0x10, true);
view.setUint32(12, 0, true);
view.setUint32(16, 0x10ff, true); // id 0xff, declared size 4
bytes.fill(0xa5, 20, 32); // deliberately not valid FileNode words
view.setBigUint64(32, 0xffffffffffffffffn, true);
view.setUint32(40, 0, true);
view.setBigUint64(44, FOOTER, true);
const parser = new DesktopFileNodeParser(bytes, resolveParserLimits());
const parsed = parser.parseList({
offset: 0,
size: bytes.length,
nil: false,
zero: false,
});
expect(parsed.nodes).toEqual([]);
});
it('uses the transaction-log count to delimit nonzero fragment padding', () => {
const bytes = new Uint8Array(52);
const view = new DataView(bytes.buffer);
view.setBigUint64(0, MAGIC, true);
view.setUint32(8, 0x10, true);
view.setUint32(12, 0, true);
view.setUint32(16, 0x1004, true); // id 4, declared size 4
bytes.fill(0xa5, 20, 32); // opaque tail without a terminator
view.setBigUint64(32, 0xffffffffffffffffn, true);
view.setUint32(40, 0, true);
view.setBigUint64(44, FOOTER, true);
const parser = new DesktopFileNodeParser(
bytes,
resolveParserLimits(),
new Map([[0x10, 1]])
);
const parsed = parser.parseList({
offset: 0,
size: bytes.length,
nil: false,
zero: false,
});
expect(parsed.nodes.map(({ id }) => id)).toEqual([0x004]);
});
});
function writeFragment(
bytes: Uint8Array,
offset: number,
listId: number,
sequence: number,
nextOffset: number
): void {
const view = new DataView(bytes.buffer, offset, 36);
view.setBigUint64(0, MAGIC, true);
view.setUint32(8, listId, true);
view.setUint32(12, sequence, true);
view.setBigUint64(16, BigInt(nextOffset), true);
view.setUint32(24, 36, true);
view.setBigUint64(28, FOOTER, true);
}

64
tests/fixtures/README.md vendored Normal file
View File

@@ -0,0 +1,64 @@
# Fixture provenance
No private notebook may be committed. Each binary fixture requires a public,
reviewed source, an immutable revision, a redistribution licence, a content
expectation, and a retained hash. Base64 encoding is only a repository transport
and does not change the underlying file's licence.
## `onenote_desktop.one.b64`
- Source: Joplin
[`packages/app-cli/tests/support/onenote/onenote_desktop.one`](https://github.com/laurent22/joplin/blob/28eb53bd9f0cd4ae0dec0c59a08037075a38302b/packages/app-cli/tests/support/onenote/onenote_desktop.one),
introduced at commit
[`28eb53bd9f0cd4ae0dec0c59a08037075a38302b`](https://github.com/laurent22/joplin/commit/28eb53bd9f0cd4ae0dec0c59a08037075a38302b).
- Licence: AGPL-3.0-or-later; full text in `LICENSES/AGPL-3.0.txt`.
- Decoded size: 34,904 bytes.
- Decoded SHA-256:
`7c43e85e2169b4bc3236db73a24c8f20d5c284b74563c07baf1406887d20eb5e`.
- Golden expectation: desktop OneStore section with three pages — `Test`,
`Another page`, and `Page 3` — plus readable text/timestamps and diagnostics
for image, table, and ink content.
## `joplin-test.onepkg.base64`
- Source: Joplin
[`packages/app-cli/tests/support/onenote/test.onepkg`](https://github.com/laurent22/joplin/blob/1e73aad7eb08fde5d9e4cb533df40052a5cd32d7/packages/app-cli/tests/support/onenote/test.onepkg)
at commit `1e73aad7eb08fde5d9e4cb533df40052a5cd32d7`, introduced by
[Joplin PR #14094](https://github.com/laurent22/joplin/pull/14094).
- Licence: AGPL-3.0-or-later; full text in `LICENSES/AGPL-3.0.txt`.
- Decoded size: 11,066 bytes.
- Decoded SHA-256:
`83cb622d40f0ab127038b629553b6926be3e2a6ce642f169df0e2614c13e8469`.
- CAB expectation: one LZX `0x1203` folder, three CFDATA blocks, seven UTF-8
and nested paths, five `.one` sections, and two `.onetoc2` entries.
- Parser expectation: all five sections parse, producing seven pages in total:
`Page 1`, `Page 2`, `Test`, `Test page`, `Testing…`, `A test`, and
`Tuesday, January 13, 2026`.
Tests verify the package hash, all seven paths, the native `.one` magic for
every section, section parse statuses, and representative title/text output.
## Inline rust-cab CAB fixtures
`src/onenote/onepkg/cabinet.test.ts` contains byte-for-byte test arrays copied
from rust-cab `src/cabinet.rs` at commit
[`c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2`](https://github.com/mdsteele/rust-cab/tree/c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2).
They are MIT-licensed; the exact upstream text is in
`LICENSES/rust-cab-MIT.txt`.
The arrays cover an uncompressed single-entry CAB, an uncompressed two-entry
CAB, and an LZX `0x1303` CAB containing `hi.txt` and `bye.txt`. They are CAB
unit fixtures, not OneNote exports, and are not evidence of `.onepkg` producer
compatibility.
## Distribution policy
The decoded fixture bytes are used only by tests. Vite does not copy them into
`dist`, and the release packager does not add them to the application ZIP. The
release may include this provenance document and licence text so its notices
remain complete.
A copied upstream fixture needs an individual licence and redistribution
review; a repository-level licence must not be assumed to cover every binary.
Generated fixtures must document the generator command/input and must not be
described as real application exports.

123
tests/fixtures/joplin-test.onepkg.base64 vendored Normal file
View File

@@ -0,0 +1,123 @@
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

613
tests/fixtures/onenote_desktop.one.b64 vendored Normal file
View File

@@ -0,0 +1,613 @@
5FJce4zYp02usVN40CmW04I9Xd2xTcNFr4sU1DnPIloAAAAAAAAAAAAAAAAAAAAAP92aEBuR9Uml
0BeR7ciu2CoAAAAqAAAAKgAAACoAAAAAAAAAAAAAAP////8AAAAAHgAAAAAAAAAAAAAAAAAAAP//
//8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIhIAAAAAAAAAAQAAAAIAAAAAAAAaAkA
AAAEAAAAAAAAAAQAAP//////////AAAAAFiIAAAAAAAASAAAAAAAAAB7imxM14TUSIBpD9JgoQCk
WwAAAAAAAACa8y+bI/UGRKDsaWzPa/jiAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPax8Dr2
sfA69rHwOvax8DoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMT0
9/WxelakEAAAAAAAAAAIbACVLQIkSkB3GrP0i0aXbGBF41TEagEAAAAEYICASkB3GrP0i0aXbGBF
41TEagEAAAAIbACV/gIkcRzRR+FsFUWTEWg2Fvz2TwEAAAAIbACVrAgkdC0xMs9rdUia3OOLQTpX
fwEAAACQHACVPQ4kCGwAlaUMJIvdQh+UOmxHiZC4rvZt8pkBAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////////8AAAAAS7ozgsMVwosQAAAA
AAAAAAEAAABC7pkZEAAAAAIAAAARAAAAAQAAAAEAAAAGlT0TEQAAAAIAAAASAAAAAQAAAAEAAADd
4WE1EgAAAAcAAAATAAAAFQAAAAEAAADBZTL0EAAAAAMAAAAUAAAAAQAAAAEAAAAQvIY3FAAAAAIA
AAAVAAAAAQAAAAEAAABwN9uZFgAAAAAAAAABAAAAvTaj7BYAAAABAAAAAQAAAEv077gWAAAAAgAA
AAEAAADRRJgNFgAAAAMAAAABAAAA+3b2gxYAAAAEAAAAAQAAAAdC/2cWAAAABQAAAAEAAABMZqw/
FgAAAAYAAAABAAAAzHT7vxYAAAAHAAAAAQAAAF0bMv4WAAAACAAAAAEAAABR/sfRFgAAAAkAAAAB
AAAA7qWTcBUAAAAIAAAAFwAAAF8AAAABAAAAaFocHhUAAAAOAAAAGAAAAAgAAAABAAAAP+VA/xAA
AAAEAAAAGQAAAAEAAAABAAAA+kgRnhkAAAACAAAAGgAAAAEAAAABAAAAYsgZyhAAAAAFAAAAGwAA
AAEAAAABAAAAI0YX8hYAAAAKAAAAAQAAAFGz0RQWAAAACwAAAAEAAAAh9+u3GgAAAAgAAAAcAAAA
JwAAAAEAAABgw5DuGgAAAA4AAAAdAAAACAAAAAEAAACNjHtsEAAAAAYAAAAeAAAAAQAAAAEAAABS
0ZDMHgAAAAIAAAAfAAAAAQAAAAEAAABQ657oFgAAAAwAAAABAAAAfsnmFB8AAAAIAAAAIAAAAB4A
AAABAAAAGXxljx8AAAAOAAAAIQAAAAgAAAABAAAALMo38QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAMT09/WxelakEQAAAAAAAAAMYICASkB3GrP0i0aXbGBF41TEagEAAAAQHACVUQIk
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
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
1ALoBewFqBuEBfQCjAFYgIAB4hXiFeIV4hXiFeIV4hXiFeIV2EWCEP8N/Q3/Dd0TtXrLQ/sdvgG8
IpsW3wcBAAAACQQAABAAAABc/6CpmmvPRanFqSKD+WlyCAAAAHRa2+moP9wBAAABAACAIwAAAA4A
eh0AFBQcABQVHAAUFTQAIE4dABSkNQAUnjQAFJ80ABSgNAAUoTQAFKI0ABSjNAAUpDQAFKU0ABQr
CCRWAAAAAAAAAAABAAAAAQAAADs39T+oupNASvIFQDD95D58guk/eM2QQAmnEUCUUSE/AAABAACA
JAAAAAEAFjQAJAEAAAAAAAAAAAABAACAHwAAAAgACzQAHBk0ABQbNAAQHDQADBA2AAwaNAAcHTQA
HAk0ACBkAAAAigGEJoAD5AXcBawLbGxsaGhk6AFY7AEwNDQ0NIgBXPQBWLxkAAARhQEVGR0hJSl1
OakBKSUlISFBKYEBOYCAAcapAbqAAcMJ34IBqRapFqsWqRapFqkWqxZjYwAAAAAAAAAAAAIAAAAJ
BAAAEAAAAGUrZHn/jZxJgRnONItyJ7wIAAAAmdhM6qg/3AEAAAAAAAABAACAJwAAAA4Aeh0AFBQc
ABQVHAAUFTQAIE4dABSkNQAUnjQAFJ80ABSgNAAUoTQAFKI0ABSjNAAUpDQAFKU0ABQuCCRWAAAA
AAAAAAABAAAAAQAAAJ6bOkDqdKpAP4aJQLqQm0A/wTRAuoenQJ5gj0AZa6FAAAABAACAKAAAAAEA
FjQAJAEAAAAAAAAAAAABAACAHwAAAAgACzQAHBk0ABQbNAAQHDQADBA2AAwaNAAcHTQAHAk0ACAy
DQAA1izwOQAEAAQABAQEBAQEBAgQeBwwNDg4ODg8nAGsAZgDgAIgIBwgIBwgHBwgHBwYHBgcGBQY
FBgQFBAQEBAYBAQABAAEAAAAAAAAAAAFAAUABQAFBQUFBQkNCQ0NDQkNDQ0NEQ0lVVVVVVG9AQ05
OTU1NTUxMS0xVSEhHR0ZGRkVFRERDQ0RDQkFBCAEAAgEBAgICAgMDAgMEAwMJBgcHBwcHBwcIFBs
yAKUAawCwAG8AcwDZGRgYFxY4AGABWgwMGQQEAwIDAgICAgAAAAAAAAABQAJAAAAAAUAAAAFAAAF
AAAFAAAFAAAFAAAFAAUACQ0JCQkJCQUJCQkFDUE5NTUxWSkpRREJCQUFCAQABQAFAAUFAAUFBQUF
BQUFCQUFBQkFCQUJBQkFCQUJERERDRERERENERENKQAFAAAAAAAFAAAAAAAABQAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAQsDJQFjAGEAdgCoAKIBcQCdHRo8AFIQIABLFAgDAkV
NVWlAZUB/QbRBf0ItQLxBCFdDQkJBQUFAAAEAAAAAAQABAAEAAQEAAQIBAQEBAQEBAQEBAQIBAQE
BAwYHBgYGDQEcOgHkAGQAYgBhAG0Ajw8iAFYAAQAAAQAAAQABAQABAAEBAAIAAAEAAAEAAAABAAA
AAQAAAAABAAAAAAAAAAAAAgABAAFCQ0NRd0BeRk1CQUFBQAFAAAAAAAAAAAAAAAEAAAAAAQAAAAE
AAAEAAAEAAAEAAQABAAABAAEAAQABFSQAfwC7AOQAmywAwQIBAgICAQICAgIGAAEAAQABAAABAAE
AAAEAAAEAAAEAAAQDQUFBQAAAAQAAAAEAAAEAAQEAAQEBAQEAAQIBAQEBAQECAQEBAgEBAgMHBwg
HBxILMgDDAwQDAwQDBAMEAwMKAgMCAgIBAgICAgQMAwMCAgICAQECBwIBAgICAgICAwIDAwMDBwc
HCAcICAgICAgIFw8ODw4OJQBLDAsLCwoJFA8PDhoICAcHBQUECAIBBElBckD6QHJAxERDQ0JDREA
AAUAAAAAAAAAAAAABAAAAAQAAAQABAAEAAQABAQABAQABAQEAAQEBAAIFBQYFBQUFBQYFDRMrAEw
eHh0cGhogAJgzAEoAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAEAAAAAAAAAAAABAAAAAAA
AAAAAAAABAAAAAAAAAURFRUZRU3VBMUClQSFAUGdAT1JCQkFCQkNCQkNCQ0NDQkNDSEJCQkNCQkJ
DQkJCQkJDQkJBQkJCQUJCQ0RERENEQkNCQkJBQkFBQAUCAwQEBAUGBgcSFxc2H0IDAgMCAgMCAgI
DAgIFHQULCwoJCQgHEA0ZCQAAAUABQkJCQkNDQ0RERERFRUVGRkZGRkdGR1FBQkFCQkFCQkFCQkF
CQkFCQkFCQUJBQkFCQkJCQkFCQUFBQUFAAUAAAQIDBA0BBAQFBgYHCAgICRQICAkJCQoKCgoLCws
LFyAAYABgAF8pAIUEBQQFBAUEBAQEBAQDBAMHBQMEAwMDAgICAwMAAktKTWdATExOTlBQckBzQV9
PUGdAR0dHR0dIR0hVQkFCQUJBQkFCREABQAABQAAAAUAAAAFAAAAAAAABQAAAAAAAAAEBAQEBAQE
BAgECAQQREhIUEyoAVxg3AFESEhISJgBqAIECAQIBAgECAQECAQEBAQIBAAEBAQEAAQEAAAEAAAA
AAQAAAAABQAFBQUFFQAAAAAAAAAAAAAABQAAAAAABQAAAAAABQAAAAAABQAAAAAFAAAAAAAFAAAA
AAAABQAAAAAZCd0CVVmVApkCtQXhAoUBjQGNAfkChQGJAckCkQHtAtkBfSUEOHigAoQC6ArMCJwP
0AS0C1SYAigkKCRsCAQEBAQIBAQEBAQECAQEBAQECAQABAAABAAABAAAAAAAAAAFCQkJDQ0dBUHl
BGltdX3NAlVd+QGpAQUABQUABQUABQUABQAFAAAFBQAEAAAAAAAABAAAAAAEAAAABAAAAAQAAAQA
AAQUFBRAUFRQVOwBqASoAjSQARAQEBAQNAAABAAEAAQAAAQABAAEAAAEAAAEAAAEAAAEAAAAAAQA
AAAAAAAAAAAAAAAhTfkBgQPdAVXJAgUFBQUFBQUFBQAFBQAAAAAEAAAAAAAEAAAABAAABAAABARA
uAEwNIABBAQIBAQIBAQIBAQEBAgEBAQEBAQEBAAEBAQABAAEAAAEAAAAAAAFBQUJCR0RwQEFBQUF
BQUABQAAAAAIBAAEBAQEBAQIBBRIICQkJCRUJCgkbBAMDAwQDAwIDAwIDAgIEBAMDAgICAgEBAAA
BQkNDREROR0ZIR0lISlRTU1NsQE9QUFFRUVFvQFJWQwsBIgF2AKwBRwQFBQUFCwEBAAEBAQABAQE
BAAEBAQABAQEAAQEAAQEAAQABAAEAAAEAAAEAAAAAAAABAAEAAUABQUFBRkhXRlJUVVZXWWdAnm1
Aj0AAAAAAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAA
AAAAMDxAPECMAZwBwAuACIQM2AK0AYwDfHwMDAwIDAgMCAgICAgECAQICAAABAAFAAAFAAUABQUF
BQUFCQUJBQkFFRkZGR0ZHR0dHR0dRUVBRcEBNTk1NTE1MTExbX15gIABvg3ADcANvg3ADb4NwA3A
Db4NwA2+DcANwA2+DcpYjgSNAY0BjQGNAY0BjQGNAY0B2y3dLeAFsAOwA64DsAOwA7ADsAOwA64D
sAOwA7ADsAOuA7ADsAOwA7ADrgOwA7ADsAOwA64DsAOwA7ADsAMAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlQGVAZUBkwGVAZUBlQGVAZMBlQGVAQsLCwkLCwsL
CwsLCwsL0wbTBtUG0wbVBvUB9QH1AfMB9QH1AfUB9QH1AfUB9QH1AfUB9QH1AfUB8wHFBcMFxQXD
BcUFwwXFBcMFxQXDBZ9An0CpDacN2ALaAtoCAAAAAAAAAAAAIyEhhgGIAYYBiAGIAYYBiAGGAYgB
+gL6AvoC+gL6AvgC+gL6AvoC+gIcHhwcHhwcHhwcHhwcHhwcHhwcHhwcHhwcHhytAq8CrQKvAq0C
rQKvAq0CrQKvAq0CrwKtAuEC3wLhAt8C4QLhAuYN5g3oDcgcyBzIHMgcyhzIHMJHAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABr0wTVBNMExR+0Eps0qSqrKqkqqyqvCq8K
rwp9fdwvgLMBuBCxBQ2+FL4U3AKgAR8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAC2/GLEIsQizCLEIsQj9LP0s/yzyEdQD0gPSA9QD0gPSA9ID1APS
A9ID1APSA9ID0gPUA9ID0gPUA44CjgKOAo4CjgKOAo4CjgKOAo4CjgKOAo4CjgKOAo4CjgKOAo4C
jgKOAowCjgKOAo4CjgKOAo4C4g7kDuIO4g71A/UD8wP1A/UD+xlitAvVHpcblRuVG5cblRuVGyMh
IyEhIyEhIyEhIyEhIyEhIyEjISEjISEjISEjISEjISEjISEjISMhISOEAesW6Rb2Av8BAADIAcoB
yAHIAcoByAHKAcgByAHKAcgBygGgAZ4BoAGgAaABoAGgAaABngGgAaABoAGgAaABoAGgAZ4BoAGg
AaABoAGgAeIV3mn4I/gj+CMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAC/EBoYGhoYGhoYGhoYGhgwLi4uLi4uLi4uLv0rW1lbW1tb9Aj0CPQI9AjAAcABwAHAAcAB
wAHAAcABwAHAAcABwAHAAcABwAHlCOUI4wjlCOUI5QjjCOUI5QjjCOUI5QjfFt8W3RbfFt8W3xbv
BPEE7wTvBPEE7wTvBPEEHyEfHwAAAAAAAAAA/A3etAGEG8Qn9AKNLqs8540B9wLtAe0B7QHtAe0B
7wEHBwUHBwcHBwcHBwcHBwUHBwcHBwcHBwcHBwcFBwcHBwcHBwcHBwcFBwcHAAAAAAAAAAAAAAAA
AABubm5wbm5uoEagRoxvQkJCQkJCQkJCQkJEQkJCQkJCQkJCQkJCQkJCQkJEQkJCQkJCQkJCQkJC
QkJCQkJCREJCQkJCQkJCQv8FwwXBBcMFwQXDBZUQj8IB5SsAAKAHmgvSRuqIAagDqgOqA6gDqgOo
A6oDqgOoA6oDqgOoA6oDqAOqA6oDqAMFBQUFBQcFBQUFBQcFBQUFBQUHBQUFBQUAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAwAAAAkEAAAQAAAAkkB7IcUdUkuw/nq1o4RBkggAAAC1hXbsqD/c
AQEAAIAuAAAADgB6HQAUFBwAFBUcABQVNAAgTh0AFKQ1ABSeNAAUnzQAFKA0ABShNAAUojQAFKM0
ABSkNAAUpTQAFC8IJFYAAAAAAAAAAAEAAAABAAAASVivQEJU7UAAcM46AHDOOhlrrEASZ+pAzug8
Ps7oPD4AAAEAAIAvAAAAAQAWNAAkAQAAAAAAAAAAAAEAAIAfAAAACAALNAAcGTQAFBs0ABAcNAAM
EDYADBo0ABwdNAAcCTQAIAkAAAAG4GyYkwGAgAEEAAAACQQAABAAAAAEiGhYfzXwQJS3uiBYzhC+
CAAAAABvJe2oP9wBAAEAAIA1AAAADgB6HQAUFBwAFBUcABQVNAAgTh0AFKQ1ABSeNAAUnzQAFKA0
ABShNAAUojQAFKM0ABSkNAAUpTQAFDAIJFYAAAAAAAAAAAEAAAABAAAAfFeVQN+itECOFBdAyJR9
P0xqkkCvtbFATckiQOIzlj8AAAEAAIA2AAAAAQAWNAAkAQAAAAAAAAAAAAEAAIAfAAAACAALNAAc
GTQAFBs0ABAcNAAMEDYADBo0ABwdNAAcCTQAIEsAAABa0FwMKFyEAdQB6AKkBOwF2AbYBoAG5ATI
A6wChHAUNEBQdKwBiALQAvQC1AKcAtABhAFIgIABzJkCAAAAty4As1sAq3wAuxMAAAAFAAAACQQA
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
5AbwAoAG1Am4AbwBuAG4AYADsAGUBKwCdHh4lALgAdgCGBgYGBgYFBgUGBQUFDgoKCgkXDw4NDBM
OIACdEg8LCwEDBRI9AFgcJQC+AOoAcwD8AKYAegBwAIwJCAUEAQEKGh0JLAB7AGIArgEIBgQAAUF
BQUJCQkJDQ0pFRUVGRkZGR0dRYEBvQaBBZ0HjQW5Fc0P7Qn5LOUD1QrdEA2BBgkJCQkJBQkJCQkJ
EQAFBQUFBQAFBQAFAAUABQAABQAAAAUAAAAABAgoIHikAZgBpATsAjQYUX11tQJRHSUJAAAFCQ0p
ba0BuQE9ZZEC+QIxLUkJJQkNDQ01HcyZAUpISkhKSEpISkhKSEpISEpISkhKSEpISkhKSEpIoguI
B4gHhgeIB4YHiAeGB/Ykigaqc+YC5gLmAuYC5ALCBgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAABzc3Nzc3Nzc3NzcXOrAqkCqwKpAqsCqQKrAqkCqwKrAuEWnVuzT4MeogEAAAAAAAAA
AAAuLi4uLi4uLi4uLi6iAaABogGgAaIBoAGiAaABoAGiAaABogGgAaIBoAGiAaABogGgAaIBoAGi
AaABogGsFKwUrBSsFKwU5h7oSZYPAAAAALEKlSOrf71BxAGeaciDAQAAT09NT09NzTHLMft69xPX
B5EDPMINwA3CDfYEqhXKA8wDygPMA8oDXAoAAAAJBAAAEAAAAKYNIISyTPZOjm8QHx5xGD4IAAAA
uJUW9Kg/3AEAAAAAAAEAAIBgAAAADgB6HQAUFBwAFBUcABQVNAAgTh0AFKQ1ABSeNAAUnzQAFKA0
ABShNAAUojQAFKM0ABSkNAAUpTQAFD8IJFYAAAAAAAAAAAEAAAABAAAA/P0mQc9NbUAAYM46AGjO
OmSHJUFwc2dArug8Pr7oPD4AAAEAAIBhAAAAAQAWNAAkAQAAAAAAAAAAAAEAAIBcAAAACAALNAAc
GTQAFBs0ABAcNAAMEDYADBo0ABwdNAAcCTQAIAkAAAAGkM8BzEnMmQELAAAACQQAABAAAADLh36c
DrrqSrdm63cJyBSnCAAAAPKsQvaoP9wBAAAAAIACANg1ABxUNgAUFAAAAAAAAAAAAAAAAAAAAAAA
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
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAA//////////8AAAAAS7ozgsMVwosAAACAAwD4HQAcdR0AHDAe
ABwGAAAASABIAAAAGAAAAEgAZQBuAHIAeQAgAEgAZQBpAG4AbwAAAAIAAAAAAAAAAAAAgA4AWjQA
HAQcAAgFHAAIBhwACAwcABQuNAAULzQAFDA0ABQHHAAIDRwAFAkcAAgIHAAIChwAHAscABAUAAAA
UABhAGcAZQBUAGkAdABsAGUAAAAAAAD/AAAAAAAAAAAAAAAAAAAA/xwAAABDAGEAbABpAGIAcgBp
ACAATABpAGcAaAB0AAAAKAAAAAAAAAAAgA4AWjQAHAQcAAgFHAAIBhwACAwcABQuNAAULzQAFDA0
ABQHHAAIDRwAFAkcAAgIHAAIChwAHAscABAaAAAAUABhAGcAZQBEAGEAdABlAFQAaQBtAGUAAAB2
dnYAAAAAAAAAAAAAAAAAAAAA/xAAAABDAGEAbABpAGIAcgBpAAAAFAAAAAAAAIACAPgdABx1HQAc
BgAAAEgASAAAABgAAABIAGUAbgByAHkAIABIAGUAaQBuAG8AAAAAAAAAAAAAgAoAFh4ACKo0AAgU
HgAIGR4ACDscABQBHQAMUjQADAE0AAgFNAAIBjQACAkEAAAAAAAAAAAAAACABAAKNAAcDzQAFAw0
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
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAD//////////wAAAABLujOCwxXCiwEAAEAMAAAAAAAAQAEAAAABAQAAAgAfHAAkezQANAEAAAAB
AAAAAAAAAACABgDzHAAcMBwAHP8dABSCHQAUizQAFGUcABgaAAAAQQBuAG8AdABoAGUAcgAgAHAA
YQBnAGUAAAAQAAAAGlwYp5Vo+ki53TgLUR0PggEAAAAoAAAAKAAAAAAJCA+pP9wBAAAAAAMAAIAk
AAAAMwAAAA0AAAAUAHodABQgHAAkXx0AJNwcAIiOHACITBwAFE0cABROHAAUTxwAFAEcABQCHAAU
nhwAFJ8cABSgHAAUoRwAFHUdABz4HQAckhwACA8dABQQHQAUiQgkVgIAAAABAAAAAACAPwAAgD8A
AABAAAAAQFCNp0EAAK5BAAAAPwAAAD8AAIA+AACAPhgAAABIAGUAbgByAHkAIABIAGUAaQBuAG8A
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
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAD//////////wAAAABLujOCwxXCi8T09/WxelakHwAAAAAAAAAUcICAi91CH5Q6bEeJkLiu
9m3ymQEAAAAAAAAAHsiAgA8SC0IBwxlNuHjyksa4SfsBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB
AAAAAACwbACVNxBUF5e/HFAwoQ4Ww24n8DB/JgAAAACEZACM//////////8AAAAAAAAAAABv0i+x
WnCAgDqBEp0wektBri663LwAQOUKAAAAAQAAAFpwgIA6gRKdMHpLQa4uuty8AEDlCwAAAAIAAABa
cICAOoESnTB6S0GuLrrcvABA5RoAAAAEAAAAHBCAgP8QAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAABYhAAAAAAAAAAEAABLujOCwxXCiwEAAEAMAAAAAAAAQAEAAAABAQAAAgAfHAAkezQA
NAEAAAABAAAAAAAAAACABgDzHAAcMBwAHP8dABSCHQAUizQAFGUcABgOAAAAUABhAGcAZQAgADMA
AAAQAAAAW/dcGkq/N0+0jPVJP07YKAEAAAAoAAAAKAAAALDrtmiuP9wBAgAAgB0AAAANAAAAFAB6
HQAUIBwAJF8dACTcHACIjhwAiEwcABRNHAAUThwAFE8cABQBHAAUAhwAFJ4cABSfHAAUoBwAFKEc
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
AAEBpEQAja4PDw4AAAAMAAYAAQGkRACN/g8OGQAAAA4ABgABAcSEAI28CQcbAAAAAQASAAABex3p
sZAjgkiiuKJqQHRv+qREAI19DwUKAAAANwAGAAMBuBCAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAD//////////wAAAABLujOCwxXCi8T09/WxelakHwAAAAEAAAAfGIGA5Db36JlU3k2DJGpS8YLu
AQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAH9JEXFrGwlClJHJiwTPTFoBAAAAsGwAldAM
FPyzQ7bIp2YOLZ/258cK2NwAAAAAhGQAjP//////////AAAAAAAAAAAAkrg0EVpwgIB/SRFxaxsJ
QpSRyYsEz0xaAgAAAAEAAABacICAf0kRcWsbCUKUkcmLBM9MWgMAAAACAAAAHBCAgAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
//////////8AAAAAS7ozgsMVwos=

5
tests/golden/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Golden parser results
No golden results exist in Phase 0. Future checked-in normalized results must
identify their fixture and reference-oracle revision. Normal tests must not
require Rust.

View File

@@ -0,0 +1,121 @@
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
collectDirectoryFiles,
createThirdPartyLicenseBundle,
} from '../scripts/release-archive.mjs';
interface PackageReleaseModule {
collectReleaseMetadataFiles(
projectRoot: string
): Promise<Record<string, Buffer>>;
}
describe('release archive', () => {
it('creates identical bytes in different process timezones', () => {
const hashes = ['UTC', 'Europe/Berlin', 'America/New_York'].map(
(timezone) => {
const script = `
import { createHash } from 'node:crypto';
import { createDeterministicZip } from './scripts/release-archive.mjs';
const archive = createDeterministicZip({
'z.txt': Buffer.from('last'),
'a.txt': Buffer.from('first'),
});
process.stdout.write(createHash('sha256').update(archive).digest('hex'));
`;
const result = spawnSync(
process.execPath,
['--input-type=module', '--eval', script],
{
cwd: process.cwd(),
encoding: 'utf8',
env: { ...process.env, TZ: timezone },
}
);
expect(result.status, result.stderr).toBe(0);
return result.stdout;
}
);
expect(new Set(hashes)).toHaveLength(1);
});
it('collects every installed runtime license text', async () => {
const bundle = await createThirdPartyLicenseBundle(process.cwd(), [
'@add-ideas/toolbox-contract',
'@add-ideas/toolbox-shell-react',
'react',
]);
const text = bundle.toString('utf8');
expect(text).toContain('@add-ideas/toolbox-contract@0.1.1');
expect(text).toContain('@add-ideas/toolbox-shell-react@0.1.1');
expect(text).toContain('react@');
expect(createHash('sha256').update(bundle).digest('hex')).toMatch(
/^[a-f0-9]{64}$/
);
});
it('packages parser provenance and every required license text', async () => {
const moduleUrl = pathToFileURL(
path.join(process.cwd(), 'scripts', 'package-release.mjs')
).href;
const { collectReleaseMetadataFiles } = (await import(
moduleUrl
)) as PackageReleaseModule;
const files = await collectReleaseMetadataFiles(process.cwd());
expect(Object.keys(files).sort()).toEqual(
expect.arrayContaining([
'LICENSE',
'LICENSES/AGPL-3.0.txt',
'LICENSES/MPL-2.0.txt',
'LICENSES/THIRD-PARTY-LICENSES.txt',
'LICENSES/lzxd-MIT.txt',
'LICENSES/rust-cab-MIT.txt',
'THIRD_PARTY_NOTICES.md',
'docs/FORMAT_SUPPORT.md',
'docs/PORTING.md',
'docs/REFERENCE_IMPLEMENTATIONS.md',
'docs/SECURITY.md',
'tests/fixtures/README.md',
])
);
expect(
createHash('sha256')
.update(files['LICENSES/rust-cab-MIT.txt']!)
.digest('hex')
).toBe('ebaac853b53fec0ed475796f533ce676912a4df9ad9d8a621fa76d5060591e10');
expect(
createHash('sha256').update(files['LICENSES/lzxd-MIT.txt']!).digest('hex')
).toBe('c7a1dcbf839b1dfb72f24e1de12ab55f789db2c61d894ca9aa8b5f595490d9ca');
expect(files).not.toHaveProperty(
'tests/fixtures/joplin-test.onepkg.base64'
);
expect(files).not.toHaveProperty('tests/fixtures/onenote_desktop.one.b64');
});
it('rejects symbolic links in release input', async () => {
const temporaryDirectory = await mkdtemp(
path.join(os.tmpdir(), 'onenote-tools-release-')
);
try {
const outsideFile = path.join(temporaryDirectory, 'outside.txt');
const inputDirectory = path.join(temporaryDirectory, 'input');
await writeFile(outsideFile, 'private');
await mkdir(inputDirectory);
await symlink(outsideFile, path.join(inputDirectory, 'linked.txt'));
await expect(collectDirectoryFiles(inputDirectory)).rejects.toThrow(
'Unsupported filesystem entry in release input: linked.txt'
);
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
});
});

5
tools/oracle/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Development oracle
The optional `onenote.rs` oracle is not wired in Phase 0. When added, it will
run only as an explicit development command against redistributable fixtures.
The production build and normal test suite will remain Rust-free.

23
tsconfig.app.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"noUncheckedIndexedAccess": true,
"types": ["node", "vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src", "tests"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

17
tsconfig.node.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

14
vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
/// <reference types="vitest/config" />
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
base: './',
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
css: true,
restoreMocks: true,
},
});