feat: add local OneNote and ONEPKG reader
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user