feat: add local OneNote and ONEPKG reader
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
release/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
1
.npmrc
Normal file
1
.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/
|
||||||
6
.prettierignore
Normal file
6
.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
coverage
|
||||||
|
dist
|
||||||
|
release
|
||||||
|
package-lock.json
|
||||||
|
LICENSES/*.txt
|
||||||
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 80
|
||||||
|
}
|
||||||
24
CHANGELOG.md
Normal file
24
CHANGELOG.md
Normal 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
4
LICENSE
Normal 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
661
LICENSES/AGPL-3.0.txt
Normal 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
373
LICENSES/MPL-2.0.txt
Normal 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
21
LICENSES/lzxd-MIT.txt
Normal 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
21
LICENSES/rust-cab-MIT.txt
Normal 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
93
README.md
Normal 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
17
SOURCE.md
Normal 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
38
THIRD_PARTY_NOTICES.md
Normal 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
57
docs/FORMAT_SUPPORT.md
Normal 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
33
docs/PORTING.md
Normal 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`.
|
||||||
46
docs/REFERENCE_IMPLEMENTATIONS.md
Normal file
46
docs/REFERENCE_IMPLEMENTATIONS.md
Normal 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
113
docs/SECURITY.md
Normal 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
34
eslint.config.mjs
Normal 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
17
index.html
Normal 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
4007
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
58
package.json
Normal file
58
package.json
Normal 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
4
public/favicon.svg
Normal 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
33
public/toolbox-app.json
Normal 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"
|
||||||
|
}
|
||||||
64
scripts/generate-toolbox-manifest.mjs
Normal file
64
scripts/generate-toolbox-manifest.mjs
Normal 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
126
scripts/package-release.mjs
Normal 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));
|
||||||
|
}
|
||||||
12
scripts/release-archive.d.mts
Normal file
12
scripts/release-archive.d.mts
Normal 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
118
scripts/release-archive.mjs
Normal 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
333
src/App.test.tsx
Normal 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
410
src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/DiagnosticsPanel.tsx
Normal file
70
src/components/DiagnosticsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/FileDropZone.tsx
Normal file
70
src/components/FileDropZone.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/HelpDialog.tsx
Normal file
37
src/components/HelpDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/components/PackageInspector.tsx
Normal file
93
src/components/PackageInspector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/components/PageReader.tsx
Normal file
107
src/components/PageReader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
src/components/SectionNavigation.tsx
Normal file
133
src/components/SectionNavigation.tsx
Normal 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
14
src/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
158
src/onenote/binary/BinaryReader.ts
Normal file
158
src/onenote/binary/BinaryReader.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/onenote/binary/guid.ts
Normal file
28
src/onenote/binary/guid.ts
Normal 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}';
|
||||||
35
src/onenote/binary/time.ts
Normal file
35
src/onenote/binary/time.ts
Normal 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
15
src/onenote/index.ts
Normal 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';
|
||||||
9
src/onenote/model/diagnostics.ts
Normal file
9
src/onenote/model/diagnostics.ts
Normal 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
68
src/onenote/model/dto.ts
Normal 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[];
|
||||||
|
}
|
||||||
47
src/onenote/one/constants.ts
Normal file
47
src/onenote/one/constants.ts
Normal 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
661
src/onenote/one/section.ts
Normal 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, '');
|
||||||
|
}
|
||||||
86
src/onenote/onepkg/binary-reader.ts
Normal file
86
src/onenote/onepkg/binary-reader.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/onenote/onepkg/cabinet.test.ts
Normal file
200
src/onenote/onepkg/cabinet.test.ts
Normal 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 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
727
src/onenote/onepkg/cabinet.ts
Normal file
727
src/onenote/onepkg/cabinet.ts
Normal 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 };
|
||||||
|
}
|
||||||
20
src/onenote/onepkg/cancellation.ts
Normal file
20
src/onenote/onepkg/cancellation.ts
Normal 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);
|
||||||
|
}
|
||||||
68
src/onenote/onepkg/checksum.ts
Normal file
68
src/onenote/onepkg/checksum.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/onenote/onepkg/errors.ts
Normal file
64
src/onenote/onepkg/errors.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
23
src/onenote/onepkg/index.ts
Normal file
23
src/onenote/onepkg/index.ts
Normal 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';
|
||||||
114
src/onenote/onepkg/joplin-fixture.test.ts
Normal file
114
src/onenote/onepkg/joplin-fixture.test.ts
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
141
src/onenote/onepkg/lzx/bit-reader.ts
Normal file
141
src/onenote/onepkg/lzx/bit-reader.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
430
src/onenote/onepkg/lzx/decoder.ts
Normal file
430
src/onenote/onepkg/lzx/decoder.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src/onenote/onepkg/lzx/huffman.ts
Normal file
161
src/onenote/onepkg/lzx/huffman.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
177
src/onenote/onepkg/package.ts
Normal file
177
src/onenote/onepkg/package.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
99
src/onenote/onepkg/path.ts
Normal file
99
src/onenote/onepkg/path.ts
Normal 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
120
src/onenote/onepkg/types.ts
Normal 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;
|
||||||
|
}
|
||||||
560
src/onenote/onestore/desktop-store.ts
Normal file
560
src/onenote/onestore/desktop-store.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
440
src/onenote/onestore/file-node.ts
Normal file
440
src/onenote/onestore/file-node.ts
Normal 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}`;
|
||||||
|
}
|
||||||
290
src/onenote/onestore/property-set.ts
Normal file
290
src/onenote/onestore/property-set.ts
Normal 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);
|
||||||
|
}
|
||||||
95
src/onenote/onestore/types.ts
Normal file
95
src/onenote/onestore/types.ts
Normal 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}`;
|
||||||
|
}
|
||||||
70
src/onenote/parser/detect-format.ts
Normal file
70
src/onenote/parser/detect-format.ts
Normal 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);
|
||||||
|
}
|
||||||
51
src/onenote/parser/error.ts
Normal file
51
src/onenote/parser/error.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/onenote/parser/limits.ts
Normal file
55
src/onenote/parser/limits.ts
Normal 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;
|
||||||
|
}
|
||||||
32
src/onenote/parser/parse-section.ts
Normal file
32
src/onenote/parser/parse-section.ts
Normal 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
10
src/source-file.ts
Normal 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
627
src/styles.css
Normal 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
7
src/test/setup.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
import { afterEach } from 'vitest';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
30
src/toolbox/manifest.definition.json
Normal file
30
src/toolbox/manifest.definition.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/toolbox/manifest.test.ts
Normal file
22
src/toolbox/manifest.test.ts
Normal 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
12
src/toolbox/manifest.ts
Normal 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
1
src/version.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const APP_VERSION = '0.1.0';
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
112
src/worker/onenote.client.test.ts
Normal file
112
src/worker/onenote.client.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
137
src/worker/onenote.client.ts
Normal file
137
src/worker/onenote.client.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/worker/onenote.worker.ts
Normal file
122
src/worker/onenote.worker.ts
Normal 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);
|
||||||
|
};
|
||||||
71
src/worker/parser-adapter.test.ts
Normal file
71
src/worker/parser-adapter.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
164
src/worker/parser-adapter.ts
Normal file
164
src/worker/parser-adapter.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
87
src/worker/worker-protocol.ts
Normal file
87
src/worker/worker-protocol.ts
Normal 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' }
|
||||||
|
>;
|
||||||
74
tests/binary-property.test.ts
Normal file
74
tests/binary-property.test.ts
Normal 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]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
83
tests/desktop-section.test.ts
Normal file
83
tests/desktop-section.test.ts
Normal 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
93
tests/file-node.test.ts
Normal 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
64
tests/fixtures/README.md
vendored
Normal 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
123
tests/fixtures/joplin-test.onepkg.base64
vendored
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
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
|
||||||
613
tests/fixtures/onenote_desktop.one.b64
vendored
Normal file
613
tests/fixtures/onenote_desktop.one.b64
vendored
Normal 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
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////////
|
||||||
|
/wAAAABLujOCwxXCi8T09/WxelakEgAAAAAAAAAUcICASkB3GrP0i0aXbGBF41TEagEAAAAAAAAA
|
||||||
|
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
|
||||||
|
ABQUHAAUFRwAFBU0ACBOHQAUpDUAFJ40ABSfNAAUoDQAFKE0ABSiNAAUozQAFKQ0ABSlNAAUKggk
|
||||||
|
VgAAAAAAAAAAAQAAAAEAAAAwy9g/dFOTQNDncz++RUlAcRbNP0RmkEBmXZE/ffpUQAAAAQAAgCAA
|
||||||
|
AAABABY0ACQBAAAAAAAAAAAAAQAAgB8AAAAIAAs0ABwZNAAUGzQAEBw0AAwQNgAMGjQAHB00ABwJ
|
||||||
|
NAAgcAAAAIoBwDQAAAUFAAUFBQ0pHX1txQFp6QHpAfUHnQFVKR2wWwwMEAwMDAwMGEw0kAKQAugE
|
||||||
|
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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
|
||||||
|
GBgYHBgUGEAYGBgUFBgQFBAkPLADlAOMBcwDkA3QB9gEpBiQAvAEnAYE4AEABAAAAAAAAAAAAAUF
|
||||||
|
AAUFAAUFBQAFBQUFBQUFBQUFBQUFBQ1FRUlFxQG5Aa0FoQe1BrEYsRj5BukI3QiFB4EH+RTdDpka
|
||||||
|
xRylEElJSUlJtQHhAt0F0QiNA50F/QzpDKkBrQG9A13lAjExMTGJATG0NwAAAAAAAAQAAAAABAAA
|
||||||
|
AAQAAAQABAAABAAEAAAIPCAcHBwgHEToATTgC5ABkAGUAZgB/AL8B1SUBvgCqAfMFJwBiAPMA9wB
|
||||||
|
5AbwAoAG1Am4AbwBuAG4AYADsAGUBKwCdHh4lALgAdgCGBgYGBgYFBgUGBQUFDgoKCgkXDw4NDBM
|
||||||
|
OIACdEg8LCwEDBRI9AFgcJQC+AOoAcwD8AKYAegBwAIwJCAUEAQEKGh0JLAB7AGIArgEIBgQAAUF
|
||||||
|
BQUJCQkJDQ0pFRUVGRkZGR0dRYEBvQaBBZ0HjQW5Fc0P7Qn5LOUD1QrdEA2BBgkJCQkJBQkJCQkJ
|
||||||
|
EQAFBQUFBQAFBQAFAAUABQAABQAAAAUAAAAABAgoIHikAZgBpATsAjQYUX11tQJRHSUJAAAFCQ0p
|
||||||
|
ba0BuQE9ZZEC+QIxLUkJJQkNDQ01HcyZAUpISkhKSEpISkhKSEpISEpISkhKSEpISkhKSEpIoguI
|
||||||
|
B4gHhgeIB4YHiAeGB/Ykigaqc+YC5gLmAuYC5ALCBgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAABzc3Nzc3Nzc3NzcXOrAqkCqwKpAqsCqQKrAqkCqwKrAuEWnVuzT4MeogEAAAAAAAAA
|
||||||
|
AAAuLi4uLi4uLi4uLi6iAaABogGgAaIBoAGiAaABoAGiAaABogGgAaIBoAGiAaABogGgAaIBoAGi
|
||||||
|
AaABogGsFKwUrBSsFKwU5h7oSZYPAAAAALEKlSOrf71BxAGeaciDAQAAT09NT09NzTHLMft69xPX
|
||||||
|
B5EDPMINwA3CDfYEqhXKA8wDygPMA8oDXAoAAAAJBAAAEAAAAKYNIISyTPZOjm8QHx5xGD4IAAAA
|
||||||
|
uJUW9Kg/3AEAAAAAAAEAAIBgAAAADgB6HQAUFBwAFBUcABQVNAAgTh0AFKQ1ABSeNAAUnzQAFKA0
|
||||||
|
ABShNAAUojQAFKM0ABSkNAAUpTQAFD8IJFYAAAAAAAAAAAEAAAABAAAA/P0mQc9NbUAAYM46AGjO
|
||||||
|
OmSHJUFwc2dArug8Pr7oPD4AAAEAAIBhAAAAAQAWNAAkAQAAAAAAAAAAAAEAAIBcAAAACAALNAAc
|
||||||
|
GTQAFBs0ABAcNAAMEDYADBo0ABwdNAAcCTQAIAkAAAAGkM8BzEnMmQELAAAACQQAABAAAADLh36c
|
||||||
|
DrrqSrdm63cJyBSnCAAAAPKsQvaoP9wBAAAAAIACANg1ABxUNgAUFAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAACwAAAAAAAAAAAAMAAIBoAAAAbgAAAHEAAAAJAHodABQgHAAkEhwAHAMcAAwbHAAUHBwA
|
||||||
|
FBQcABQVHAAUhBwAFEoIJFYDAAAAFAAAAAQAAAAAAAA/AAAAAAAAQD8AAEA/AQAAUEGamRk/AACQ
|
||||||
|
QGZmRkEJAAAAAAAAAAADAACAEQAAABEAAABpAAAABgB6HQAUeR0AIHgdACAJHQAUAxwADB8cACRG
|
||||||
|
CCRWRQgkVgEBAAAAAAIAAIBqAAAAHAAAAAYAeh0AFCw0ACD+HAAQEx4AJJg0ABzdNACIRggkVgkE
|
||||||
|
AQAAAAUAAABUZXN0IQAAAAAAAAAEAACAEQAAABEAAABvAAAAcAAAAAcAeh0AFHkdACB4HQAgCR0A
|
||||||
|
FAMcAAwfHAAk4hwAIEcIJFZHCCRWAQEAAAAAAgAAgGoAAAAcAAAABwB6HQAUPhwAFIQcABQsNAAg
|
||||||
|
/hwAEBMeACTdNACIRwgkVgEAAAABAAAACQQBAAAAAAAAAAAAAIAKAAocABwLHAAQDBwAFAQcAAgF
|
||||||
|
HAAIBhwACAccAAgJHAAICBwACA0cABQQAAAAQwBhAGwAaQBiAHIAaQAAABYAAAAA/wAAAP8AAAAA
|
||||||
|
AwAAgBEAAAARAAAAcgAAAAYAeh0AFHkdACB4HQAgCR0AFAMcAAwfHAAkSggkVkoIJFYBAQAAAAAC
|
||||||
|
AACAcwAAAHoAAAAHAHodABRXHQAUWB0AFF4dAIhmHQAcfR0AHCAcACRNCCRWAgAAAAIAAAAJAAAA
|
||||||
|
Alny4z9Z8oM/AgAAAAIAAgAAAAAAAAIAAIB0AAAAdwAAAAIAeh0AFCAcACRKCCRWAgAAAAAAAQAA
|
||||||
|
gIMAAAAGAHodABQgHAAkEhwAHAMcAAyRHACIGxwAFFMIJFYBAAAAFAAAAAQAAAAAAAA/AAAAAAAA
|
||||||
|
QD8AAEA/AQAAUEEAAQAAgHgAAAAFAHodABQgHAAkEhwAHAMcAAyRHACISggkVgEAAAAUAAAABAAA
|
||||||
|
AAAAAD8AAAAAAABAPwAAQD8BAAMAAIARAAAAEQAAAHkAAAAGAHodABR5HQAgeB0AIAkdABQDHAAM
|
||||||
|
HxwAJE0IJFZMCCRWAQEAAAAAAgAAgGoAAAAcAAAACAB6HQAULDQAID4cABSEHAAU/hwAEBMeACSY
|
||||||
|
NAAc3TQAiE0IJFYBAAAAAQAAAAkEAQAAAAEAAABEAAAAAgAAgHsAAAB+AAAAAgB6HQAUIBwAJE0I
|
||||||
|
JFYCAAAAAAABAACAfAAAAAUAeh0AFCAcACQSHAAcAxwADJEcAIhOCCRWAQAAABQAAAAEAAAAAAAA
|
||||||
|
PwAAAAAAAEA/AABAPwEABAAAgBEAAAARAAAAfQAAAIEAAAAHAHodABR5HQAgeB0AIAkdABQDHAAM
|
||||||
|
HxwAJCAcACROCCRWTQgkVgEBAAAAAQAAAAAAAAAAAgAAgGoAAAAcAAAACAB6HQAULDQAID4cABSE
|
||||||
|
HAAU/hwAEBMeACSYNAAc3TQAiE4IJFYBAAAAAQAAAAkEAQAAAAEAAABFAAAAAQAAgH8AAAAFAHod
|
||||||
|
ABQgHAAkEhwAHAMcAAyRHACITQgkVgEAAAAUAAAABAAAAAAAAD8AAAAAAABAPwAAQD8BAAMAAIAR
|
||||||
|
AAAAEQAAAIAAAAAGAHodABR5HQAgeB0AIAkdABQDHAAMHxwAJFAIJFZQCCRWAQEAAAAAAgAAgGoA
|
||||||
|
AAAcAAAACAB6HQAULDQAID4cABSEHAAU/hwAEBMeACSYNAAc3TQAiFAIJFYBAAAAAQAAAAkEAQAA
|
||||||
|
AAEAAABHAAAAAwAAgBEAAAARAAAAggAAAAYAeh0AFHkdACB4HQAgCR0AFAMcAAwfHAAkTwgkVk8I
|
||||||
|
JFYBAQAAAAACAACAagAAABwAAAAIAHodABQ+HAAUhBwAFCw0ACD+HAAQEx4AJJg0ABzdNACITwgk
|
||||||
|
VgEAAAABAAAACQQBAAAAAQAAAGYAAAADAACAEQAAABEAAACEAAAABgB6HQAUeR0AIHgdACAJHQAU
|
||||||
|
AxwADB8cACRTCCRWUwgkVgEBAAAAAAIAAIBqAAAAHAAAAAYAeh0AFCw0ACD+HAAQEx4AJJg0ABzd
|
||||||
|
NACIUwgkVgkEAQAAAAQAAABURVNUAQAAgIkAAAAJAHodABQgHAAkEhwAHAMcAAwbHAAUHBwAFBQc
|
||||||
|
ABQVHAAUhBwAFFgIJFYBAAAAFAAAAATI0l0AAAA/AAAAAAAAQD8AAEA/AQAAUEGamRk/AABAQDMz
|
||||||
|
q0EJAAAAAAAAAAADAACAEQAAABEAAACKAAAABgB6HQAUeR0AIHgdACAJHQAUAxwADB8cACRgCCRW
|
||||||
|
XQgkVgEBAAAAAAIAAIBqAAAAiwAAAAcAeh0AFCw0ACD+HAAQEx4AJJk0AECYNAAc3TQAiFkRJFZ/
|
||||||
|
AAEAAAABAAAAEQgARAEATzQAFAAAAJAFAAAAMyszPTYAAQAAgAsCAAAKAHodABQgHAAkEhwAHAMc
|
||||||
|
AAwbHAAUHBwAFBQcABQVHAAUhBwAFNscABRbESRWAQAAABQAAAAEBQYAAAAAPwAAAAAAAEA/AABA
|
||||||
|
PwEAAFBBmpkZPwAAoECamRlACQAAAAEAS0EAAAAAAAMAAIANAgAADQIAAAwCAAAGAHodABR5HQAg
|
||||||
|
eB0AIAkdABQDHAAMHxwAJGERJFZbESRWAQEAAAAAAgAAgGoAAAAcAAAABgB6HQAULDQAIP4cABAT
|
||||||
|
HgAkmDQAHN00AIhhESRWCQQBAAAAJgAAAFRoaXMgbm90ZWJvb2sgc2hvdWxkIGhhdmUgdGhyZWUg
|
||||||
|
cGFnZXMuAAAAAAAAAAAAgAAAAAAAAACAAgCCHQAUizQAFCgAAAAoAAAAAADE9Pf1sXpWpBgAAAAA
|
||||||
|
AAAAtGCAgI4Y0ORLzrYNKBTA+IGQ2ugAAAAAIhCAgCRggIAAAAAAf0kRcWsbCUKUkcmLBM9MWigQ
|
||||||
|
gICMYICAJG23J8CG7EyN8g9cheM0UAEAAACkRACNlQgDAwAAAEYAAgAAAaREAI2UCAECAAAAPAAG
|
||||||
|
AAABuBCAgAAAAAAAAP//////////AAAAAEu6M4LDFcKLxPT39bF6VqQZAAAAAAAAAAxggIB0LTEy
|
||||||
|
z2t1SJrc44tBOld/AQAAABAcAJXQCCQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAP//////////AAAAAEu6M4LDFcKLxPT39bF6VqQaAAAAAAAAABRwgIB0
|
||||||
|
LTEyz2t1SJrc44tBOld/AQAAAAAAAAAeyICAU5qV2Yx0n0a8xH2CwI3qKAEAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAEAAAAAALBsAJVhDnhLHyGH3YcnBRJ/4Tf2Bdz1AAAAAIRkAIz//////////wAA
|
||||||
|
AAAAAAAAAFVXn49acICAZ+e53o5MKEaOj1XVP7OztgoAAAABAAAAWnCAgGfnud6OTChGjo9V1T+z
|
||||||
|
s7YLAAAAAgAAAFpwgIBn57nejkwoRo6PVdU/s7O2GgAAAAQAAAAcEICA/xAAgAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAMh2AAAAAAAAAAQAAEu6M4LDFcKLAAAAgAsAFh4ACKo0AAgUHgAI
|
||||||
|
GR4ACDscABQBHQAMChwAHFI0AAwBNAAIBTQACAY0AAgJBAAAACAAAABUAGkAbQBlAHMAIABOAGUA
|
||||||
|
dwAgAFIAbwBtAGEAbgAAAAAAAAAAAAAAgAwAFh4ACKo0AAgUHgAIGR4ACAYcAIgVHgAMOxwAFAEd
|
||||||
|
AAxSNAAMATQACAU0AAgGNAAIAQkEAAAAAAAAAAAAAIAAAAAAAAAAgAIAgh0AFIs0ABQoAAAAKAAA
|
||||||
|
AAAAAAAAgAAAAAAAAACAAgCCHQAUizQAFCgAAAAoAAAAAAAAAAAAAAAAAMT09/WxelakFgAAAAAA
|
||||||
|
AADCXACNkQkIig/9GlG+IHWV6dtZF4fqKsJcAI2ZCRLOWPbM/37H61VV7ce/jDEvwlwAjasJEVrs
|
||||||
|
Oh48hraQNO5VCklVQYzCXACNvAkH8zR3HjwRjL1q4PQDDRsznMJcAI3DCQe2Q4M2n6eluXrSFG5v
|
||||||
|
LBbmwlwAjcoJETOih1ZsmTaUhx3lTt4VzNLCXACN2wkRA9SvCUxWqsAkg0oDj98nYsJcAI3sCQ5f
|
||||||
|
1LzO/MwZabQ9fFf79U07wlwAjfoJDHKsCvaXJt7rbztq0G0OINHCXACN9AgMCj5JWMn1cm8iDBds
|
||||||
|
PMVVOsJcAI0ACQhe3qTf/26LDbUTw8uj6yNvwlwAjckMB9K3zY9vzIBGlnoJ8mfWex0AAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAA//////////8AAAAAS7ozgsMVwosAAACAAwD4HQAcdR0AHDAe
|
||||||
|
ABwGAAAASABIAAAAGAAAAEgAZQBuAHIAeQAgAEgAZQBpAG4AbwAAAAIAAAAAAAAAAAAAgA4AWjQA
|
||||||
|
HAQcAAgFHAAIBhwACAwcABQuNAAULzQAFDA0ABQHHAAIDRwAFAkcAAgIHAAIChwAHAscABAUAAAA
|
||||||
|
UABhAGcAZQBUAGkAdABsAGUAAAAAAAD/AAAAAAAAAAAAAAAAAAAA/xwAAABDAGEAbABpAGIAcgBp
|
||||||
|
ACAATABpAGcAaAB0AAAAKAAAAAAAAAAAgA4AWjQAHAQcAAgFHAAIBhwACAwcABQuNAAULzQAFDA0
|
||||||
|
ABQHHAAIDRwAFAkcAAgIHAAIChwAHAscABAaAAAAUABhAGcAZQBEAGEAdABlAFQAaQBtAGUAAAB2
|
||||||
|
dnYAAAAAAAAAAAAAAAAAAAAA/xAAAABDAGEAbABpAGIAcgBpAAAAFAAAAAAAAIACAPgdABx1HQAc
|
||||||
|
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
|
||||||
|
AAAAAP//////////AAAAAEu6M4LDFcKLxPT39bF6VqQVAAAAAQAAAB8YgYCWnWS6Gj0OToavXE23
|
||||||
|
GOw1AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAf0kRcWsbCUKUkcmLBM9MWgEAAACwbACV
|
||||||
|
mAgUjhjQ5EvOtg0oFMD4gZDa6AAAAACEZACM//////////8AAAAAAAAAAACSuDQRWnCAgH9JEXFr
|
||||||
|
GwlClJHJiwTPTFoCAAAAAQAAAFpwgIB/SRFxaxsJQpSRyYsEz0xaAwAAAAIAAAAcEICAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
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
|
||||||
|
sPhiTLMeTbP2YtgI0oIzpEQAjWwLBQoAAAA3AAYAAwG4EICAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAP//////////AAAAAEu6M4LDFcKLxPT39bF6VqQaAAAAAQAAAB8YgYBK09mExVygQ6YD
|
||||||
|
xCB6NUe+AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAf0kRcWsbCUKUkcmLBM9MWgEAAACw
|
||||||
|
bACVkQwUUlZt2pSvGAAIuFiVTL1xYwAAAACEZACM//////////8AAAAAAAAAAACSuDQRWnCAgH9J
|
||||||
|
EXFrGwlClJHJiwTPTFoCAAAAAQAAAFpwgIB/SRFxaxsJQpSRyYsEz0xaAwAAAAIAAAAcEICAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
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
|
||||||
|
ABR1HQAc+B0AHJIcAAgPHQAUEB0AFGkRJFYBAAAAAQAAAAAAgD8AAIA/AAAAQAAAAEBQjadBAACu
|
||||||
|
QQAAAD8AAAA/AACAPgAAgD4YAAAASABlAG4AcgB5ACAASABlAGkAbgBvAAAABgAAAEgASAAAAAAA
|
||||||
|
gD/NzMw+AgAAgA4AAAAUAAAABwB6HQAUFBwAFBUcABQ+HAAUhBwAFPEcABQgHAAkYhEkVgAAAAAA
|
||||||
|
AAAADAAJAAAAAAAAAAAAAgAAAAAAAQAAgA8AAAAOAHodABQgHAAkEhwAHAMcAAwcHAAU+RwAiLIc
|
||||||
|
AIi0HACIkRwAiOwcABT/HACIPhwAFIQcABQbHAAUYhEkVgEAAAAUAAAABAA43AAAAD8AAAAAAABA
|
||||||
|
PwAAQD8BmpkZPwAAkEAAAAAADAAAAPYoUEEAAwAAgBEAAAARAAAAEAAAAAgAeh0AFHkdACB4HQAg
|
||||||
|
CR0AFAMcAAwfHAAkshwAiLQcAIh8ESRWZhEkVgEBAAAAAAIAAIATAAAAHAAAAAcAeh0AFCw0ACC0
|
||||||
|
HACI/hwAEBMeACSYNAAc3TQAiHwRJFYJBAEAAAAGAAAAUGFnZSAzAAACAACAFQAAABgAAAANAHod
|
||||||
|
ABQgHAAkEhwAHAMcAAwcHAAUtRwAiAAcAIiyHACI3hwAiAwdAIg+HAAUhBwAFBscABRiESRWAgAA
|
||||||
|
ABQAAAAE11jZAAAAPwAAAAAAAEA/AABAPwGamRk/AAAAAAwAAAD2KFBBAAAAAAADAACAEQAAABEA
|
||||||
|
AAAWAAAACQB6HQAUeR0AIHgdACAJHQAUAxwADB8cACSyHACIDB0AiDQcAAhiESRWYhEkVgEBAAAA
|
||||||
|
AAAAAAACAACAFwAAABwAAAAOAHodABQsNAAg3hwAiD4cABSEHAAUABwAiIgcAIgMHQCItRwAiP4c
|
||||||
|
ABDIHAAcmDQAHBMeACTdNACIYhEkVgEAAAAMAAAACQQFAAAAAAAAAAMYAAAARnJpZGF5LCBPY3Rv
|
||||||
|
YmVyIDE3LCAyMDI1AQAAAAAAAAMAAIARAAAAEQAAABkAAAAJAHodABR5HQAgeB0AIAkdABQDHAAM
|
||||||
|
HxwAJLIcAIgMHQCINBwACGIRJFZiESRWAQEAAAAAAAAAAAIAAIAXAAAAHAAAAA4Aeh0AFCw0ACDe
|
||||||
|
HACIPhwAFIQcABQAHACIiBwAiAwdAIiHHACI/hwAEMgcAByYNAAcEx4AJN00AIhiESRWAQAAAAwA
|
||||||
|
AAAJBAUAAAAAAAAAAwcAAAAyOjM4IFBNAQAAAAAAAAABAACAGwAAAAIAeR0AIHcdABgA5rx3rj/c
|
||||||
|
AQAAAAAAAAEAAIAeAAAACQB6HQAUIBwAJBIcABwDHAAMGxwAFBwcABQUHAAUFRwAFIQcABRpESRW
|
||||||
|
AQAAABQAAAAE2JMXAAAAPwAAAAAAAEA/AABAPwH2KEBBmpkZPwAAgD+amRlACQAAAAAAAAAAAwAA
|
||||||
|
gBEAAAARAAAAHwAAAAYAeh0AFHkdACB4HQAgCR0AFAMcAAwfHAAkdxEkVmkRJFYBAQAAAAAEAACA
|
||||||
|
EgAAABwAAAAgAAAAHAAAAAcAeh0AFCw0ACD+HAAQEx4AJJg0ABwSHgAc3TQAiHcRJFYJBAMAAABG
|
||||||
|
AAAAVGhpcyBwYWdlIGlzIG5lYXJseSBlbXB0eS4gSXQgaGFzIGEgc2luZ2xlIHRleHQgYm94IGFu
|
||||||
|
ZCBub3QgbXVjaCBlbHNlLggAAAANAAAAGQAAAAAAAAAAAMT09/WxelakIAAAAAAAAAC0YICAF5e/
|
||||||
|
HFAwoQ4Ww24n8DB/JgAAAAAiEICAJGCAgAAAAAA6gRKdMHpLQa4uuty8AEDlJGCAgAEAAAB/SRFx
|
||||||
|
axsJQpSRyYsEz0xaKBCAgIxggIAGPa6oNvBxT6ldFC0iQ73DAQAAAKREAI3NDw8UAAAADAAGAAEB
|
||||||
|
pEQAjeUPEBYAAAAOAAYAAQGkRACNxQ8IEAAAAA4ABgABAcSEAI3sCQ4SAAAATQASAAABTgHkm2Vz
|
||||||
|
9keA8I6mnuyXAaREAI2lDwkNAAAALAAGAAEBxIQAjcMJBxwAAABNABIAAAUU5Yf6Bq0qQZ84XJHv
|
||||||
|
3P9JpEQAjb0PCA8AAAANAAYAAQGkRACNHRAHHgAAAA0ABgABAaREAI31DwkYAAAADQAGAAEBpEQA
|
||||||
|
jYIPCwsAAAAwAAIAAAGkRACNDBAEGgAAAEQAAgABAaREAI3cDwkVAAAADQAGAAEBxIQAjasJERcA
|
||||||
|
AABNABIAAAIJYnxiN7dXRIJACreskqBYxIQAjZEJCBEAAAABABIAAAhWIcC4Z//JSafB4ovI7vDi
|
||||||
|
xIQAjckMByAAAABNABIAAAEBTPy+aNQCSZyGmkrfje3NxIQAjZkJEhMAAABNABIAAAHKLlDK55Ww
|
||||||
|
So+lVWZcG6c5pEQAjRAQDR0AAAAMAAYAAQGkRACNjQ8YDAAAAAsABgABAaREAI0kEBMfAAAADgAG
|
||||||
|
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
5
tests/golden/README.md
Normal 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.
|
||||||
121
tests/release-archive.test.ts
Normal file
121
tests/release-archive.test.ts
Normal 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
5
tools/oracle/README.md
Normal 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
23
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
17
tsconfig.node.json
Normal file
17
tsconfig.node.json
Normal 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
14
vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user