feat: add local OneNote and ONEPKG reader

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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