mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 09:52:27 +00:00
* fix(desktop): keep a program word and a directory's neighbor The program after env stays the program even when a captured file sits in a folder of that name. A selected directory is not a database, so a neighboring file keeps the parent folder's spelling. * fix(desktop): record whether a selected path is a directory Capture stores the walked path's kind. A neighboring file of a selected directory keeps the parent folder's spelling even when that directory never changes and its children are not content roots. * fix(desktop): mark containing paths without a pairwise scan A recorded path marks its known ancestors as directories in one walk up the path, instead of comparing every recorded path with every other. * fix(desktop): keep a selected folder that is named like a database log A folder the caller selected stays a selected root when its name ends in -journal or -wal. A generated log beside a database file still does not. * fix(desktop): refuse a build command that exceeds the argument vector Splitting one argument still leaves the whole command under Linux's E2BIG limit. A request past that budget is refused before the process starts. * fix(desktop): remap a captured path in an env assignment The program word stays the program. A NAME=value assignment keeps its name and follows a captured path value when that path's spelling moves. * fix(desktop): keep oversized text out of draft guards A value or line longer than the draft's text bound is not copied into a guard, so one large frame cannot fill the task by itself. * docs(desktop): correct dconf and check-preview guidance A dconf version kept without its bytes is unreadable, not an empty key list. A values preview is copied into a check only when it is an uncut scalar. * fix(desktop): keep an empty env value empty An empty assignment is not a path. Resolving it named the working directory and rewrote the value when that directory's spelling moved. * fix(desktop): keep a run's font on the run, not the frame's text A formatting attribute such as Scribus FONT is not the document's words. Joining it across runs made a frame-wide text check of ItalicBold that failed when a run was split. The font stays a value check on its own run. * fix(desktop): do not pair JSON records by a selector-shaped field A field named @record_id matched the id suffix and became [@record_id=one], which reads an XML attribute. Records paired while their pointers matched nothing. Names that start with @ or > are not id fields. * fix(desktop): count a save-numbered id once when pairing members xml:id and draw:id values such as id123456 were both generated and numbered, so the same selector was counted twice and refused. A text edit of two elements keyed only by those ids now keeps its check. * fix(desktop): preserve selected state directory aliases * docs(desktop): explain tool and skill refresh prerequisites * fix(desktop): align nested state selections with aliases * fix(desktop): check build runtime export capability * fix(desktop): provide complete runtime repack command * fix(desktop): retain runtime probe failures * test(desktop): identify capture independently of schema preflight * fix(desktop): preserve document URLs and value formats * fix(desktop): bound added checks and omit noisy text guards * fix(desktop): exclude unseeded locks from task checks * fix(desktop): preserve draft bounds and launch URLs * fix(desktop): render draft values using their source format * fix(desktop): preserve launch URLs and JSON draft values * fix(desktop): recognize captured file URL documents * fix(desktop): render captured file URLs for runtime home * fix(desktop): distinguish phone numbers from save metadata * fix(desktop): preserve launch URLs and user text * fix(desktop): preserve captured URLs and user text * fix(desktop): preserve local file URL authority * fix(desktop): preserve local file URL authority * fix(desktop): preserve local file URL authority * fix(desktop): render captured file URLs at launch * fix(desktop): classify numeric timestamps using field context * fix(desktop): classify numeric timestamps using field context * fix(desktop): classify numeric timestamps using field context * fix(desktop): retain context for generated draft values * fix(desktop): retain context for generated draft values * fix(desktop): retain context for generated draft values * test(desktop): cover generated IDs in text guards * test(desktop): cover generated IDs in text guards * test(desktop): cover generated IDs in text guards * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): retain clean text in noisy record fields * fix(desktop): classify added line values in one pass * fix(desktop): apply XML quote rules to text drafts * fix(desktop): distinguish XML attributes from quoted text * fix(desktop): preserve parsed XML line context * fix(desktop): retain partial XML syntax facts * fix(desktop): recognize markdown autolinks in drafts * fix(desktop): reuse export exclusions in task drafts * docs(desktop): clarify gzip scrub limits * style(desktop): format the noisy-record draft files with the pinned oxfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(desktop): move launch execution to boundary suite * fix(desktop): redact build schema failure output * refactor(desktop): normalize schema failure once * fix(desktop): omit unsafe schema parser snippets * fix(desktop): preserve draft change and selected path evidence * test(desktop): group retained dconf note regressions * fix(desktop): respect archive entry format in draft checks * test(desktop): separate unowned-file draft note cases * fix(desktop): preserve explicitly selected journal documents * fix(xml): avoid scanning selected ancestors per tag * fix(desktop): exclude covered text runs from draft counts * fix: preserve text when drafting desktop checks * fix: restore nested desktop state aliases * refactor: share UUID recognition across draft checks * fix: retain multiline XML attribute context * fix: retain relationship identifiers in task drafts * docs: clarify desktop draft identifier handling * test(desktop): type formatting run cases as pairs * test(desktop): separate markup boundary regression group * test(desktop): remove duplicate seed filename case * docs(desktop): clarify task checks before scratch export * fix(desktop): respell selected paths in file URLs * test(desktop): group captured document spelling cases * fix(desktop): bound draft preview serialization * fix(desktop): restore selected state aliases * fix(desktop): respect entry format in draft noise * test(desktop): match exported alias evidence at its JSON boundary * fix(desktop): retain app state logs in exported seeds * fix: align desktop numeric seed comparisons * test(desktop): keep one draft exclusion fixture import * test(desktop): retain log eligibility across draft test groups * style: fix desktop launch test formatting * style: format native command path schema * fix(desktop): map selected external launch paths * style(desktop): format seed document regressions * test(desktop): group launch URL regressions * fix(desktop): omit redundant nested state aliases * fix(desktop): compare added free text as a whole * fix(desktop): check recompressed seeds as shipped * docs(desktop): clarify recovered seed format * test(desktop): group shipped gzip seed checks * fix: retain quoted identifier text in task drafts * fix: recognize Unicode XML in desktop state readers * fix(desktop): bound nested draft checks * fix(desktop): explain missing arrays in truncated views * fix(desktop): find XML type references in archives * test(desktop): remove duplicated capture assertions * fix(desktop): retain nested omitted text boundaries * style(desktop): format draft value imports * test(desktop): group model and draft value checks * fix(desktop): map captured launch paths once * refactor: remove unused whole-text guard state * refactor: stop passing unused text children * test: align free-text checks with whole-note policy * fix: validate recompressed desktop archive seeds * style(desktop): format archive seed regression * fix(desktop): retain placed-item sibling guards * fix(desktop): redact runtime schema errors * fix(desktop): normalize captured file URLs * fix(desktop): report seeded files by capture path * fix(desktop): retain startup readiness failure stage * fix(desktop): retain JSON date checks * fix(desktop): preserve archive formatting checks * fix(desktop): preserve explicitly selected journal files * fix(desktop): report build command transport limits * docs(desktop): clarify unreadable dconf versions * test(desktop): remove duplicate history regression * fix(desktop): align XML names with exported reader * fix(desktop): scrub XML independently of reader compatibility * style(desktop): format review fixes * style(desktop): format capture review fixes --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
import { DOMParser, onErrorStopParsing, type Document } from "@xmldom/xmldom";
|
|
import { SaxesParser, type SaxesTagNS } from "saxes";
|
|
import { err, ok, type Result } from "./result.js";
|
|
|
|
/** XML 1.0 literal line endings; Unicode separators remain source characters. */
|
|
export const XML_LINE_ENDING = /\r\n?|\n/gu;
|
|
|
|
export class XmlDocumentParseError extends Error {
|
|
readonly _tag = "XmlDocumentParseError";
|
|
constructor(
|
|
readonly reason: "malformed" | "doctype" | "internal-subset",
|
|
cause?: unknown,
|
|
) {
|
|
super(
|
|
reason === "doctype"
|
|
? "XML document types are unsupported"
|
|
: reason === "internal-subset"
|
|
? "XML document type internal subsets are unsupported"
|
|
: `XML is not well-formed${cause instanceof Error ? `: ${cause.message}` : ""}`,
|
|
{ cause },
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Whether a DOCTYPE's text, as saxes reports it, opens an internal subset: a `[` outside its quoted literals. */
|
|
const declaresInternalSubset = (declaration: string): boolean =>
|
|
declaration.replace(/"[^"]*"|'[^']*'/gu, "").includes("[");
|
|
|
|
/** XML 1.0's `NameStartChar`, and the further characters `NameChar` allows. */
|
|
const NAME_START = String.raw`:A-Z_a-z\u{C0}-\u{D6}\u{D8}-\u{F6}\u{F8}-\u{2FF}\u{370}-\u{37D}\u{37F}-\u{1FFF}\u{200C}\u{200D}\u{2070}-\u{218F}\u{2C00}-\u{2FEF}\u{3001}-\u{D7FF}\u{F900}-\u{FDCF}\u{FDF0}-\u{FFFD}\u{10000}-\u{EFFFF}`;
|
|
const NAME_MORE = String.raw`\-.0-9\u{B7}\u{300}-\u{36F}\u{203F}\u{2040}`;
|
|
/** XML element opening grammar shared by the desktop format readers. */
|
|
export const XML_ELEMENT_OPENING = String.raw`<[${NAME_START}][${NAME_START}${NAME_MORE}]*(?=[ \t\r\n]|/?>)`;
|
|
const XML_OPENING = new RegExp(
|
|
`^(?:<\\?xml\\b|<!DOCTYPE\\b|${XML_ELEMENT_OPENING})`,
|
|
"u",
|
|
);
|
|
|
|
/** Whether text begins with a possible XML declaration, DOCTYPE or start tag. */
|
|
export const opensLikeXml = (source: string): boolean =>
|
|
XML_OPENING.test(source);
|
|
|
|
/** XML 1.0's `PubidChar` but the apostrophe, which only a double-quoted literal may hold. */
|
|
const PUBID = String.raw`\n\r a-zA-Z0-9\-()+,./:=?;!*#@$_%`;
|
|
const SPACE = String.raw`[ \t\r\n]`;
|
|
|
|
/**
|
|
* A DOCTYPE without an internal subset, as XML 1.0's `doctypedecl` spells it
|
|
* between `<!DOCTYPE` and `>`: a name, then optionally `SYSTEM` and a literal,
|
|
* or `PUBLIC` and a public identifier and a literal.
|
|
*/
|
|
const EXTERNAL_DOCTYPE = new RegExp(
|
|
`^${SPACE}+[${NAME_START}][${NAME_START}${NAME_MORE}]*` +
|
|
`(?:${SPACE}+(?:SYSTEM|PUBLIC${SPACE}+(?:"[${PUBID}']*"|'[${PUBID}]*'))` +
|
|
`${SPACE}+(?:"[^"]*"|'[^']*'))?${SPACE}*$`,
|
|
"u",
|
|
);
|
|
|
|
/**
|
|
* Which DOCTYPE a document may have: any, none, or with `external` one
|
|
* without an internal subset, so the document declares no entity. Neither
|
|
* parser reads an external DTD.
|
|
*/
|
|
type DoctypePolicy = "allow" | "reject" | "external";
|
|
|
|
/**
|
|
* Read XML syntax as a stream, throwing at the first error; it holds only the
|
|
* open elements. Saxes reads a DOCTYPE without checking it, so the policy
|
|
* checks one it admits without an internal subset against XML's grammar.
|
|
*/
|
|
const checkXmlSyntax = (
|
|
source: string,
|
|
doctype: DoctypePolicy,
|
|
listen?: (syntax: SaxesParser<{ xmlns: true }>) => void,
|
|
nameSupported?: (name: string) => boolean,
|
|
): void => {
|
|
const syntax = new SaxesParser({
|
|
xmlns: true,
|
|
defaultXMLVersion: "1.0",
|
|
forceXMLVersion: true,
|
|
});
|
|
listen?.(syntax);
|
|
const checkName = (name: string) => {
|
|
if (nameSupported !== undefined && !nameSupported(name))
|
|
throw new XmlDocumentParseError("malformed");
|
|
};
|
|
if (nameSupported !== undefined) {
|
|
syntax.on("opentagstart", (tag) => checkName(tag.name));
|
|
syntax.on("attribute", (attribute) => checkName(attribute.name));
|
|
syntax.on("processinginstruction", (instruction) =>
|
|
checkName(instruction.target),
|
|
);
|
|
}
|
|
syntax.on("doctype", (declaration) => {
|
|
if (doctype === "reject") throw new XmlDocumentParseError("doctype");
|
|
if (doctype !== "external") return;
|
|
if (declaresInternalSubset(declaration))
|
|
throw new XmlDocumentParseError("internal-subset");
|
|
if (!EXTERNAL_DOCTYPE.test(declaration))
|
|
throw new XmlDocumentParseError("malformed");
|
|
checkName(declaration.trimStart().split(/[ \t\r\n]/u, 1)[0] ?? "");
|
|
});
|
|
syntax.write(source).close();
|
|
};
|
|
|
|
/**
|
|
* Whether XML passes the syntax check `parseXmlDocument` makes, without
|
|
* building a document. An internal subset's syntax needs the DOM parser, so
|
|
* the policy must refuse one.
|
|
*/
|
|
export const isWellFormedXml = (
|
|
source: string,
|
|
policy: {
|
|
readonly doctype: Exclude<DoctypePolicy, "allow">;
|
|
readonly nameSupported?: (name: string) => boolean;
|
|
},
|
|
): boolean => {
|
|
try {
|
|
checkXmlSyntax(source, policy.doctype, undefined, policy.nameSupported);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/** An element as `xmlElementRanges` offers it: its namespace and local name, and an attribute's value by namespace and local name. */
|
|
export interface XmlElementFacts {
|
|
readonly uri: string;
|
|
readonly local: string;
|
|
readonly attribute: (uri: string, local: string) => string | undefined;
|
|
}
|
|
|
|
/**
|
|
* The source ranges of the elements `select` picks, from the `<` of the start
|
|
* tag to the `>` that ends the element, outermost first and none inside
|
|
* another picked one; read with the syntax check `parseXmlDocument` makes, so
|
|
* text in a comment, CDATA or an attribute is never an element.
|
|
*/
|
|
export const xmlElementRanges = (
|
|
source: string,
|
|
select: (element: XmlElementFacts) => boolean,
|
|
policy: { readonly doctype: Exclude<DoctypePolicy, "allow"> },
|
|
): Result<
|
|
readonly { readonly start: number; readonly end: number }[],
|
|
XmlDocumentParseError
|
|
> => {
|
|
const ranges: { start: number; end: number }[] = [];
|
|
// Each open element's start, and whether it was picked.
|
|
const open: { start: number; picked: boolean }[] = [];
|
|
let selectedAncestor = false;
|
|
try {
|
|
checkXmlSyntax(source, policy.doctype, (syntax) => {
|
|
// Saxes reports the opening after reading the name and one character past it, none of which is `<`.
|
|
syntax.on("opentagstart", () => {
|
|
open.push({
|
|
start: source.lastIndexOf("<", syntax.position - 1),
|
|
picked: false,
|
|
});
|
|
});
|
|
syntax.on("opentag", (tag: SaxesTagNS) => {
|
|
const element = open.at(-1);
|
|
if (element === undefined || selectedAncestor) return;
|
|
element.picked = select({
|
|
uri: tag.uri,
|
|
local: tag.local,
|
|
attribute: (uri, local) =>
|
|
Object.values(tag.attributes).find(
|
|
(attribute) => attribute.uri === uri && attribute.local === local,
|
|
)?.value,
|
|
});
|
|
selectedAncestor = element.picked;
|
|
});
|
|
syntax.on("closetag", () => {
|
|
const element = open.pop();
|
|
if (element?.picked === true) {
|
|
ranges.push({ start: element.start, end: syntax.position });
|
|
selectedAncestor = false;
|
|
}
|
|
});
|
|
});
|
|
return ok(ranges.sort((left, right) => left.start - right.start));
|
|
} catch (cause: unknown) {
|
|
return err(
|
|
cause instanceof XmlDocumentParseError
|
|
? cause
|
|
: new XmlDocumentParseError("malformed", cause),
|
|
);
|
|
}
|
|
};
|
|
|
|
/** Inclusive source ranges of attribute quotes, parsed without reading a DTD. */
|
|
export const xmlAttributeQuoteRanges = (
|
|
source: string,
|
|
): Result<
|
|
readonly { readonly start: number; readonly end: number }[],
|
|
{
|
|
readonly error: XmlDocumentParseError;
|
|
readonly ranges: readonly {
|
|
readonly start: number;
|
|
readonly end: number;
|
|
}[];
|
|
readonly examined: number;
|
|
}
|
|
> => {
|
|
const ranges: { start: number; end: number }[] = [];
|
|
let reader: SaxesParser<{ xmlns: true }> | undefined;
|
|
try {
|
|
checkXmlSyntax(source, "allow", (syntax) => {
|
|
reader = syntax;
|
|
syntax.on("attribute", () => {
|
|
const end = syntax.position - 1;
|
|
const quote = source[end];
|
|
if (quote !== '"' && quote !== "'")
|
|
throw new XmlDocumentParseError("malformed");
|
|
const start = source.lastIndexOf(quote, end - 1);
|
|
if (start < 0) throw new XmlDocumentParseError("malformed");
|
|
ranges.push({ start, end });
|
|
});
|
|
});
|
|
return ok(ranges);
|
|
} catch (cause: unknown) {
|
|
return err({
|
|
error:
|
|
cause instanceof XmlDocumentParseError
|
|
? cause
|
|
: new XmlDocumentParseError("malformed", cause),
|
|
ranges,
|
|
examined: Math.max(0, (reader?.position ?? 0) - 1),
|
|
});
|
|
}
|
|
};
|
|
|
|
/** One boundary owns strict XML syntax, under a DOCTYPE policy, and the source-located DOM projection. */
|
|
export const parseXmlDocument = (
|
|
source: string,
|
|
policy: {
|
|
readonly doctype: DoctypePolicy;
|
|
readonly lineEndings: "preserve" | "normalize";
|
|
readonly nameSupported?: (name: string) => boolean;
|
|
},
|
|
): Result<Document, XmlDocumentParseError> => {
|
|
try {
|
|
checkXmlSyntax(source, policy.doctype, undefined, policy.nameSupported);
|
|
// Saxes owns well-formedness outside DTDs. The DOM parser additionally
|
|
// checks DTD syntax and builds source-located nodes; its advisory warnings
|
|
// (including literal U+FFFD) are not byte-decoding or syntax failures.
|
|
return ok(
|
|
new DOMParser({
|
|
normalizeLineEndings:
|
|
policy.lineEndings === "preserve"
|
|
? (text) => text
|
|
: (text) => text.replace(XML_LINE_ENDING, "\n"),
|
|
onError: onErrorStopParsing,
|
|
}).parseFromString(source, "application/xml"),
|
|
);
|
|
} catch (cause: unknown) {
|
|
return err(
|
|
cause instanceof XmlDocumentParseError
|
|
? cause
|
|
: new XmlDocumentParseError("malformed", cause),
|
|
);
|
|
}
|
|
};
|
|
|
|
/** A DOCTYPE's name and external identifiers, null where it gives none. */
|
|
export interface DeclaredDocumentType {
|
|
readonly name: string;
|
|
readonly publicId: string | null;
|
|
readonly systemId: string | null;
|
|
}
|
|
|
|
/** A quoted literal's value; the DOM keeps the quotes, and holds "" where there is no literal. */
|
|
const literalValue = (literal: string): string | null =>
|
|
literal === "" ? null : literal.slice(1, -1);
|
|
|
|
/**
|
|
* The document's DOCTYPE as declared, or undefined without one. The public
|
|
* identifier is normalized as XML matches it, and as expat reports it: each
|
|
* run of spaces and line breaks becomes one space, and the ends are trimmed.
|
|
*/
|
|
export const declaredDocumentType = (
|
|
document: Document,
|
|
): DeclaredDocumentType | undefined => {
|
|
const doctype = document.doctype;
|
|
if (doctype === null) return undefined;
|
|
const publicId = literalValue(doctype.publicId);
|
|
return {
|
|
name: doctype.name,
|
|
publicId:
|
|
publicId === null
|
|
? null
|
|
: publicId.replace(/[ \r\n]+/gu, " ").replace(/^ | $/gu, ""),
|
|
systemId: literalValue(doctype.systemId),
|
|
};
|
|
};
|