mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 00:36:35 +00:00
feat(desktop): name a record by a nested field or a child's attribute (#9185)
* 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): count members of root JSON arrays * 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 * Format appTaskChecks.ts with the pinned oxfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * 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): give the root-array draft test its own describe The records describe grew past the 150-line lint limit with it. 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 * fix(desktop): carry document format into selector evaluation * test(desktop): separate markup boundary regression group * fix(desktop): propagate formats through draft selectors * fix(desktop): switch selector format within archive entries * test(desktop): remove duplicate seed filename case * test(desktop): pass JSON format in exported index parity * 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 * test(desktop): specify XML format in heap pairing fixture * 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): retain record identity in draft checks * 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>
This commit is contained in:
46 files changed
+2831
-405
No files matched your search
@@ -178,7 +178,11 @@ the first source that has the app:
|
||||
- CherryTree: its first-run autosave "Choose Storage Type" dialog swallows
|
||||
keys, so save once early as `.ctd` and untick autosave.
|
||||
- Glade: a new widget's id is empty until you set it, and a second save
|
||||
writes a `.glade~` backup.
|
||||
writes a `.glade~` backup, which shows in the draft as an unrelated
|
||||
change. A backup present at `seed` is seeded with the document, so trash
|
||||
it before `seed`. The draft names a button's `<child>` by the id
|
||||
its widget carries, `[name=child][>object@id=submit_button]`, which moving
|
||||
the button keeps; give the widget an id before `seed` so it can.
|
||||
- VYM: the title never names the map, so pass `Documents/<map>.vym` in
|
||||
`state_paths`. Its settings live in `.config/InSilmaril` and are written
|
||||
only at a clean exit. It writes `<map>.vym.lock` while a map is open, so
|
||||
@@ -228,10 +232,17 @@ the first source that has the app:
|
||||
- An app whose state folder is named after it, such as
|
||||
`~/.local/share/gnote`, or after it and its version, such as TreeLine's
|
||||
`~/.treeline-3.1`, is found without `state_paths`.
|
||||
- TreeLine: Ctrl+A adds a child node even while a field has focus. Clicking
|
||||
a node already selected starts an inline rename, so Ctrl+X then cuts its
|
||||
text: cut a node from its context menu. It saves nodes sorted by their
|
||||
generated uid1, so their order changes from run to run.
|
||||
- TreeLine: File → New opens a template window. Book List's CATEGORY makes
|
||||
AUTHOR children by default; change it under Data → Configure Data Types →
|
||||
Default Child Type. A new child opens an inline rename showing "", so
|
||||
press Escape and fill its fields in the Data Edit tab. Ctrl+A adds a child
|
||||
node even while a field has focus. Clicking a node already selected starts
|
||||
an inline rename, so Ctrl+X then cuts its text: cut a node from its
|
||||
context menu. It saves nodes sorted by their
|
||||
generated uid1, so their order changes from run to run. The draft names a
|
||||
new node by its format and a field of its `data`,
|
||||
`/nodes/[format=BOOK][>data~1Title=The Silk Roads]`, and checks its other
|
||||
fields through that, so give each new node a title no other node has.
|
||||
- After an app crashes, capture again on a fresh computer, so the seed
|
||||
carries no crash-recovery state, such as MyPaint's "Recover interrupted
|
||||
work?".
|
||||
@@ -336,8 +347,10 @@ the first source that has the app:
|
||||
- `by_position`: only position names what changed; its `elements` lack
|
||||
a naming attribute. Name a record that keeps its fields as child
|
||||
elements by one of them, as Osmo's contact by
|
||||
`[name=contact][>first_name=Delphine]`, and count records with
|
||||
`member_count`, `matching` `[name=contact]`; otherwise check an
|
||||
`[name=contact][>first_name=Delphine]`, a JSON record by a field
|
||||
inside it, `[>data/Title=…]` (`~1` for the `/` inside a pointer), or
|
||||
an element by an id its child carries, `[>object@id=…]`; count records
|
||||
with `member_count`, `matching` `[name=contact]`; otherwise check an
|
||||
ancestor's text, or give the element an id in the app and capture again.
|
||||
- `view_state_only`: every change is view state, a renumbered id or a
|
||||
time the app stamps, and its `pointers` are the checks kept. The edit
|
||||
|
||||
@@ -106,11 +106,23 @@ operation `describe_desktop_application_task` and predicate type
|
||||
JSON and key files gain
|
||||
`value_equals` and `value_absent` by pointer, a JSON array's records named
|
||||
by an id field each holds alone at both ends (`id`, `uid`, `uuid`, `guid`,
|
||||
`key` or a name ending in `_id` or `Id`), as TreeLine's
|
||||
`/nodes/[uid=7584d494d9f511f1a873edd4f1920549]`, whatever order the app
|
||||
saves them in, and by position only where nothing names a record, as a
|
||||
new record whose only id the app generated, with a `by_position` note
|
||||
listing those checks; never a value that is a uid the app generates (a
|
||||
`key` or a name ending in `_id` or `Id`), as `/lists/[id=chores]`,
|
||||
whatever order the app saves them in; where the app generated that id, a
|
||||
record both ends hold is named by its kind field and its first name-like
|
||||
string field (a key ending in `name`, `title`, `label` or `caption`),
|
||||
nearest first and up to three keys deep, that holds the same string at
|
||||
both ends and names it alone at each, as TreeLine's category by
|
||||
`/nodes/[format=CATEGORY][>data~1NAME=Science]`, and by its id where none
|
||||
does, as `/nodes/[uid=7584d494d9f511f1a873edd4f1920549]` once its name
|
||||
changed; and a record only one end holds is named by such a field that
|
||||
names it alone at its end and nothing at the other, as TreeLine's new book by
|
||||
`/nodes/[format=BOOK][>data~1Title=The Silk Roads]`, its other fields
|
||||
checked through that selector and the fields the selector reads not, nor
|
||||
a field holding what every like record (one of its kind) at the end holds
|
||||
there, as the empty `children` of every book; and
|
||||
by position only where nothing names a record, as a new book whose title
|
||||
another book holds, with a `by_position` note listing those checks;
|
||||
never a value that is a uid the app generates (a
|
||||
GUID, or 32 hex digits as TreeLine's uuid1), nor a check the seed already
|
||||
passes, as a new record's `format` where another record of that kind held
|
||||
its place; a removed record is checked gone by its own pointer, as
|
||||
@@ -147,7 +159,12 @@ operation `describe_desktop_application_task` and predicate type
|
||||
record by `[>first_name=Delphine]`, MuseScore's `<Text>` by
|
||||
`[>style=Title]`, a spreadsheet row by its label cell, or GnuCash's
|
||||
transaction by `[>trn:description=Greenleaf Market]`: text of at most 64
|
||||
characters, never a time or a token or id a program generates. A pair
|
||||
characters, never a time or a token or id a program generates; then by
|
||||
an identifier a child carries, `[>CHILD@ATTR=VALUE]`, as a GtkBuilder
|
||||
`<child>` by `[>object@id=submit_button]`, never one an app generates or
|
||||
numbers anew as it saves; and then by text a child joins from elements
|
||||
of its own, as Glade's `[>packing=TrueTrue1]` from a widget's packing
|
||||
properties, which moving the widget changes. A pair
|
||||
counts every member holding both its values, whichever attributes that
|
||||
member ranks first. An element with identifiers is named by those alone;
|
||||
an id the app generates, as Freeplane's `ID_329023692`, or a GUID in
|
||||
@@ -390,7 +407,12 @@ operation `describe_desktop_application_task` and predicate type
|
||||
ancestor's whole text a check.
|
||||
- `guards`: checks an author may add to show the edit left the rest alone,
|
||||
kept out of `task.checks`, with ids `guard-1` and on: for each suggested
|
||||
`value_equals` or `text_equals` on a member of a list, a field on up to
|
||||
`value_equals` or `text_equals` on a member of an XML list, or a
|
||||
`value_equals` through a JSON record added whole, as TreeLine's new book's
|
||||
`data/Author` gives
|
||||
`/nodes/[format=BOOK][>data~1Title=Small Engines of Life]/data/Author`
|
||||
(its like records are those of its kind, named as both ends pair them,
|
||||
and it reads only the check's own field), a field on up to
|
||||
two like siblings, those sharing most of the attributes its selector
|
||||
names it by and its type, as a cell's `Row` or `Col` and `ValueType`,
|
||||
then the nearest first, at its seed value, which the end holds
|
||||
@@ -459,7 +481,9 @@ operation `describe_desktop_application_task` and predicate type
|
||||
when the views hold only part of the file; `unreadable_view` when no
|
||||
check can read it, as a document REA reads as binary;
|
||||
`volatile_lines` when a text file gained lines holding a value the app
|
||||
generates with no text before it to count them by; and `read_as_text`
|
||||
generates with no text before it to count them by; `generated_reference`
|
||||
when an array references a newly generated record ID that cannot be checked
|
||||
exactly across runs, with up to five affected `pointers`; and `read_as_text`
|
||||
when a text file opens like XML, with a declaration, a DOCTYPE or an
|
||||
element, but reads as text because its DOCTYPE has an internal subset or
|
||||
it is not well formed, so its checks compare lines that hold attributes in
|
||||
@@ -578,16 +602,31 @@ rather than by its position:
|
||||
`[name=Object]`.
|
||||
- `[@KEY=VALUE]` names the member whose `attributes` hold `KEY="VALUE"`, as
|
||||
the XML view keeps an element's attributes.
|
||||
- `[>CHILD=VALUE]` names the XML element that has a child element `CHILD`
|
||||
whose text, every text node in it with each run of whitespace collapsed to
|
||||
one space and none at either end, is `VALUE`, for records that keep their
|
||||
fields as elements: Osmo's `<contact><first_name>Delphine</first_name>…`
|
||||
is `[name=contact][>first_name=Delphine]`. Any such child will do, and a
|
||||
member that is no element never matches.
|
||||
- `[>PATH=VALUE]` names the member that holds `VALUE` at a path of one to
|
||||
three `/`-separated steps inside it. `>` reads XML and JSON differently:
|
||||
|
||||
| Form | In an XML element | In a JSON record |
|
||||
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| `[>CHILD=VALUE]` | a child element `CHILD` whose text, every text node in it with each run of whitespace collapsed to one space and none at either end, is `VALUE` | the string field `CHILD`, compared whole |
|
||||
| `[>A/B=VALUE]`, up to `[>A/B/C=VALUE]` | a child `A` with a child `B` of that text | the string at `member.A.B`, compared whole |
|
||||
| `[>A@ATTR=VALUE]`, `[>A/B@ATTR=VALUE]` | a child `A` (or its child `B`) whose attribute `ATTR` is `VALUE` | the field named `A@ATTR`: `@` is part of a JSON key |
|
||||
|
||||
Records that keep their fields as elements are named by a child's text:
|
||||
Osmo's `<contact><first_name>Delphine</first_name>…` is
|
||||
`[name=contact][>first_name=Delphine]`. A GtkBuilder `<child>` is named by
|
||||
the id its widget carries, `[name=child][>object@id=submit_button]`, and a
|
||||
TreeLine node by a field of its `data`,
|
||||
`[format=BOOK][>data/Title=The Silk Roads]`. Any element on the way will
|
||||
do; a path with more than three steps, or an empty one, is no selector;
|
||||
and a missing step, or a value at its end that is not a string, matches
|
||||
nothing.
|
||||
|
||||
- Conditions can follow one another, `[name=Object][@name=Hole]`, and all must
|
||||
hold. They compare strings only, the selector is escaped like any pointer
|
||||
segment (`~1` for `/`), and a value cannot hold `][`. A field whose key
|
||||
begins with `@` or `>` cannot be selected by.
|
||||
segment, so a path's `/` is `~1` inside a pointer,
|
||||
`/nodes/[format=BOOK][>data~1Title=The Silk Roads]`, and a `/` in a key
|
||||
cannot be a step; a value cannot hold `][`. A JSON field whose key begins
|
||||
with `@` or `>` is selected through a path, `[>@type=Book]`.
|
||||
|
||||
A selector must name exactly one member. When several match, or none does
|
||||
before the last segment, the check fails and says which selector matched what.
|
||||
@@ -731,7 +770,7 @@ read the element's whole text instead:
|
||||
|
||||
`member_count` counts the members of the array its pointer names: a JSON
|
||||
array, or an XML element's `children`. `matching`, a selector of one or more
|
||||
conditions as [above](#pointers-and-selectors), `[>CHILD=VALUE]` included,
|
||||
conditions as [above](#pointers-and-selectors), `[>PATH=VALUE]` included,
|
||||
counts only the members it matches, however many; without it every member
|
||||
counts. An element's children hold its text nodes too, so give `matching`
|
||||
for XML, such as `[name=entry]`. A Tellico task that adds one book to a
|
||||
|
||||
@@ -101,7 +101,7 @@ See [desktop builds](https://github.com/morluto/rea/blob/main/.agents/skills/rev
|
||||
`unowned_changes` and `no_document` point at `state_paths`. They also say,
|
||||
for a drafted `file`, why its suggestions are thin: `by_position`,
|
||||
`view_state_only`, `long_text`, `cut_view`, `unreadable_view`,
|
||||
`volatile_lines` or `read_as_text`.
|
||||
`volatile_lines`, `generated_reference` or `read_as_text`.
|
||||
6. Write the task: its name, the prompt in the user's own words naming the
|
||||
app (a solving agent's own shell cannot see the computer's files, and the
|
||||
export's limitations say when a prompt names neither the app's title,
|
||||
|
||||
@@ -47,7 +47,10 @@ evaluators, REA's and the exported verifier's, agree.
|
||||
it is.
|
||||
- Selectors: on an array, a pointer segment `[KEY=VALUE]` names the member
|
||||
whose string field `KEY` is `VALUE`, `[@KEY=VALUE]` the member whose
|
||||
`attributes` hold it, and conditions can follow one another. A selector must
|
||||
`attributes` hold it, `[>PATH=VALUE]` the member holding it one to three
|
||||
`/`-separated steps inside (in XML a descendant element's collapsed text
|
||||
or, ending `NAME@ATTR`, a child's attribute; in JSON the string at those
|
||||
keys), and conditions can follow one another. A selector must
|
||||
match exactly one member; several, or none before the last segment, fail the
|
||||
check with a message naming the selector. None at the last segment names no
|
||||
value, so `value_absent` passes and `value_equals` fails.
|
||||
|
||||
@@ -35,8 +35,11 @@ never runs code the agent wrote.
|
||||
`line_count` (`exact` or `contains`; `equals`, `at_least` or `at_most`),
|
||||
`value_equals` (optional numeric `tolerance`) and `value_absent` (RFC 6901
|
||||
pointer, with selectors on arrays: `[KEY=VALUE]` by a field, `[@KEY=VALUE]`
|
||||
by an attribute, `[>CHILD=VALUE]` by a child element's text with its
|
||||
whitespace collapsed), `text_equals` and `text_contains` (an XML element's
|
||||
by an attribute, `[>PATH=VALUE]` by what one to three `/`-separated steps
|
||||
inside the member hold: in XML a child element's text with its whitespace
|
||||
collapsed, a grandchild's (`A/B`) or, ending `NAME@ATTR`, a child's
|
||||
attribute; in JSON the string at those keys), `text_equals` and
|
||||
`text_contains` (an XML element's
|
||||
whole text at a pointer, optional `normalize_whitespace`; see
|
||||
[XML text checks](../xml-text-checks/spec.md); with `text_attribute`, the
|
||||
values of that attribute on the element and its descendants in document
|
||||
|
||||
@@ -82,7 +82,7 @@ const checkSchema = z.discriminatedUnion("kind", [
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"A selector of one or more [KEY=VALUE], [@KEY=VALUE] or [>CHILD=VALUE] conditions that a member must meet to count; every member counts without it, text nodes of an XML element's children included.",
|
||||
"A selector of one or more [KEY=VALUE], [@KEY=VALUE] or [>PATH=VALUE] conditions that a member must meet to count, where PATH is up to three /-separated steps inside the member; every member counts without it, text nodes of an XML element's children included.",
|
||||
),
|
||||
...comparison,
|
||||
}),
|
||||
@@ -223,7 +223,7 @@ const checkProblem = (check: DesktopTaskCheck): string | undefined => {
|
||||
check.matching !== undefined &&
|
||||
selectorOf(check.matching) === undefined
|
||||
)
|
||||
return `matching ${JSON.stringify(check.matching)} is not a selector of one or more [KEY=VALUE], [@KEY=VALUE] or [>CHILD=VALUE]`;
|
||||
return `matching ${JSON.stringify(check.matching)} is not a selector of one or more [KEY=VALUE], [@KEY=VALUE] or [>PATH=VALUE], a PATH of one to three steps`;
|
||||
if (
|
||||
(check.kind === "lines_present" || check.kind === "lines_absent") &&
|
||||
new Set(check.lines).size !== check.lines.length
|
||||
|
||||
@@ -132,6 +132,29 @@ export const CHECK_VIEWS: Readonly<Record<string, TaskFileView>> = {
|
||||
format: "zip",
|
||||
view: { entries: [], entry_count: 1200, truncated: true },
|
||||
},
|
||||
"Documents/shaped.zip": {
|
||||
format: "zip",
|
||||
view: {
|
||||
entries: [
|
||||
{
|
||||
name: "a.json",
|
||||
format: "json",
|
||||
view: {
|
||||
nodes: [
|
||||
{
|
||||
name: "node",
|
||||
attributes: {},
|
||||
children: [],
|
||||
data: { Title: "New" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
entry_count: 1,
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
"Documents/cut.xml": {
|
||||
format: "xml",
|
||||
view: {
|
||||
@@ -277,6 +300,80 @@ export const CHECK_VIEWS: Readonly<Record<string, TaskFileView>> = {
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
"Documents/reading-list.trln": {
|
||||
format: "json",
|
||||
view: {
|
||||
nodes: [
|
||||
{
|
||||
children: [],
|
||||
data: { Title: "The Guns of August", Year: "1962" },
|
||||
format: "BOOK",
|
||||
uid: "812403bed9f511f19fc4edd4f1920549",
|
||||
},
|
||||
{
|
||||
name: "node",
|
||||
attributes: {},
|
||||
children: [],
|
||||
data: {
|
||||
Author: "Peter Frankopan",
|
||||
Pages: 636,
|
||||
Title: "The Silk Roads",
|
||||
Year: "2015",
|
||||
},
|
||||
format: "BOOK",
|
||||
uid: "fc11808dd9f511f181b7edd4f1920549",
|
||||
},
|
||||
{
|
||||
children: ["812403bed9f511f19fc4edd4f1920549"],
|
||||
data: { NAME: "History" },
|
||||
format: "CATEGORY",
|
||||
uid: "7584d494d9f511f1a873edd4f1920549",
|
||||
},
|
||||
{
|
||||
children: [],
|
||||
data: { Title: { text: "Margins" } },
|
||||
format: "NOTE",
|
||||
meta: { "@type": "Note", "a~1b": "tilde", "x/y": "slash" },
|
||||
uid: "0a11808dd9f511f181b7edd4f1920549",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"Documents/feedback.glade": {
|
||||
format: "xml",
|
||||
view: {
|
||||
root: element("interface", {}, [
|
||||
element("child", {}, [
|
||||
element("object", { class: "GtkButton", id: "cancel_button" }, [
|
||||
element("property", { name: "label" }, ["Cancel"]),
|
||||
]),
|
||||
element("packing", {}, [
|
||||
element("property", { name: "position" }, ["0"]),
|
||||
]),
|
||||
]),
|
||||
element("child", {}, [
|
||||
element("object", { class: "GtkButton", id: "submit_button" }, [
|
||||
element("property", { name: "label", translatable: "yes" }, [
|
||||
"\n Send\tFeedback ",
|
||||
]),
|
||||
]),
|
||||
element("packing", {}, [
|
||||
element("property", { name: "position" }, ["1"]),
|
||||
]),
|
||||
]),
|
||||
element("child", {}, [
|
||||
element("object", { class: "GtkBox" }, [
|
||||
element("child", {}, [
|
||||
element("object", { class: "GtkLabel", id: "intro_label" }, [
|
||||
element("property", { name: "label" }, ["Tell us"]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
"Documents/ledger.json": {
|
||||
format: "json",
|
||||
view: [
|
||||
@@ -1369,6 +1466,362 @@ const CHILD_TEXT_CASES: readonly (readonly [DesktopTaskCheckInput, boolean])[] =
|
||||
],
|
||||
];
|
||||
|
||||
const books = "Documents/reading-list.trln";
|
||||
const glade = "Documents/feedback.glade";
|
||||
|
||||
/**
|
||||
* Members named by a path inside them of up to three steps: in JSON the string
|
||||
* at those keys, compared whole; in XML a descendant element's text, its
|
||||
* whitespace collapsed, or with `NAME@ATTR` last its attribute.
|
||||
*/
|
||||
const INSIDE_PATH_CASES: readonly (readonly [
|
||||
DesktopTaskCheckInput,
|
||||
boolean,
|
||||
])[] = [
|
||||
[
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: "Documents/plate.FCStd",
|
||||
pointer: "/entries/[>name=Document.xml]/format",
|
||||
value: "xml",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "member_count",
|
||||
file: "Documents/plate.FCStd",
|
||||
pointer: "/entries/[>name=Document.xml]/view/root/children",
|
||||
matching: "[>Property/Float@value=5.0000000000000000]",
|
||||
equals: 1,
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "text_equals",
|
||||
file: "Documents/plate.FCStd",
|
||||
pointer:
|
||||
"/entries/[>name=Document.xml]/view/root/children/[>Property~1Float@value=5.0000000000000000]/children/[name=Property]/children/[name=Float]/attributes/value",
|
||||
value: "5.0000000000000000",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: "Documents/plate.FCStd",
|
||||
pointer:
|
||||
"/entries/[>name=Document.xml]/view/root/children/[>Property~1Float@value=5.0000000000000000]/name",
|
||||
value: "Object",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: "Documents/shaped.zip",
|
||||
pointer:
|
||||
"/entries/[>name=a.json]/view/nodes/[>data~1Title=New]/data/Title",
|
||||
value: "New",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_absent",
|
||||
file: books,
|
||||
pointer: "/nodes/[>data~1Title=The Silk Roads]",
|
||||
},
|
||||
false,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "member_count",
|
||||
file: books,
|
||||
pointer: "/nodes",
|
||||
matching: "[>data/Title=The Silk Roads]",
|
||||
equals: 1,
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_absent",
|
||||
file: "Documents/shaped.zip",
|
||||
pointer: "/entries/[name=a.json]/view/nodes/[>data~1Title=New]",
|
||||
},
|
||||
false,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "member_count",
|
||||
file: "Documents/shaped.zip",
|
||||
pointer: "/entries/[name=a.json]/view/nodes",
|
||||
matching: "[>data/Title=New]",
|
||||
equals: 1,
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: books,
|
||||
pointer: "/nodes/[format=BOOK][>data~1Title=The Silk Roads]/data/Author",
|
||||
value: "Peter Frankopan",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: books,
|
||||
pointer: "/nodes/[>data~1Title=The Guns of August]/data/Year",
|
||||
value: "1962",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: books,
|
||||
pointer: "/nodes/[>format=CATEGORY]/data/NAME",
|
||||
value: "History",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: books,
|
||||
pointer: "/nodes/[>data~1Title~1text=Margins]/format",
|
||||
value: "NOTE",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: books,
|
||||
pointer: "/nodes/[>meta~1a~01b=tilde]/format",
|
||||
value: "NOTE",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: books,
|
||||
pointer: "/nodes/[>meta~1@type=Note]/format",
|
||||
value: "NOTE",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_absent",
|
||||
file: books,
|
||||
pointer: "/nodes/[>meta~1x~1y=slash]",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_absent",
|
||||
file: books,
|
||||
pointer: "/nodes/[>data~1Subtitle=The Silk Roads]",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_absent",
|
||||
file: books,
|
||||
pointer: "/nodes/[>data~1Subtitle=The Silk Roads]/uid",
|
||||
},
|
||||
false,
|
||||
],
|
||||
[
|
||||
{ kind: "value_absent", file: books, pointer: "/nodes/[>data~1Pages=636]" },
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_absent",
|
||||
file: books,
|
||||
pointer: "/nodes/[>data~1Title=Margins]",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_absent",
|
||||
file: books,
|
||||
pointer: "/nodes/[>data~1Title~1text~1x=Margins]",
|
||||
},
|
||||
false,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "member_count",
|
||||
file: books,
|
||||
pointer: "/nodes",
|
||||
matching: "[format=BOOK][>data/Year=2015]",
|
||||
equals: 1,
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "member_count",
|
||||
file: books,
|
||||
pointer: "/nodes",
|
||||
matching: "[>data/Title=The Silk Roads]",
|
||||
equals: 1,
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "text_equals",
|
||||
file: glade,
|
||||
pointer:
|
||||
"/root/children/[name=child][>object@id=submit_button]/children/[name=object]/children/[name=property][@name=label]",
|
||||
value: "Send Feedback",
|
||||
normalize_whitespace: true,
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: glade,
|
||||
pointer:
|
||||
"/root/children/[>object~1property=Cancel]/children/[name=packing]/children/[name=property]/children/0",
|
||||
value: "0",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: glade,
|
||||
pointer:
|
||||
"/root/children/[>object~1property=Send Feedback]/children/[name=object]/attributes/id",
|
||||
value: "submit_button",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: glade,
|
||||
pointer:
|
||||
"/root/children/[>object~1property@translatable=yes]/children/[name=object]/attributes/id",
|
||||
value: "submit_button",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: glade,
|
||||
pointer:
|
||||
"/root/children/[>object~1child~1object@id=intro_label]/children/0/attributes/class",
|
||||
value: "GtkBox",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: glade,
|
||||
pointer:
|
||||
"/root/children/[>object~1child~1object=Tell us]/children/0/attributes/class",
|
||||
value: "GtkBox",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_absent",
|
||||
file: glade,
|
||||
pointer: "/root/children/[>object@id=gone_button]",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_absent",
|
||||
file: glade,
|
||||
pointer: "/root/children/[>object@id~1property=Cancel]",
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_absent",
|
||||
file: glade,
|
||||
pointer: "/root/children/[>object@class=GtkButton]",
|
||||
},
|
||||
false,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_absent",
|
||||
file: glade,
|
||||
pointer: "/root/children/[>object~1child~1object~1property=Tell us]",
|
||||
},
|
||||
false,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "member_count",
|
||||
file: glade,
|
||||
pointer: "/root/children",
|
||||
matching: "[name=child][>object@class=GtkButton]",
|
||||
equals: 2,
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "member_count",
|
||||
file: glade,
|
||||
pointer: "/root/children",
|
||||
matching: "[>packing/property@name=position][>object/property=Cancel]",
|
||||
equals: 1,
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_absent",
|
||||
file: "Documents/cut.xml",
|
||||
pointer: "/root/children/[name=p][>q~1r=x]",
|
||||
},
|
||||
false,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "value_absent",
|
||||
file: "Documents/cut.xml",
|
||||
pointer: "/root/children/[>q@id=x]",
|
||||
},
|
||||
false,
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "member_count",
|
||||
file: "Documents/cut.xml",
|
||||
pointer: "/root/children",
|
||||
matching: "[>q@id=x]",
|
||||
equals: 0,
|
||||
},
|
||||
false,
|
||||
],
|
||||
];
|
||||
|
||||
/** Counts of an array's members, every one or those a selector matches, decided only on an array the view holds whole. */
|
||||
const MEMBER_COUNT_CASES: readonly (readonly [
|
||||
DesktopTaskCheckInput,
|
||||
@@ -1792,4 +2245,5 @@ export const CHECK_CASES: readonly (readonly [
|
||||
...ATTRIBUTE_TEXT_CASES,
|
||||
...CHILD_TEXT_CASES,
|
||||
...MEMBER_COUNT_CASES,
|
||||
...INSIDE_PATH_CASES,
|
||||
];
|
||||
@@ -292,7 +292,14 @@ const REFUSALS: readonly (readonly [Record<string, unknown>, string])[] = [
|
||||
{ checks: [{ kind: "member_count", file: "a", pointer: "x", equals: 1 }] },
|
||||
"must start with /",
|
||||
],
|
||||
...["[>first_name]", "first_name=x", "[name=a]x"].map(
|
||||
...[
|
||||
"[>first_name]",
|
||||
"first_name=x",
|
||||
"[name=a]x",
|
||||
"[>data/info/label/x=Deep]",
|
||||
"[>data//Title=x]",
|
||||
"[>/Title=x]",
|
||||
].map(
|
||||
(matching) =>
|
||||
[
|
||||
{
|
||||
|
||||
@@ -30,6 +30,20 @@ export interface TaskFileView {
|
||||
readonly error?: string | null;
|
||||
}
|
||||
|
||||
/** The format owns selector interpretation, including inside archive entries. */
|
||||
const selectorFormat = (
|
||||
entry: TaskFileView | undefined,
|
||||
pointer: string,
|
||||
): string | null | undefined => {
|
||||
if (entry?.format !== "zip") return entry?.format;
|
||||
const member = zipEntryAt(entry.view, pointer);
|
||||
return typeof member === "object" &&
|
||||
member !== null &&
|
||||
typeof member.format === "string"
|
||||
? member.format
|
||||
: "zip";
|
||||
};
|
||||
|
||||
/**
|
||||
* How one check came out against one set of views. `undecided` marks a failure
|
||||
* that shows nothing about the task: its file or table could not be read, or
|
||||
@@ -287,7 +301,9 @@ const valueVerdict = (
|
||||
const read = viewOf(check, entry, undefined);
|
||||
if ("passed" in read) return read;
|
||||
const located =
|
||||
entry === undefined ? NOTHING : locatePointer(read.view, check.pointer);
|
||||
entry === undefined
|
||||
? NOTHING
|
||||
: locatePointer(read.view, check.pointer, entry.format);
|
||||
const at = `${check.file} at ${check.pointer}`;
|
||||
const unknown = unreadOrPartial(check, entry?.format, read, located);
|
||||
if (unknown !== undefined) return unknown;
|
||||
@@ -331,11 +347,15 @@ const valueVerdict = (
|
||||
* element's `children` (a text node) or `attributes` (an attribute's value),
|
||||
* rather than an element's name or a field of the DOCTYPE.
|
||||
*/
|
||||
const isTextAt = (view: JsonValue | null, pointer: string): boolean => {
|
||||
const isTextAt = (
|
||||
view: JsonValue | null,
|
||||
pointer: string,
|
||||
format: string | null,
|
||||
): boolean => {
|
||||
const segments = pointer.split("/");
|
||||
const field = segments.at(-2);
|
||||
if (field !== "children" && field !== "attributes") return false;
|
||||
const owner = locatePointer(view, segments.slice(0, -2).join("/"));
|
||||
const owner = locatePointer(view, segments.slice(0, -2).join("/"), format);
|
||||
return owner.kind === "value" && isXmlElement(owner.value);
|
||||
};
|
||||
|
||||
@@ -389,7 +409,7 @@ const textVerdict = (
|
||||
const zip = entry.format === "zip";
|
||||
if (!zip && entry.format !== "xml")
|
||||
return fail(`${check.file} is read as ${String(entry.format)}, not xml`);
|
||||
const located = locatePointer(read.view, check.pointer);
|
||||
const located = locatePointer(read.view, check.pointer, entry.format);
|
||||
const unknown = unreadOrPartial(check, entry.format, read, located);
|
||||
if (unknown !== undefined) return unknown;
|
||||
const problem = zip ? notXmlEntry(check, read.view) : undefined;
|
||||
@@ -412,7 +432,8 @@ const textVerdict = (
|
||||
const attribute = check.text_attribute;
|
||||
if (
|
||||
typeof found === "string"
|
||||
? attribute !== undefined || !isTextAt(read.view, check.pointer)
|
||||
? attribute !== undefined ||
|
||||
!isTextAt(read.view, check.pointer, entry.format)
|
||||
: !isXmlElement(found)
|
||||
)
|
||||
return fail(
|
||||
@@ -432,13 +453,10 @@ const memberVerdict = (
|
||||
const { matching, pointer } = check;
|
||||
const read = viewOf(check, entry, undefined);
|
||||
if ("passed" in read) return read;
|
||||
// The empty pointer names the whole view, as a JSON file whose top level is an array.
|
||||
const located: Located =
|
||||
entry === undefined
|
||||
? NOTHING
|
||||
: pointer === ""
|
||||
? { kind: "value", value: read.view, searched: [], byPosition: [] }
|
||||
: locatePointer(read.view, pointer);
|
||||
: locatePointer(read.view, pointer, entry.format);
|
||||
const at = pointer === "" ? check.file : `${check.file} at ${pointer}`;
|
||||
const unknown = unreadOrPartial(check, entry?.format, read, located);
|
||||
if (unknown !== undefined) return unknown;
|
||||
@@ -458,7 +476,8 @@ const memberVerdict = (
|
||||
const meets =
|
||||
matching === undefined
|
||||
? () => true
|
||||
: (selectorTest(matching) ?? (() => false));
|
||||
: (selectorTest(matching, selectorFormat(entry, pointer)) ??
|
||||
(() => false));
|
||||
return compared(
|
||||
check,
|
||||
items.filter(meets).length,
|
||||
|
||||
@@ -414,7 +414,7 @@ describe("draftTaskFiles for an AbiWord document", () => {
|
||||
).toEqual([]);
|
||||
// Every check names the elements it goes through, so a reordered save keeps it.
|
||||
for (const pointer of pointers) {
|
||||
const located = locatePointer(after, pointer);
|
||||
const located = locatePointer(after, pointer, "xml");
|
||||
expect([pointer, located.byPosition]).toEqual([pointer, []]);
|
||||
}
|
||||
expect(
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import { isTimestamp, type DesktopTaskCheckInput } from "./appTask.js";
|
||||
import { isGeneratedUid } from "./attributeRank.js";
|
||||
import {
|
||||
decisive,
|
||||
generatedRecordId,
|
||||
isLikeDefault,
|
||||
jsonCounts,
|
||||
outermost,
|
||||
ownNamesUnreadOtherwise,
|
||||
positionalNotes,
|
||||
recordGuards,
|
||||
} from "./appTaskDraftJson.js";
|
||||
import {
|
||||
fileNotes,
|
||||
generatedReferenceNotes,
|
||||
readAsTextNotes,
|
||||
type FileNote,
|
||||
} from "./appTaskDraftFileNotes.js";
|
||||
@@ -198,7 +202,8 @@ const plainChange = (
|
||||
baseline: Version,
|
||||
final: Version,
|
||||
) => {
|
||||
if (format === "json") return diffNamedMembers(baseline.view, final.view);
|
||||
if (format === "json")
|
||||
return diffNamedMembers(baseline.view, final.view, "json");
|
||||
const change = diffStateViews(format, baseline.view, final.view);
|
||||
const pointers = keyPointers([baseline.view, final.view]);
|
||||
const at = (entries: readonly string[]) =>
|
||||
@@ -282,26 +287,57 @@ const valueSuggestions = (
|
||||
format === "keyfile" ? entries : innermost(entries);
|
||||
const wholes = (entries: readonly string[]) =>
|
||||
format === "keyfile" ? entries : outermost(entries);
|
||||
const listed: Suggestion[] = [];
|
||||
const edits: Suggestion[] = [];
|
||||
const ends = versions(baseline, final);
|
||||
const generatedId =
|
||||
format === "json" ? generatedRecordId(ends, "json") : () => false;
|
||||
// Array references to a newly added record cannot compare its generated id
|
||||
// across runs. Only ids selected by record pairing qualify; an existing
|
||||
// account or other scalar value remains an exact check.
|
||||
const newRecordIds = new Set(
|
||||
change.added.flatMap((entry) => {
|
||||
const at = pointer(entry);
|
||||
const value =
|
||||
at === undefined ? undefined : resolvePointer(final.view, at, format);
|
||||
return at !== undefined && generatedId(at) && typeof value === "string"
|
||||
? [value]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
// A key file's entries hold no array, so no record of one is added whole.
|
||||
const added = new Set(change.added);
|
||||
const generatedReferences: string[] = [];
|
||||
for (const entry of leaves([...change.added, ...change.changed])) {
|
||||
const at = pointer(entry);
|
||||
const value = at === undefined ? undefined : resolvePointer(final.view, at);
|
||||
const value =
|
||||
at === undefined ? undefined : resolvePointer(final.view, at, format);
|
||||
const generatedReference =
|
||||
at !== undefined &&
|
||||
typeof value === "string" &&
|
||||
newRecordIds.has(value) &&
|
||||
Array.isArray(
|
||||
resolvePointer(final.view, at.replace(/\/[^/]+$/u, ""), format),
|
||||
);
|
||||
if (generatedReference) generatedReferences.push(at);
|
||||
// An id the app generates, as TreeLine's uuid1 `uid`, holds nothing another save repeats.
|
||||
if (
|
||||
at !== undefined &&
|
||||
at !== "" &&
|
||||
value !== undefined &&
|
||||
!isTimestamp(value) &&
|
||||
!(typeof value === "string" && isGeneratedUid(value))
|
||||
!generatedId(at) &&
|
||||
!generatedReference &&
|
||||
!isLikeDefault(ends, added, at, value)
|
||||
)
|
||||
listed.push({ kind: "value_equals", file, pointer: at, value });
|
||||
edits.push({ kind: "value_equals", file, pointer: at, value });
|
||||
}
|
||||
const listed = ownNamesUnreadOtherwise(edits);
|
||||
for (const entry of wholes(change.removed)) {
|
||||
const at = pointer(entry);
|
||||
if (at !== undefined && at !== "")
|
||||
listed.push({ kind: "value_absent", file, pointer: at });
|
||||
}
|
||||
const suggestions = decisive(listed, versions(baseline, final));
|
||||
const suggestions = decisive(listed, ends);
|
||||
const { values, cut } = plainValues(
|
||||
plainChange(format, baseline, final),
|
||||
baseline,
|
||||
@@ -309,14 +345,21 @@ const valueSuggestions = (
|
||||
);
|
||||
if (format === "keyfile")
|
||||
return { suggested: suggestions, values, valuesCut: cut, guards: [] };
|
||||
const counts = jsonCounts(file, change, versions(baseline, final));
|
||||
const counts = jsonCounts(file, change, ends);
|
||||
const suggested = [...suggestions, ...counts.checks];
|
||||
return {
|
||||
suggested,
|
||||
values,
|
||||
valuesCut: cut,
|
||||
guards: counts.guards,
|
||||
notes: positionalNotes(file, suggested, final.view),
|
||||
// The count guard sits outside the cap, as for XML.
|
||||
guards: [
|
||||
...recordGuards(file, suggested.slice(0, MAX_SUGGESTIONS), ends, added),
|
||||
...counts.guards,
|
||||
],
|
||||
notes: [
|
||||
...positionalNotes(file, suggested, final.view),
|
||||
...generatedReferenceNotes(file, generatedReferences),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ const parentElementOf = (pointer: string): string | undefined =>
|
||||
|
||||
/** How many elements with a tag the list a member sits in holds, in a view. */
|
||||
const likeCount = (view: JsonValue | null, pointer: string, tag: string) => {
|
||||
const list = resolvePointer(view, listOf(pointer));
|
||||
const list = resolvePointer(view, listOf(pointer), "xml");
|
||||
return Array.isArray(list)
|
||||
? list.filter((item) => isXmlElement(item) && item.name === tag).length
|
||||
: 0;
|
||||
@@ -98,7 +98,7 @@ const copiesSibling = (
|
||||
) =>
|
||||
attributes.length >= MIN_COPIED &&
|
||||
[ends.baseline, ends.final].some((view) => {
|
||||
const list = resolvePointer(view, listOf(pointer));
|
||||
const list = resolvePointer(view, listOf(pointer), "xml");
|
||||
return (
|
||||
Array.isArray(list) &&
|
||||
list.some(
|
||||
@@ -189,7 +189,7 @@ export const attributeChecks = (
|
||||
}
|
||||
// A record's data is in its fields; an attribute every like record holds alike, as GnuCash's `version`, is its format's.
|
||||
const nested = element.children.some(isXmlElement);
|
||||
const list = resolvePointer(ends.final, listOf(pointer));
|
||||
const list = resolvePointer(ends.final, listOf(pointer), "xml");
|
||||
const likes = (Array.isArray(list) ? list : []).filter(
|
||||
(item) =>
|
||||
item !== element && isXmlElement(item) && item.name === element.name,
|
||||
@@ -286,8 +286,8 @@ const keptNamedAncestor = (context: AddedContext, pointer: string) => {
|
||||
)
|
||||
if (
|
||||
context.named(ends.final, at) &&
|
||||
isXmlElement(resolvePointer(ends.baseline, at)) &&
|
||||
isXmlElement(resolvePointer(ends.final, at))
|
||||
isXmlElement(resolvePointer(ends.baseline, at, "xml")) &&
|
||||
isXmlElement(resolvePointer(ends.final, at, "xml"))
|
||||
)
|
||||
return at;
|
||||
return undefined;
|
||||
@@ -322,8 +322,8 @@ export const fieldChecks = (
|
||||
const onRecord = context.named(ends.final, pointer);
|
||||
const seen = onRecord
|
||||
? ""
|
||||
: elementText(resolvePointer(ends.baseline, at) ?? null);
|
||||
const { keys } = pairMembers(element.children, []).newer;
|
||||
: elementText(resolvePointer(ends.baseline, at, "xml") ?? null);
|
||||
const { keys } = pairMembers(element.children, [], undefined, "xml").newer;
|
||||
const texts: string[] = [];
|
||||
let alreadySeen = false;
|
||||
element.children.forEach((field, index) => {
|
||||
@@ -365,8 +365,8 @@ export const fieldChecks = (
|
||||
}));
|
||||
if (onRecord) return [];
|
||||
// Every new field repeats text the seed already has. The list's text shows the record was added, when it fits and holds no save time. A save time stays out of the check: the new record's own text has to be there, and the seed's text has to remain.
|
||||
const holder = resolvePointer(ends.final, at);
|
||||
const before = resolvePointer(ends.baseline, at);
|
||||
const holder = resolvePointer(ends.final, at, "xml");
|
||||
const before = resolvePointer(ends.baseline, at, "xml");
|
||||
const now = elementText(holder ?? null);
|
||||
const plain = plainText(holder ?? null);
|
||||
const seedPlain = plainText(before ?? null);
|
||||
@@ -395,7 +395,7 @@ const sharedFields = (
|
||||
pointer: string,
|
||||
element: Element,
|
||||
) => {
|
||||
const list = resolvePointer(ends.final, listOf(pointer));
|
||||
const list = resolvePointer(ends.final, listOf(pointer), "xml");
|
||||
const likes = (Array.isArray(list) ? list : []).filter(
|
||||
(item) =>
|
||||
item !== element && isXmlElement(item) && item.name === element.name,
|
||||
@@ -488,7 +488,7 @@ const fieldsOf = (
|
||||
{ nested, selector = false }: { nested: boolean; selector?: boolean },
|
||||
): Suggestion[] => {
|
||||
const { ends, file } = context;
|
||||
const { keys } = pairMembers(element.children, []).newer;
|
||||
const { keys } = pairMembers(element.children, [], undefined, "xml").newer;
|
||||
const skipped = new Set([
|
||||
...sharedFields(context, pointer, element),
|
||||
...(selector ? [] : selectorFields(pointer)),
|
||||
@@ -509,7 +509,8 @@ const fieldsOf = (
|
||||
)
|
||||
return [];
|
||||
if (nested && recordList(field)) {
|
||||
const records = pairMembers(field.children, []).newer.keys;
|
||||
const records = pairMembers(field.children, [], undefined, "xml").newer
|
||||
.keys;
|
||||
return field.children.flatMap((record, place) =>
|
||||
isXmlElement(record)
|
||||
? fieldsOf(
|
||||
|
||||
@@ -37,10 +37,15 @@ export const alternativePointers = (
|
||||
if (conditions === undefined) continue;
|
||||
// A step its tag alone names, as an XHTML `h1` holding `id="chapter-two"`, gives way outward when nothing else names it.
|
||||
const tagOnly = conditions.every(({ prefix }) => prefix === "");
|
||||
const list = resolvePointer(view, segments.slice(0, index).join("/"));
|
||||
const list = resolvePointer(
|
||||
view,
|
||||
segments.slice(0, index).join("/"),
|
||||
"xml",
|
||||
);
|
||||
const element = resolvePointer(
|
||||
view,
|
||||
segments.slice(0, index + 1).join("/"),
|
||||
"xml",
|
||||
);
|
||||
if (!Array.isArray(list) || !isXmlElement(element)) {
|
||||
if (tagOnly) continue;
|
||||
|
||||
@@ -36,7 +36,7 @@ const textAt = (
|
||||
pointer: string,
|
||||
attribute?: string,
|
||||
) => {
|
||||
const element = resolvePointer(view, pointer);
|
||||
const element = resolvePointer(view, pointer, "xml");
|
||||
return isXmlElement(element) ? elementText(element, attribute) : undefined;
|
||||
};
|
||||
|
||||
@@ -55,7 +55,7 @@ const isNamedRecord = (node: JsonValue, key: string) =>
|
||||
* and the nodes in it, each holding `TEXT`, are no runs.
|
||||
*/
|
||||
const runsOf = (view: JsonValue | null, pointer: string, key: string) => {
|
||||
const element = resolvePointer(view, pointer);
|
||||
const element = resolvePointer(view, pointer, "xml");
|
||||
const holds = (node: JsonValue) =>
|
||||
isXmlElement(node) && typeof node.attributes[key] === "string";
|
||||
let runs = 0;
|
||||
@@ -122,7 +122,7 @@ export const attributeTextCheck = (
|
||||
| undefined => {
|
||||
const { ends } = context;
|
||||
const element = [ends.final, ends.baseline]
|
||||
.map((view) => resolvePointer(view, owner))
|
||||
.map((view) => resolvePointer(view, owner, "xml"))
|
||||
.find(isXmlElement);
|
||||
const value = element?.attributes[key];
|
||||
if (
|
||||
@@ -169,7 +169,7 @@ export const textAttributes = (
|
||||
noise: (pointer: string) => NoiseReason | undefined,
|
||||
) => {
|
||||
const own = (view: JsonValue | null, pointer: string) => {
|
||||
const element = resolvePointer(view, pointer);
|
||||
const element = resolvePointer(view, pointer, "xml");
|
||||
return isXmlElement(element)
|
||||
? Object.keys(element.attributes).map((key) => ({ owner: pointer, key }))
|
||||
: [];
|
||||
@@ -194,7 +194,7 @@ export const readsText =
|
||||
(checked: readonly { readonly element: string; readonly key: string }[]) =>
|
||||
(pointer: string, view: JsonValue | null): boolean =>
|
||||
checked.some(({ element, key }) => {
|
||||
const run = resolvePointer(view, pointer);
|
||||
const run = resolvePointer(view, pointer, "xml");
|
||||
return (
|
||||
pointer.startsWith(`${element}/`) &&
|
||||
(attributeKeyOf(pointer) === key ||
|
||||
|
||||
@@ -40,7 +40,7 @@ const listOf = (pointer: string): string | undefined =>
|
||||
|
||||
/** The members of a tag, and kind, in a list, in a view. */
|
||||
const membersOf = (view: JsonValue | null, { list, tag, kind }: Counted) => {
|
||||
const items = resolvePointer(view, list);
|
||||
const items = resolvePointer(view, list, "xml");
|
||||
return Array.isArray(items)
|
||||
? items.filter(
|
||||
(item) =>
|
||||
@@ -83,7 +83,11 @@ const keyOf = ({ list, tag, kind }: Counted) =>
|
||||
/** Whether a list's element holds text of its own at either end, so its elements are runs of that text. */
|
||||
const mixed = ({ ends }: CountContext, list: string) =>
|
||||
[ends.baseline, ends.final].some((view) => {
|
||||
const owner = resolvePointer(view, list.slice(0, -"/children".length));
|
||||
const owner = resolvePointer(
|
||||
view,
|
||||
list.slice(0, -"/children".length),
|
||||
"xml",
|
||||
);
|
||||
return (
|
||||
isXmlElement(owner) &&
|
||||
owner.children.some(
|
||||
@@ -136,7 +140,7 @@ const editedList = (
|
||||
for (const [index, step] of steps.entries()) {
|
||||
const { list } = step;
|
||||
const at = `${list}/${step.member}`;
|
||||
const member = resolvePointer(ends.final, at);
|
||||
const member = resolvePointer(ends.final, at, "xml");
|
||||
const local = isXmlElement(member)
|
||||
? (member.name.split(":").at(-1) ?? member.name)
|
||||
: "";
|
||||
@@ -149,8 +153,8 @@ const editedList = (
|
||||
continue;
|
||||
const kind = kindOf(
|
||||
member,
|
||||
resolvePointer(ends.final, list) ?? null,
|
||||
resolvePointer(ends.baseline, at) ?? null,
|
||||
resolvePointer(ends.final, list, "xml") ?? null,
|
||||
resolvePointer(ends.baseline, at, "xml") ?? null,
|
||||
);
|
||||
const counted = { list, tag: member.name, ...(kind && { kind }) };
|
||||
if (
|
||||
@@ -181,7 +185,7 @@ export const draftCounts = (
|
||||
const changed = new Map<string, Counted>();
|
||||
const note = (pointer: string, view: JsonValue | null) => {
|
||||
const list = listOf(pointer);
|
||||
const value = resolvePointer(view, pointer);
|
||||
const value = resolvePointer(view, pointer, "xml");
|
||||
// A member of an element a text check reads is a run of that text.
|
||||
if (
|
||||
list === undefined ||
|
||||
@@ -191,7 +195,7 @@ export const draftCounts = (
|
||||
context.covered(list.slice(0, -"/children".length))
|
||||
)
|
||||
return;
|
||||
const kind = kindOf(value, resolvePointer(view, list) ?? null);
|
||||
const kind = kindOf(value, resolvePointer(view, list, "xml") ?? null);
|
||||
const counted = { list, tag: value.name, ...(kind && { kind }) };
|
||||
changed.set(keyOf(counted), counted);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ export const FILE_NOTE_CODES = [
|
||||
"cut_view",
|
||||
"unreadable_view",
|
||||
"volatile_lines",
|
||||
"generated_reference",
|
||||
"read_as_text",
|
||||
] as const;
|
||||
|
||||
@@ -26,6 +27,23 @@ export interface FileNote {
|
||||
|
||||
const MAX_LISTED = 5;
|
||||
|
||||
/** Explain a relationship whose generated ID cannot be compared across runs. */
|
||||
export const generatedReferenceNotes = (
|
||||
file: string,
|
||||
pointers: readonly string[],
|
||||
): FileNote[] =>
|
||||
pointers.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
code: "generated_reference",
|
||||
file,
|
||||
message:
|
||||
"Some array values refer to newly generated record IDs and cannot be checked exactly across runs; review the relationship manually.",
|
||||
pointers: pointers.slice(0, MAX_LISTED),
|
||||
},
|
||||
];
|
||||
|
||||
/** What a file's change says about whether its views can show it. */
|
||||
export interface ChangeShape {
|
||||
readonly kind: string;
|
||||
|
||||
@@ -410,22 +410,27 @@ describe("draftTaskFiles for a dialog's buttons, as Glade saves them", () => {
|
||||
/** A button in a GtkBuilder child: its label and flags, then where it is packed. */
|
||||
const button = (id: string, label: string, position: string) =>
|
||||
`<child><object class="GtkButton" id="${id}"><property name="label">${label}</property><property name="visible">True</property><property name="can-focus">True</property></object><packing><property name="expand">True</property><property name="position">${position}</property></packing></child>`;
|
||||
const dialog = (submit: string) =>
|
||||
`<interface><object class="GtkDialog" id="feedback_dialog"><child internal-child="vbox"><object class="GtkBox"><child internal-child="action_area"><object class="GtkButtonBox">${button("cancel_button", "Cancel", "0")}${button("submit_button", submit, "1")}</object><packing><property name="position">0</property></packing></child><child><object class="GtkLabel" id="intro_label"><property name="label">Tell us what you think.</property></object><packing><property name="position">1</property></packing></child></object></child></object></interface>`;
|
||||
const dialog = (submit: string, moved = false) => {
|
||||
const buttons = [
|
||||
button("cancel_button", "Cancel", moved ? "1" : "0"),
|
||||
button("submit_button", submit, moved ? "0" : "1"),
|
||||
];
|
||||
return `<interface><object class="GtkDialog" id="feedback_dialog"><child internal-child="vbox"><object class="GtkBox"><child internal-child="action_area"><object class="GtkButtonBox">${(moved ? buttons.toReversed() : buttons).join("")}</object><packing><property name="position">0</property></packing></child><child><object class="GtkLabel" id="intro_label"><property name="label">Tell us what you think.</property></object><packing><property name="position">1</property></packing></child></object></child></object></interface>`;
|
||||
};
|
||||
const BUTTONS =
|
||||
"/root/children/[name=object]/children/[name=child]/children/[name=object]/children/[name=child][@internal-child=action_area]/children/[name=object]/children";
|
||||
|
||||
it("selects the other button by where it is packed, not by the child text holding the label the guard reads, and counts the buttons, not the dialog's other children", () => {
|
||||
it("selects each button by the id its object carries, which moving the button keeps, not where it is packed or the child text holding the label the guard reads, and counts the buttons, not the dialog's other children", () => {
|
||||
const file = xmlDraft(dialog("Submit"), dialog("Send Feedback"));
|
||||
|
||||
expect(file.suggested.map(pointerOf)).toEqual([
|
||||
`${BUTTONS}/[name=child][>packing=True1]/children/[name=object]/children/[name=property][@name=label]`,
|
||||
`${BUTTONS}/[name=child][>object@id=submit_button]/children/[name=object]/children/[name=property][@name=label]`,
|
||||
]);
|
||||
expect(file.guards).toEqual([
|
||||
{
|
||||
kind: "text_equals",
|
||||
file: FILE,
|
||||
pointer: `${BUTTONS}/[name=child][>packing=True0]/children/[name=object]/children/[name=property][@name=label]`,
|
||||
pointer: `${BUTTONS}/[name=child][>object@id=cancel_button]/children/[name=object]/children/[name=property][@name=label]`,
|
||||
value: "Cancel",
|
||||
},
|
||||
{
|
||||
@@ -436,6 +441,9 @@ describe("draftTaskFiles for a dialog's buttons, as Glade saves them", () => {
|
||||
equals: 2,
|
||||
},
|
||||
]);
|
||||
const moved = xml(dialog("Send Feedback", true));
|
||||
expect(passing(FILE, moved, file.suggested)).toEqual([true]);
|
||||
expect(passing(FILE, moved, file.guards)).toEqual([true, true]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -30,10 +30,10 @@ import { childConditions } from "./selectorCandidates.js";
|
||||
type Suggestion = DesktopTaskCheckInput;
|
||||
|
||||
/** Guards a file's draft offers, and how many one suggested check takes. */
|
||||
const MAX_GUARDS = 4;
|
||||
const MAX_GUARDS_PER_CHECK = 2;
|
||||
export const MAX_GUARDS = 4;
|
||||
export const MAX_GUARDS_PER_CHECK = 2;
|
||||
/** Like siblings a guard is sought on per list, nearest first, so a long list costs no more than a short one. */
|
||||
const MAX_SIBLINGS_READ = 16;
|
||||
export const MAX_SIBLINGS_READ = 16;
|
||||
|
||||
/** What guards read: a file's two ends, whether a check there is decided by name, and noise. */
|
||||
export interface GuardContext {
|
||||
@@ -60,8 +60,8 @@ const guardAt = (
|
||||
!context.named(ends.final, pointer)
|
||||
)
|
||||
return undefined;
|
||||
const before = resolvePointer(ends.baseline, pointer);
|
||||
const after = resolvePointer(ends.final, pointer);
|
||||
const before = resolvePointer(ends.baseline, pointer, "xml");
|
||||
const after = resolvePointer(ends.final, pointer, "xml");
|
||||
if (before === undefined || after === undefined) return undefined;
|
||||
if (
|
||||
check.kind === "value_equals" &&
|
||||
@@ -166,11 +166,12 @@ const siblingsOf = (
|
||||
context: GuardContext,
|
||||
step: ReturnType<typeof listSteps>[number],
|
||||
): { readonly siblings: Sibling[]; readonly all: JsonValue[] } => {
|
||||
const newer = resolvePointer(context.ends.final, step.list);
|
||||
const older = resolvePointer(context.ends.baseline, step.list);
|
||||
const newer = resolvePointer(context.ends.final, step.list, "xml");
|
||||
const older = resolvePointer(context.ends.baseline, step.list, "xml");
|
||||
const member = resolvePointer(
|
||||
context.ends.final,
|
||||
`${step.list}/${step.member}`,
|
||||
"xml",
|
||||
);
|
||||
if (!Array.isArray(newer) || !Array.isArray(older) || !isXmlElement(member))
|
||||
return { siblings: [], all: [] };
|
||||
@@ -196,7 +197,7 @@ const siblingsOf = (
|
||||
(item) => isXmlElement(item) && item.name === member.name,
|
||||
);
|
||||
if (likes.length === 0) return { siblings: [], all };
|
||||
const pairing = pairMembers(newer, older, holdsTagOnly(step.list));
|
||||
const pairing = pairMembers(newer, older, holdsTagOnly(step.list), "xml");
|
||||
return {
|
||||
siblings: likes.flatMap(({ index }) => {
|
||||
const key = pairing.newer.keys[index];
|
||||
@@ -236,7 +237,7 @@ const readAt = (sibling: Sibling, rest: readonly string[]) => {
|
||||
const value =
|
||||
rest.length === 0
|
||||
? sibling.element
|
||||
: resolvePointer(sibling.element, `/${rest.join("/")}`);
|
||||
: resolvePointer(sibling.element, `/${rest.join("/")}`, "xml");
|
||||
return (
|
||||
isXmlElement(value)
|
||||
? elementText(value)
|
||||
@@ -307,7 +308,7 @@ const rekeyed = (
|
||||
...childConditions(element).flatMap((spelled) => selectorOf(spelled) ?? []),
|
||||
].filter((condition) => !selectsByRead(condition, read, text));
|
||||
const at = (member: string, view: JsonValue | null) =>
|
||||
resolvePointer(view, `${step.list}/${member}`);
|
||||
resolvePointer(view, `${step.list}/${member}`, "xml");
|
||||
const end = at(sibling.member, context.ends.final);
|
||||
for (const condition of candidates) {
|
||||
const member = escapeToken(`[name=${tag}]${spellCondition(condition)}`);
|
||||
@@ -329,7 +330,7 @@ const variesAmong = (all: readonly JsonValue[]) => {
|
||||
known.get(pointer) ??
|
||||
new Set(
|
||||
all.map((item) =>
|
||||
JSON.stringify(resolvePointer(item, pointer) ?? null),
|
||||
JSON.stringify(resolvePointer(item, pointer, "xml") ?? null),
|
||||
),
|
||||
).size > 1;
|
||||
known.set(pointer, varied);
|
||||
|
||||
@@ -31,14 +31,16 @@ export const isPlacedItem = (
|
||||
view: JsonValue | null,
|
||||
pointer: string,
|
||||
): boolean => {
|
||||
const element = resolvePointer(view, pointer);
|
||||
const element = resolvePointer(view, pointer, "xml");
|
||||
const local = isXmlElement(element)
|
||||
? (element.name.split(":").at(-1) ?? element.name)
|
||||
: "";
|
||||
if (TEXT_RUN.test(local)) return false;
|
||||
const owner = placeOf(pointer);
|
||||
const list =
|
||||
owner === undefined ? undefined : resolvePointer(view, `${owner}/children`);
|
||||
owner === undefined
|
||||
? undefined
|
||||
: resolvePointer(view, `${owner}/children`, "xml");
|
||||
return (
|
||||
isXmlElement(element) &&
|
||||
placed(element) &&
|
||||
@@ -70,7 +72,7 @@ const siblingTexts = (
|
||||
owner: string,
|
||||
item: JsonValue & { readonly name: string },
|
||||
) => {
|
||||
const list = resolvePointer(view, `${owner}/children`);
|
||||
const list = resolvePointer(view, `${owner}/children`, "xml");
|
||||
return (Array.isArray(list) ? list : []).flatMap((sibling, index) =>
|
||||
sibling !== item && isXmlElement(sibling) && sibling.name === item.name
|
||||
? [{ text: elementText(sibling).trim(), index }]
|
||||
@@ -98,9 +100,10 @@ export const placedItemCheck = (
|
||||
const { ends, file } = context;
|
||||
const owner = placeOf(pointer);
|
||||
const at = owner === undefined ? undefined : context.name(owner);
|
||||
const item = resolvePointer(ends.final, pointer);
|
||||
const item = resolvePointer(ends.final, pointer, "xml");
|
||||
const value = isXmlElement(item) ? elementText(item).trim() : "";
|
||||
const seed = at === undefined ? undefined : resolvePointer(ends.baseline, at);
|
||||
const seed =
|
||||
at === undefined ? undefined : resolvePointer(ends.baseline, at, "xml");
|
||||
if (
|
||||
owner === undefined ||
|
||||
at === undefined ||
|
||||
@@ -115,7 +118,7 @@ export const placedItemCheck = (
|
||||
const kept = new Set(
|
||||
siblingTexts(ends.baseline, owner, item).map(({ text }) => text),
|
||||
);
|
||||
const list = resolvePointer(ends.final, `${owner}/children`);
|
||||
const list = resolvePointer(ends.final, `${owner}/children`, "xml");
|
||||
const here = Array.isArray(list) ? list.indexOf(item) : 0;
|
||||
const guards = siblingTexts(ends.final, owner, item)
|
||||
.filter(({ text }) => text.length >= MIN_TEXT && kept.has(text))
|
||||
|
||||
@@ -5,6 +5,7 @@ import { draftOf, pointerOf, statePath } from "./appTaskDraft.fixture.js";
|
||||
import { evaluateTaskChecks, type TaskFileView } from "./appTaskChecks.js";
|
||||
import { jsonCounts } from "./appTaskDraftJson.js";
|
||||
import { pairRecordsById } from "./jsonRecordPairing.js";
|
||||
import { zipEntry, zipView } from "./zipView.js";
|
||||
import type { DesktopTaskCheckInput } from "./appTask.js";
|
||||
|
||||
const FILE = "Documents/reading-list.trln";
|
||||
@@ -24,7 +25,10 @@ const outline = (
|
||||
history,
|
||||
...books.map(([uid, name]) => ({
|
||||
children: [],
|
||||
data: { Title: name === "Guns" ? title : name, Year: "2015" },
|
||||
data: {
|
||||
Title: name === "Guns" ? title : name,
|
||||
Year: name === "Guns" ? "1962" : "2015",
|
||||
},
|
||||
format: "BOOK",
|
||||
uid,
|
||||
})),
|
||||
@@ -46,7 +50,75 @@ const passing = (view: JsonValue, checks: readonly DesktopTaskCheckInput[]) =>
|
||||
new Map([[FILE, { format: "json" as const, view }]]),
|
||||
).map(({ passed }) => passed);
|
||||
|
||||
describe("draftTaskFiles for a root JSON array of records", () => {
|
||||
it("counts additions to a root array and guards edits to its named members", () => {
|
||||
const before = [
|
||||
{ name: "one", done: false },
|
||||
{ name: "two", done: false },
|
||||
];
|
||||
const after = [...before, { name: "three", done: false }];
|
||||
const count = jsonCounts(
|
||||
FILE,
|
||||
{
|
||||
added: ["/[name=three]"],
|
||||
changed: [],
|
||||
removed: [],
|
||||
},
|
||||
{ baseline: before, final: after },
|
||||
);
|
||||
expect(count.checks).toEqual([
|
||||
{ kind: "member_count", file: FILE, pointer: "", equals: 3 },
|
||||
]);
|
||||
expect(passing(before, count.checks)).toEqual([false]);
|
||||
expect(passing(after, count.checks)).toEqual([true]);
|
||||
expect(drafted(before, after).suggested).toContainEqual(count.checks[0]);
|
||||
expect(
|
||||
jsonCounts(
|
||||
FILE,
|
||||
{ added: [], changed: [], removed: ["/[name=three]"] },
|
||||
{ baseline: after, final: before },
|
||||
).checks,
|
||||
).toEqual([{ kind: "member_count", file: FILE, pointer: "", equals: 2 }]);
|
||||
|
||||
const edited = [{ name: "one", done: true }, before[1]!];
|
||||
const guard = jsonCounts(
|
||||
FILE,
|
||||
{
|
||||
added: [],
|
||||
changed: ["/[name=one]/done"],
|
||||
removed: [],
|
||||
},
|
||||
{ baseline: before, final: edited },
|
||||
);
|
||||
expect(guard.guards).toEqual([
|
||||
{ kind: "member_count", file: FILE, pointer: "", equals: 2 },
|
||||
]);
|
||||
expect(passing(edited, guard.guards)).toEqual([true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("draftTaskFiles for records in a JSON array, as TreeLine saves its nodes", () => {
|
||||
it("names kept records by name fields of at most 64 kinds, each a pass over the records, and those past them by their uid", () => {
|
||||
/** Seventy records with a generated uid, each with a name field of its own key and `done` as `edited` says. */
|
||||
const items = (edited: (index: number) => boolean) =>
|
||||
({
|
||||
items: Array.from({ length: 70 }, (_, index) => ({
|
||||
[`n${String(index)}name`]: `Item ${String(index)}`,
|
||||
done: edited(index),
|
||||
uid: `${String(index).padStart(8, "0")}da2511f1a0ba3971baf6a808`,
|
||||
})),
|
||||
}) as unknown as JsonValue;
|
||||
const file = drafted(
|
||||
items(() => false),
|
||||
items((index) => index === 0 || index === 69),
|
||||
);
|
||||
|
||||
expect(file.suggested.map(pointerOf)).toEqual([
|
||||
"/items/[n0name=Item 0]/done",
|
||||
"/items/[uid=00000069da2511f1a0ba3971baf6a808]/done",
|
||||
]);
|
||||
});
|
||||
|
||||
it("names a kept record by its uid, so the edit's check holds in whatever order the app saves the records, and guards their count", () => {
|
||||
const file = drafted(
|
||||
outline([GUNS, SPQR]),
|
||||
@@ -70,16 +142,22 @@ describe("draftTaskFiles for records in a JSON array, as TreeLine saves its node
|
||||
expect(passing(reordered, file.suggested)).toEqual([true]);
|
||||
});
|
||||
|
||||
it("counts the records a new one joins, of its kind, never suggests the uid the app generated, and notes the checks only position reaches", () => {
|
||||
it("counts the records a new one joins, of its kind, never suggests the uid the app generated, and names the new record by its kind and title, checking its other fields through that", () => {
|
||||
const book = [
|
||||
"fc11808dd9f511f181b7edd4f1920549",
|
||||
"The Silk Roads",
|
||||
] as const;
|
||||
const file = drafted(outline([GUNS, SPQR]), outline([GUNS, SPQR, book]));
|
||||
const seed = outline([GUNS, SPQR]);
|
||||
const file = drafted(seed, outline([GUNS, SPQR, book]));
|
||||
|
||||
expect(
|
||||
file.suggested.filter(({ kind }) => kind === "member_count"),
|
||||
).toEqual([
|
||||
const silk = "/nodes/[format=BOOK][>data~1Title=The Silk Roads]";
|
||||
expect(file.suggested).toEqual([
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: FILE,
|
||||
pointer: `${silk}/data/Year`,
|
||||
value: "2015",
|
||||
},
|
||||
{
|
||||
kind: "member_count",
|
||||
file: FILE,
|
||||
@@ -90,22 +168,51 @@ describe("draftTaskFiles for records in a JSON array, as TreeLine saves its node
|
||||
{
|
||||
kind: "member_count",
|
||||
file: FILE,
|
||||
pointer: "/nodes/[uid=7584d494d9f511f1a873edd4f1920549]/children",
|
||||
pointer: "/nodes/[format=CATEGORY][>data~1NAME=History]/children",
|
||||
equals: 3,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
file.suggested.some(
|
||||
(check) => "value" in check && check.value === book[0],
|
||||
),
|
||||
).toBe(false);
|
||||
const positional = file.suggested
|
||||
expect(file.notes).toEqual([
|
||||
expect.objectContaining({ code: "generated_reference" }),
|
||||
]);
|
||||
// Another run of the edit generates another uid, which sorts the new record elsewhere.
|
||||
const rerun = outline([
|
||||
GUNS,
|
||||
SPQR,
|
||||
["0a11808dd9f511f181b7edd4f1920549", "The Silk Roads"],
|
||||
]);
|
||||
expect(passing(seed, file.suggested)).toEqual([false, false, false]);
|
||||
expect(passing(rerun, file.suggested)).toEqual([true, true, true]);
|
||||
});
|
||||
|
||||
it("checks a removed record with a generated uid is gone by its kind and title, and keeps position and its note where a title another record shares names nothing", () => {
|
||||
const book = [
|
||||
"fc11808dd9f511f181b7edd4f1920549",
|
||||
"The Silk Roads",
|
||||
] as const;
|
||||
const removed = drafted(outline([GUNS, SPQR, book]), outline([GUNS, SPQR]));
|
||||
const twin = drafted(
|
||||
outline([GUNS, SPQR]),
|
||||
outline([GUNS, SPQR, ["fc11808dd9f511f181b7edd4f1920549", "SPQR"]]),
|
||||
);
|
||||
|
||||
expect(removed.suggested).toContainEqual({
|
||||
kind: "value_absent",
|
||||
file: FILE,
|
||||
pointer: "/nodes/[format=BOOK][>data~1Title=The Silk Roads]",
|
||||
});
|
||||
const positional = twin.suggested
|
||||
.map(pointerOf)
|
||||
.filter((pointer) => pointer?.startsWith("/nodes/3"));
|
||||
expect(positional).toContain("/nodes/3/data/Title");
|
||||
expect(file.notes).toMatchObject([
|
||||
{ code: "by_position", pointers: expect.arrayContaining(positional) },
|
||||
]);
|
||||
expect(twin.notes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: "by_position",
|
||||
pointers: expect.arrayContaining(positional),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("checks nothing the seed already holds, nor a removed record's place another record takes, when position alone reaches the records", () => {
|
||||
@@ -132,6 +239,489 @@ describe("draftTaskFiles for records in a JSON array, as TreeLine saves its node
|
||||
});
|
||||
});
|
||||
|
||||
describe("draftTaskFiles generated record ids", () => {
|
||||
it("checks an account reference while omitting only the chosen generated record id", () => {
|
||||
const oldId = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const selected = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const before = { selectedAccount: oldId, nodes: [] };
|
||||
const after = {
|
||||
selectedAccount: selected,
|
||||
nodes: [{ uid: "ID_329023692", name: "New", selectedAccount: selected }],
|
||||
};
|
||||
const file = drafted(before, after);
|
||||
expect(file.suggested).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pointer: "/selectedAccount",
|
||||
value: selected,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
pointer: "/nodes/[name=New]/selectedAccount",
|
||||
value: selected,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(file.suggested.map(pointerOf)).not.toContain(
|
||||
"/nodes/[name=New]/uid",
|
||||
);
|
||||
expect(
|
||||
passing(
|
||||
{ ...after, nodes: [{ ...after.nodes[0]!, uid: "ID_999999999" }] },
|
||||
file.suggested,
|
||||
).every(Boolean),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a keyfile setting containing a selected profile id", () => {
|
||||
const selected = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const [file] = draftOf({
|
||||
"Documents/profile.ini": statePath(
|
||||
"document",
|
||||
"keyfile",
|
||||
{
|
||||
groups: {
|
||||
profile: { selected: "550e8400-e29b-41d4-a716-446655440000" },
|
||||
},
|
||||
},
|
||||
{ groups: { profile: { selected } } },
|
||||
),
|
||||
});
|
||||
expect(file?.suggested).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "value_equals", value: selected }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a scalar reference even when it equals a newly added generated id", () => {
|
||||
const file = drafted(
|
||||
{ selectedAccount: "old", nodes: [] },
|
||||
{
|
||||
selectedAccount: "ID_329023692",
|
||||
nodes: [{ uid: "ID_329023692", name: "New" }],
|
||||
},
|
||||
);
|
||||
expect(file.suggested).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pointer: "/selectedAccount",
|
||||
value: "ID_329023692",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("draftTaskFiles generated record ids in archives", () => {
|
||||
it("omits a generated record id in JSON inside an archive while keeping its account reference", () => {
|
||||
const selected = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const archive = (value: unknown) => {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(value));
|
||||
return zipView(
|
||||
[
|
||||
zipEntry(
|
||||
{
|
||||
name: "state.json",
|
||||
size: bytes.length,
|
||||
compressedSize: bytes.length,
|
||||
method: 8,
|
||||
encrypted: false,
|
||||
},
|
||||
bytes,
|
||||
),
|
||||
],
|
||||
1,
|
||||
);
|
||||
};
|
||||
const [file] = draftOf({
|
||||
"Documents/state.zip": statePath(
|
||||
"document",
|
||||
"zip",
|
||||
archive({ nodes: [] }),
|
||||
archive({
|
||||
nodes: [
|
||||
{ uid: "ID_329023692", name: "New", selectedAccount: selected },
|
||||
],
|
||||
}),
|
||||
),
|
||||
});
|
||||
expect(file?.suggested).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ value: selected })]),
|
||||
);
|
||||
expect(
|
||||
file?.suggested
|
||||
.map(pointerOf)
|
||||
.some((pointer) => pointer?.endsWith("/uid")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not require a newly generated record id in an archive's reference array", () => {
|
||||
const archive = (value: unknown) => {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(value));
|
||||
return zipView(
|
||||
[
|
||||
zipEntry(
|
||||
{
|
||||
name: "state.json",
|
||||
size: bytes.length,
|
||||
compressedSize: bytes.length,
|
||||
method: 8,
|
||||
encrypted: false,
|
||||
},
|
||||
bytes,
|
||||
),
|
||||
],
|
||||
1,
|
||||
);
|
||||
};
|
||||
const before = { nodes: [{ uid: "old", name: "Parent", children: [] }] };
|
||||
const after = {
|
||||
nodes: [
|
||||
{ uid: "old", name: "Parent", children: ["ID_329023692"] },
|
||||
{ uid: "ID_329023692", name: "New", children: [] },
|
||||
],
|
||||
};
|
||||
const [file] = draftOf({
|
||||
"Documents/state.zip": statePath(
|
||||
"document",
|
||||
"zip",
|
||||
archive(before),
|
||||
archive(after),
|
||||
),
|
||||
});
|
||||
expect(
|
||||
file?.suggested.some(
|
||||
(check) => "value" in check && check.value === "ID_329023692",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(file?.notes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: "generated_reference" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("draftTaskFiles for a new JSON record named by a field inside it", () => {
|
||||
it("checks a new record by the field that names it when nothing else reads it, so a wrong title fails however many records the list holds", () => {
|
||||
const note = (uid: string, title: string) => ({
|
||||
data: { Title: title },
|
||||
uid,
|
||||
});
|
||||
const NEW = note("fc11808dd9f511f181b7edd4f1920549", "New");
|
||||
const OLD = note("812403bed9f511f19fc4edd4f1920549", "Old");
|
||||
const nodes = (...members: unknown[]) =>
|
||||
({ nodes: members }) as unknown as JsonValue;
|
||||
const titled = {
|
||||
kind: "value_equals",
|
||||
file: FILE,
|
||||
pointer: "/nodes/[>data~1Title=New]/data/Title",
|
||||
value: "New",
|
||||
};
|
||||
const first = drafted(nodes(), nodes(NEW));
|
||||
const joined = drafted(nodes(OLD), nodes(OLD, NEW));
|
||||
|
||||
expect(first.suggested).toEqual([titled]);
|
||||
expect(joined.suggested).toEqual([
|
||||
titled,
|
||||
{ kind: "member_count", file: FILE, pointer: "/nodes", equals: 2 },
|
||||
]);
|
||||
// Another run gives the record another uid; a wrong title fails.
|
||||
const rerun = note("0a11808dd9f511f181b7edd4f1920549", "New");
|
||||
expect(passing(nodes(rerun), first.suggested)).toEqual([true]);
|
||||
expect(passing(nodes(OLD, note(NEW.uid, "Nwe")), joined.suggested)).toEqual(
|
||||
[false, true],
|
||||
);
|
||||
});
|
||||
|
||||
it("names a new record by a later name-like field when the first three name another record too", () => {
|
||||
const item = (uid: string, last: string, done: boolean) => ({
|
||||
aname: "Shared",
|
||||
bname: "Shared",
|
||||
cname: "Shared",
|
||||
dname: last,
|
||||
done,
|
||||
uid,
|
||||
});
|
||||
const FIRST = "00000001da2511f1a0ba3971baf6a808";
|
||||
const items = (...members: unknown[]) =>
|
||||
({ items: members }) as unknown as JsonValue;
|
||||
const file = drafted(
|
||||
items(item(FIRST, "First", false)),
|
||||
items(
|
||||
item(FIRST, "First", false),
|
||||
item("00000002da2511f1a0ba3971baf6a808", "Second", true),
|
||||
),
|
||||
);
|
||||
|
||||
expect(file.suggested.map(pointerOf)).toEqual([
|
||||
"/items/[dname=Second]/dname",
|
||||
"/items/[dname=Second]/done",
|
||||
"/items",
|
||||
]);
|
||||
expect(file.notes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
type Book = readonly [
|
||||
uid: string,
|
||||
title: string,
|
||||
author: string,
|
||||
copyright: string,
|
||||
plot?: string,
|
||||
];
|
||||
/** The seed's categories and books, as the run's `reading-list.trln` held them. */
|
||||
const SEED: readonly (readonly [uid: string, name: string, books: Book[]])[] = [
|
||||
[
|
||||
"0b7bfb76959211e7a8357054d2175f18",
|
||||
"Fiction",
|
||||
[
|
||||
[
|
||||
"2d4390ceda2511f195a23971baf6a808",
|
||||
"The Cartographer's Daughter",
|
||||
"Lena Whitcombe",
|
||||
"2016",
|
||||
"A mapmaker's heir follows her mother's unfinished charts north.",
|
||||
],
|
||||
[
|
||||
"4eb3fbd2da2511f188893971baf6a808",
|
||||
"Winter at Marrow Bay",
|
||||
"Joel Ashby",
|
||||
"2012",
|
||||
"A lighthouse keeper spends a last season on a fading island.",
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
"53f0d9c3da2511f1a8913971baf6a808",
|
||||
"History",
|
||||
[
|
||||
[
|
||||
"60e26394da2511f198393971baf6a808",
|
||||
"The Salt Caravans",
|
||||
"Mira Okonkwo",
|
||||
"2014",
|
||||
"How desert trade routes carried salt, gold and news across centuries.",
|
||||
],
|
||||
[
|
||||
"668660d1da2511f1b0ee3971baf6a808",
|
||||
"Lanterns of the Old Port",
|
||||
"Tomas Reyes",
|
||||
"2009",
|
||||
"A harbor town told through its lamplighters and their ledgers.",
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
"75201b5ada2511f1a0ba3971baf6a808",
|
||||
"Science",
|
||||
[
|
||||
[
|
||||
"76cbaf8fda2511f1b9343971baf6a808",
|
||||
"The Quiet Glacier",
|
||||
"Ines Halvorsen",
|
||||
"2018",
|
||||
"A field season spent measuring a retreating ice sheet.",
|
||||
],
|
||||
[
|
||||
"7be27d14da2511f1afb93971baf6a808",
|
||||
"Small Engines of Life",
|
||||
"Dev Ramanathan",
|
||||
"2021",
|
||||
"A tour of the molecular machines inside every cell.",
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
/** The book the agent added under Science, with no plot. */
|
||||
const CLOCKWORK: Book = [
|
||||
"ae23424ada2511f1bbd43971baf6a808",
|
||||
"The Clockwork Ocean",
|
||||
"Priya Lindqvist",
|
||||
"2020",
|
||||
];
|
||||
/** The outline's nodes sorted by uid, as TreeLine saves them, with `added` under Science and `rename` naming categories anew. */
|
||||
const outlineOf = (
|
||||
added: readonly Book[] = [],
|
||||
rename: Readonly<Record<string, string>> = {},
|
||||
) => {
|
||||
const categories = SEED.map(([uid, name, books]) => ({
|
||||
uid,
|
||||
name: rename[name] ?? name,
|
||||
books: name === "Science" ? [...books, ...added] : books,
|
||||
}));
|
||||
const nodes = [
|
||||
...categories.map(({ uid, name, books }) => ({
|
||||
children: books.map(([id]) => id),
|
||||
data: { NAME: name },
|
||||
format: "CATEGORY",
|
||||
uid,
|
||||
})),
|
||||
...categories.flatMap(({ books }) =>
|
||||
books.map(([uid, title, author, copyright, plot]) => ({
|
||||
children: [],
|
||||
data: {
|
||||
Author: author,
|
||||
Copyright: copyright,
|
||||
...(plot === undefined ? {} : { Plot: plot }),
|
||||
Title: title,
|
||||
},
|
||||
format: "BOOK",
|
||||
uid,
|
||||
})),
|
||||
),
|
||||
].toSorted((left, right) => (left.uid < right.uid ? -1 : 1));
|
||||
return { nodes } as unknown as JsonValue;
|
||||
};
|
||||
const book = (title: string) => `/nodes/[format=BOOK][>data~1Title=${title}]`;
|
||||
|
||||
describe("draftTaskFiles for a book added under a category, as TreeLine saved one on a real run", () => {
|
||||
it("checks the new book's author and copyright, not the empty children every book holds, guards the nearest books' same fields named by their titles, and names Science by its name", () => {
|
||||
const seed = outlineOf();
|
||||
const end = outlineOf([CLOCKWORK]);
|
||||
const file = drafted(seed, end);
|
||||
|
||||
expect(file.suggested).toEqual([
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: FILE,
|
||||
pointer: `${book("The Clockwork Ocean")}/data/Author`,
|
||||
value: "Priya Lindqvist",
|
||||
},
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: FILE,
|
||||
pointer: `${book("The Clockwork Ocean")}/data/Copyright`,
|
||||
value: "2020",
|
||||
},
|
||||
{
|
||||
kind: "member_count",
|
||||
file: FILE,
|
||||
pointer: "/nodes",
|
||||
matching: "[format=BOOK]",
|
||||
equals: 7,
|
||||
},
|
||||
{
|
||||
kind: "member_count",
|
||||
file: FILE,
|
||||
pointer: "/nodes/[format=CATEGORY][>data~1NAME=Science]/children",
|
||||
equals: 3,
|
||||
},
|
||||
]);
|
||||
expect(file.guards).toEqual([
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: FILE,
|
||||
pointer: `${book("Small Engines of Life")}/data/Author`,
|
||||
value: "Dev Ramanathan",
|
||||
},
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: FILE,
|
||||
pointer: `${book("The Quiet Glacier")}/data/Author`,
|
||||
value: "Ines Halvorsen",
|
||||
},
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: FILE,
|
||||
pointer: `${book("Small Engines of Life")}/data/Copyright`,
|
||||
value: "2021",
|
||||
},
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: FILE,
|
||||
pointer: `${book("The Quiet Glacier")}/data/Copyright`,
|
||||
value: "2018",
|
||||
},
|
||||
]);
|
||||
expect(passing(seed, file.suggested)).toEqual([false, false, false, false]);
|
||||
expect(passing(end, file.suggested)).toEqual([true, true, true, true]);
|
||||
expect(passing(seed, file.guards)).toEqual([true, true, true, true]);
|
||||
expect(passing(end, file.guards)).toEqual([true, true, true, true]);
|
||||
});
|
||||
|
||||
it("guards the nearest books and skips the children every book holds where the nodes are the file's top level", () => {
|
||||
const top = (view: JsonValue) => (view as { nodes: JsonValue }).nodes;
|
||||
const at = (title: string) => book(title).replace(/^\/nodes/u, "");
|
||||
const seed = top(outlineOf());
|
||||
const end = top(outlineOf([CLOCKWORK]));
|
||||
const file = drafted(seed, end);
|
||||
|
||||
expect(file.suggested.map(pointerOf)).not.toContain(
|
||||
`${at("The Clockwork Ocean")}/children`,
|
||||
);
|
||||
expect(file.guards.map(pointerOf)).toEqual([
|
||||
`${at("Small Engines of Life")}/data/Author`,
|
||||
`${at("The Quiet Glacier")}/data/Author`,
|
||||
`${at("Small Engines of Life")}/data/Copyright`,
|
||||
`${at("The Quiet Glacier")}/data/Copyright`,
|
||||
]);
|
||||
expect(passing(seed, file.guards)).toEqual([true, true, true, true]);
|
||||
expect(passing(end, file.guards)).toEqual([true, true, true, true]);
|
||||
});
|
||||
|
||||
it("names a kept category by its uid where its name changed or another category holds it", () => {
|
||||
const children = (file: ReturnType<typeof drafted>) =>
|
||||
file.suggested
|
||||
.map(pointerOf)
|
||||
.filter((pointer) => pointer?.endsWith("/children"));
|
||||
const renamed = drafted(
|
||||
outlineOf(),
|
||||
outlineOf([CLOCKWORK], { Science: "Natural Science" }),
|
||||
);
|
||||
const shared = drafted(
|
||||
outlineOf([], { Science: "History" }),
|
||||
outlineOf([CLOCKWORK], { Science: "History" }),
|
||||
);
|
||||
|
||||
for (const file of [renamed, shared])
|
||||
expect(children(file)).toEqual([
|
||||
"/nodes/[uid=75201b5ada2511f1a0ba3971baf6a808]/children",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads at most the 16 nearest like books for a guard, so none further out guards the new one", () => {
|
||||
/** Forty books in uid order, each authored as `author` says, and the new one after them. */
|
||||
const shelf = (author: (index: number) => string, added: boolean) =>
|
||||
({
|
||||
nodes: [
|
||||
...Array.from({ length: 40 }, (_, index) => ({
|
||||
data: { Author: author(index), Title: `Volume ${String(index)}` },
|
||||
format: "BOOK",
|
||||
uid: `${String(index).padStart(8, "0")}da2511f1a0ba3971baf6a808`,
|
||||
})),
|
||||
...(added
|
||||
? [
|
||||
{
|
||||
data: { Author: CLOCKWORK[2], Title: CLOCKWORK[1] },
|
||||
format: "BOOK",
|
||||
uid: CLOCKWORK[0],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}) as unknown as JsonValue;
|
||||
const author = (index: number) => `Author ${String(index)}`;
|
||||
// The 15 nearest books were re-authored: the 16th nearest, Volume 24, guards, and Volume 23, past the 16, does not.
|
||||
const file = drafted(
|
||||
shelf(author, false),
|
||||
shelf(
|
||||
(index) => `${author(index)}${index > 24 ? ", revised" : ""}`,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(file.guards).toEqual([
|
||||
{
|
||||
kind: "value_equals",
|
||||
file: FILE,
|
||||
pointer: `${book("Volume 24")}/data/Author`,
|
||||
value: "Author 24",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("draftTaskFiles for records a JSON array names by a field", () => {
|
||||
const board = (lists: readonly string[]) =>
|
||||
({
|
||||
@@ -253,6 +843,28 @@ describe("jsonCounts over many edited records", () => {
|
||||
expect(paired?.newer.keys).toEqual(["[uid=a]", "[uid=b]"]);
|
||||
});
|
||||
|
||||
it("pairs XML-shaped JSON records by their JSON ids", () => {
|
||||
const records = (title: string) => [
|
||||
{
|
||||
uid: "one",
|
||||
name: "node",
|
||||
attributes: {},
|
||||
children: [],
|
||||
data: { Title: title },
|
||||
},
|
||||
{
|
||||
uid: "two",
|
||||
name: "node",
|
||||
attributes: {},
|
||||
children: [],
|
||||
data: { Title: "Other" },
|
||||
},
|
||||
];
|
||||
const paired = pairRecordsById(records("New"), records("Old"));
|
||||
expect(paired?.newer.keys).toEqual(["[uid=one]", "[uid=two]"]);
|
||||
expect(paired?.newer.partners).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("does not pair records through a field whose name is a selector mark", () => {
|
||||
const records = (name: string) => [
|
||||
{ "@record_id": "one", name },
|
||||
|
||||
@@ -2,20 +2,183 @@ import type { JsonValue } from "../jsonValue.js";
|
||||
import type { DesktopTaskCheckInput } from "./appTask.js";
|
||||
import type { FileNote } from "./appTaskDraftFileNotes.js";
|
||||
import { sameJson } from "./appTaskChecks.js";
|
||||
import {
|
||||
MAX_GUARDS,
|
||||
MAX_GUARDS_PER_CHECK,
|
||||
MAX_SIBLINGS_READ,
|
||||
} from "./appTaskDraftGuards.js";
|
||||
import { isStamp } from "./appTaskDraftNoise.js";
|
||||
import { valueEqualsCheck } from "./appTaskDraftScalars.js";
|
||||
import {
|
||||
isRecordIdField,
|
||||
KIND_FIELD,
|
||||
pairRecordsById,
|
||||
} from "./jsonRecordPairing.js";
|
||||
import { pairMembers } from "./memberPairing.js";
|
||||
import type { DesktopStateDifference } from "./stateChanges.js";
|
||||
import { locatePointer, resolvePointer } from "./viewPointer.js";
|
||||
import { spellCondition } from "./viewSelector.js";
|
||||
import {
|
||||
escapeToken,
|
||||
locatePointer as locateView,
|
||||
resolvePointer as resolveView,
|
||||
} from "./viewPointer.js";
|
||||
|
||||
const locatePointer = (view: JsonValue | null, pointer: string) =>
|
||||
locateView(view, pointer, "json");
|
||||
const resolvePointer = (view: JsonValue | null, pointer: string) =>
|
||||
resolveView(view, pointer, "json");
|
||||
import { selectorOf, spellCondition } from "./viewSelector.js";
|
||||
|
||||
type Suggestion = DesktopTaskCheckInput;
|
||||
|
||||
/** Identify only fields the record pairing actually selected as generated ids. */
|
||||
export const generatedRecordId = (
|
||||
ends: {
|
||||
readonly baseline: JsonValue | null;
|
||||
readonly final: JsonValue | null;
|
||||
},
|
||||
format: "json" | "zip",
|
||||
) => {
|
||||
const lists = new Map<
|
||||
string,
|
||||
| { readonly field: string; readonly generated: ReadonlySet<string> }
|
||||
| undefined
|
||||
>();
|
||||
return (pointer: string): boolean => {
|
||||
const segments = pointer.split("/");
|
||||
if (segments.length < 3) return false;
|
||||
const field = segments.at(-1)?.replaceAll("~1", "/").replaceAll("~0", "~");
|
||||
if (field === undefined || !isRecordIdField(field)) return false;
|
||||
const member = segments.at(-2)?.replaceAll("~1", "/").replaceAll("~0", "~");
|
||||
const list = segments.slice(0, -2).join("/");
|
||||
if (!lists.has(list)) {
|
||||
const after = resolveView(ends.final, list, format);
|
||||
const before = resolveView(ends.baseline, list, format);
|
||||
const paired = Array.isArray(after)
|
||||
? pairRecordsById(after, Array.isArray(before) ? before : [])
|
||||
: undefined;
|
||||
lists.set(
|
||||
list,
|
||||
paired === undefined
|
||||
? undefined
|
||||
: {
|
||||
field: paired.idField,
|
||||
generated: new Set(
|
||||
paired.newer.keys.filter(
|
||||
(_, index) => paired.generated[index] === true,
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
const paired = lists.get(list);
|
||||
if (paired === undefined || field !== paired.field || member === undefined)
|
||||
return false;
|
||||
return paired.generated.has(member);
|
||||
};
|
||||
};
|
||||
|
||||
/** Find the generated record IDs inside added JSON subtrees named by a diff. */
|
||||
export const generatedIdsInAdditions = (
|
||||
ends: {
|
||||
readonly baseline: JsonValue | null;
|
||||
readonly final: JsonValue | null;
|
||||
},
|
||||
format: "json" | "zip",
|
||||
pointers: readonly string[],
|
||||
isJson: (pointer: string) => boolean,
|
||||
generatedId: (pointer: string) => boolean,
|
||||
): ReadonlySet<string> => {
|
||||
const found = new Set<string>();
|
||||
const pending: { pointer: string; value: JsonValue }[] = [];
|
||||
for (const pointer of pointers) {
|
||||
const value = resolveView(ends.final, pointer, format);
|
||||
if (value !== undefined && isJson(pointer))
|
||||
pending.push({ pointer, value });
|
||||
}
|
||||
while (pending.length > 0) {
|
||||
const item = pending.pop();
|
||||
if (item === undefined) break;
|
||||
const { pointer, value } = item;
|
||||
if (typeof value === "string" && generatedId(pointer)) found.add(value);
|
||||
else if (Array.isArray(value)) {
|
||||
const { keys } = pairMembers(value, [], undefined, "json").newer;
|
||||
value.forEach((child, index) =>
|
||||
pending.push({
|
||||
pointer: `${pointer}/${escapeToken(keys[index] ?? String(index))}`,
|
||||
value: child,
|
||||
}),
|
||||
);
|
||||
} else if (value !== null && typeof value === "object") {
|
||||
for (const [key, child] of Object.entries(value))
|
||||
pending.push({
|
||||
pointer: `${pointer}/${escapeToken(key)}`,
|
||||
value: child,
|
||||
});
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
/** Count checks a JSON file's draft suggests, and count guards it offers. */
|
||||
const MAX_COUNTS = 2;
|
||||
const MAX_COUNT_GUARDS = 1;
|
||||
/** Pointers a note lists. */
|
||||
const MAX_LISTED = 5;
|
||||
|
||||
/** A field naming what kind of record a member is, as TreeLine's `format="BOOK"`. */
|
||||
const KIND_FIELD = /^(?:type|kind|format|category)$/iu;
|
||||
/**
|
||||
* The record a pointer reads a field of that the record's selector names it
|
||||
* by, where that selector reads a path inside the record, as
|
||||
* `/nodes/[format=BOOK][>data~1Title=The Silk Roads]` for its `data/Title`
|
||||
* and `format`, which the selector already decides; undefined when it reads none.
|
||||
*/
|
||||
const ownNameRecord = (pointer: string): string | undefined => {
|
||||
const spelled = pointer.split("/");
|
||||
const segments = spelled.map((segment) =>
|
||||
segment.replaceAll("~1", "/").replaceAll("~0", "~"),
|
||||
);
|
||||
const at = segments.findIndex((segment, index) => {
|
||||
const conditions = selectorOf(segment) ?? [];
|
||||
const rest = segments.slice(index + 1).join("/");
|
||||
return (
|
||||
conditions.some(({ prefix }) => prefix === ">") &&
|
||||
conditions.some(({ prefix, key }) => prefix !== "@" && key === rest)
|
||||
);
|
||||
});
|
||||
return at === -1 ? undefined : spelled.slice(0, at + 1).join("/");
|
||||
};
|
||||
|
||||
/**
|
||||
* The checks without those of a field its record's selector names it by
|
||||
* (`ownNameRecord`), but for the first of a record no other check reads, as
|
||||
* a new record holding only a generated uid and its title, so its addition
|
||||
* is still checked.
|
||||
*/
|
||||
export const ownNamesUnreadOtherwise = (
|
||||
checks: readonly Suggestion[],
|
||||
): Suggestion[] => {
|
||||
const records = checks.map((check) =>
|
||||
"pointer" in check ? ownNameRecord(check.pointer) : undefined,
|
||||
);
|
||||
const read = new Set<string>();
|
||||
const reads = (pointer: string) => {
|
||||
const parts = pointer.split("/");
|
||||
for (let end = 1; end < parts.length; end += 1)
|
||||
read.add(parts.slice(0, end).join("/"));
|
||||
};
|
||||
const kept = checks.filter((check, index) => {
|
||||
if (records[index] !== undefined) return false;
|
||||
if ("pointer" in check) reads(check.pointer);
|
||||
return true;
|
||||
});
|
||||
checks.forEach((check, index) => {
|
||||
const record = records[index];
|
||||
if (record === undefined || read.has(record) || !("pointer" in check))
|
||||
return;
|
||||
reads(check.pointer);
|
||||
kept.push(check);
|
||||
});
|
||||
return kept;
|
||||
};
|
||||
|
||||
const isRecord = (
|
||||
value: JsonValue | undefined,
|
||||
@@ -157,6 +320,200 @@ export const jsonCounts = (
|
||||
return { checks, guards };
|
||||
};
|
||||
|
||||
type Ends = {
|
||||
readonly baseline: JsonValue | null;
|
||||
readonly final: JsonValue | null;
|
||||
};
|
||||
|
||||
/** A record added whole that a pointer goes through: the innermost array holding a record on its way, the record's segment and place there, the path into it, and its like records, those of its kind (`kindOf`), at each end. */
|
||||
interface AddedRecord {
|
||||
readonly list: string;
|
||||
readonly member: string;
|
||||
readonly place: number;
|
||||
readonly field: string;
|
||||
readonly likes: {
|
||||
readonly baseline: readonly JsonValue[];
|
||||
readonly final: readonly {
|
||||
readonly item: JsonValue;
|
||||
readonly place: number;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
/** The record added whole a pointer goes through (`AddedRecord`), when the change lists it among `added`; undefined when the nearest record on the way is kept. */
|
||||
const addedRecordOf = (
|
||||
ends: Ends,
|
||||
added: ReadonlySet<string>,
|
||||
pointer: string,
|
||||
): AddedRecord | undefined => {
|
||||
const segments = pointer.split("/");
|
||||
for (let index = segments.length - 1; index >= 1; index -= 1) {
|
||||
const list = segments.slice(0, index).join("/");
|
||||
const items = resolvePointer(ends.final, list);
|
||||
const member = segments[index] ?? "";
|
||||
const record = resolvePointer(ends.final, `${list}/${member}`);
|
||||
if (!Array.isArray(items) || !isRecord(record)) continue;
|
||||
if (!added.has(`${list}/${member}`)) return undefined;
|
||||
const kind = kindOf(record, items);
|
||||
const like = (item: JsonValue) =>
|
||||
item !== record &&
|
||||
isRecord(item) &&
|
||||
(kind === undefined || item[kind.key] === kind.value);
|
||||
const older = resolvePointer(ends.baseline, list);
|
||||
return {
|
||||
list,
|
||||
member,
|
||||
place: items.indexOf(record),
|
||||
field: `/${segments.slice(index + 1).join("/")}`,
|
||||
likes: {
|
||||
baseline: (Array.isArray(older) ? older : []).filter(like),
|
||||
final: items.flatMap((item, place) =>
|
||||
like(item) ? [{ item, place }] : [],
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Whether a field of a record added whole holds what every like record at the end holds there, as the empty `children` of every TreeLine book: a default, not what the user entered. */
|
||||
export const isLikeDefault = (
|
||||
ends: Ends,
|
||||
added: ReadonlySet<string>,
|
||||
pointer: string,
|
||||
value: JsonValue,
|
||||
): boolean => {
|
||||
const record = addedRecordOf(ends, added, pointer);
|
||||
return (
|
||||
record !== undefined &&
|
||||
record.field !== "/" &&
|
||||
record.likes.final.length > 0 &&
|
||||
record.likes.final.every(({ item }) =>
|
||||
sameJson(resolvePointer(item, record.field), value),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/** Whether a member's selector reads a field, as `[format=BOOK][>data~1Title=SPQR]` reads `/data/Title`: a guard of it proves only that such a member exists. */
|
||||
const selectsBy = (member: string, field: string) => {
|
||||
const path = field
|
||||
.slice(1)
|
||||
.split("/")
|
||||
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||
.join("/");
|
||||
return (selectorOf(member) ?? []).some(
|
||||
({ prefix, key }) => prefix !== "@" && key === path,
|
||||
);
|
||||
};
|
||||
|
||||
/** The guard at a field of a like record a pairing names alike at both ends, not by position: its seed value, which the end holds too, and no stamp. */
|
||||
const recordGuard = (
|
||||
file: string,
|
||||
ends: Ends,
|
||||
member: string,
|
||||
field: string,
|
||||
): Suggestion | undefined => {
|
||||
const pointer = `${member}${field}`;
|
||||
const [before, after] = [ends.baseline, ends.final].map((view) =>
|
||||
locatePointer(view, pointer).byPosition.length === 0
|
||||
? resolvePointer(view, pointer)
|
||||
: undefined,
|
||||
);
|
||||
return before !== undefined &&
|
||||
after !== undefined &&
|
||||
typeof before !== "object" &&
|
||||
sameJson(after, before) &&
|
||||
!isStamp(before)
|
||||
? valueEqualsCheck(file, pointer, before)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Guards of a record added whole, as XML's: for each `value_equals` through
|
||||
* it, its field on up to two like records, nearest first among the
|
||||
* `MAX_SIBLINGS_READ` nearest that both ends name alike by selector, at the
|
||||
* seed value the end holds too, where like records at the seed hold
|
||||
* different values in it and the record's selector does not read it, as
|
||||
* TreeLine's `[format=BOOK][>data~1Title=Small Engines of Life]/data/Author`
|
||||
* beside a new book's author; at most `MAX_GUARDS` a file, none at a
|
||||
* suggested pointer.
|
||||
*/
|
||||
export const recordGuards = (
|
||||
file: string,
|
||||
suggested: readonly Suggestion[],
|
||||
ends: Ends,
|
||||
added: ReadonlySet<string>,
|
||||
): Suggestion[] => {
|
||||
const taken = new Set(
|
||||
suggested.flatMap((check) => ("pointer" in check ? [check.pointer] : [])),
|
||||
);
|
||||
const pairings = new Map<string, ReturnType<typeof pairMembers>>();
|
||||
const pairingOf = (list: string) => {
|
||||
const known = pairings.get(list);
|
||||
if (known !== undefined) return known;
|
||||
const [newer = [], older = []] = [ends.final, ends.baseline].map((view) => {
|
||||
const items = resolvePointer(view, list);
|
||||
return Array.isArray(items) ? items : [];
|
||||
});
|
||||
const pairing = pairMembers(newer, older, undefined, "json");
|
||||
pairings.set(list, pairing);
|
||||
return pairing;
|
||||
};
|
||||
const guards: Suggestion[] = [];
|
||||
for (const check of suggested) {
|
||||
if (guards.length >= MAX_GUARDS) break;
|
||||
const record =
|
||||
check.kind === "value_equals"
|
||||
? addedRecordOf(ends, added, check.pointer)
|
||||
: undefined;
|
||||
if (
|
||||
record === undefined ||
|
||||
/^\d+$/u.test(record.member) ||
|
||||
record.field === "/" ||
|
||||
new Set(
|
||||
record.likes.baseline.map((item) =>
|
||||
JSON.stringify(resolvePointer(item, record.field) ?? null),
|
||||
),
|
||||
).size < 2
|
||||
)
|
||||
continue;
|
||||
const pairing = pairingOf(record.list);
|
||||
const siblings = record.likes.final
|
||||
.toSorted(
|
||||
(left, right) =>
|
||||
Math.abs(left.place - record.place) -
|
||||
Math.abs(right.place - record.place) || left.place - right.place,
|
||||
)
|
||||
.flatMap(({ place }) => {
|
||||
const key = pairing.newer.keys[place];
|
||||
const partner = pairing.newer.partners[place];
|
||||
return key === undefined ||
|
||||
/^\d+$/u.test(key) ||
|
||||
partner === undefined ||
|
||||
pairing.older.keys[partner] !== key ||
|
||||
selectsBy(key, record.field)
|
||||
? []
|
||||
: [`${record.list}/${escapeToken(key)}`];
|
||||
})
|
||||
.slice(0, MAX_SIBLINGS_READ);
|
||||
let found = 0;
|
||||
for (const member of siblings) {
|
||||
if (found === MAX_GUARDS_PER_CHECK) break;
|
||||
const guard = recordGuard(file, ends, member, record.field);
|
||||
if (
|
||||
guard === undefined ||
|
||||
!("pointer" in guard) ||
|
||||
taken.has(guard.pointer)
|
||||
)
|
||||
continue;
|
||||
taken.add(guard.pointer);
|
||||
guards.push(guard);
|
||||
found += 1;
|
||||
}
|
||||
}
|
||||
return guards.slice(0, MAX_GUARDS);
|
||||
};
|
||||
|
||||
/** Pointers without a listed ancestor: a record removed whole is checked gone by its own pointer. */
|
||||
export const outermost = (pointers: readonly string[]): readonly string[] =>
|
||||
pointers.filter(
|
||||
|
||||
@@ -25,7 +25,7 @@ export const isMemberCount = (
|
||||
key: string | undefined,
|
||||
) => {
|
||||
const counter = pointer.replace(/\/children\/\d+$/u, "");
|
||||
const holder = resolvePointer(ends.final, counter);
|
||||
const holder = resolvePointer(ends.final, counter, "xml");
|
||||
const scope =
|
||||
key === undefined
|
||||
? counter !== pointer &&
|
||||
@@ -37,7 +37,7 @@ export const isMemberCount = (
|
||||
? pointer.split("/").slice(0, -2).join("/")
|
||||
: undefined;
|
||||
const [before, after] = [ends.baseline, ends.final].map((view) =>
|
||||
resolvePointer(view, pointer),
|
||||
resolvePointer(view, pointer, "xml"),
|
||||
);
|
||||
const number = (value: JsonValue | undefined) =>
|
||||
typeof value === "string" && /^\d+$/u.test(value)
|
||||
@@ -53,8 +53,9 @@ export const isMemberCount = (
|
||||
return false;
|
||||
const tallyAt = (view: JsonValue | null, deep: boolean) => {
|
||||
const tally = new Map<string, number>();
|
||||
const own = key === undefined ? resolvePointer(view, counter) : undefined;
|
||||
const owner = resolvePointer(view, scope);
|
||||
const own =
|
||||
key === undefined ? resolvePointer(view, counter, "xml") : undefined;
|
||||
const owner = resolvePointer(view, scope, "xml");
|
||||
const pending = isXmlElement(owner) ? [...owner.children] : [];
|
||||
for (let node = pending.pop(); node !== undefined; node = pending.pop())
|
||||
if (isXmlElement(node) && node !== own) {
|
||||
|
||||
@@ -32,7 +32,7 @@ export const mirrorsOf = (
|
||||
) => {
|
||||
const texts = change.changed.flatMap((pointer) => {
|
||||
const [before, after] = [ends.baseline, ends.final].map((view) =>
|
||||
resolvePointer(view, pointer),
|
||||
resolvePointer(view, pointer, "xml"),
|
||||
);
|
||||
const element = pointer.replace(/\/children\/\d+$/u, "");
|
||||
return element !== pointer &&
|
||||
@@ -58,6 +58,7 @@ export const mirrorsOf = (
|
||||
? resolvePointer(
|
||||
ends.final,
|
||||
mirror.element.replace(/\/children\/[^/]+$/u, ""),
|
||||
"xml",
|
||||
)
|
||||
: undefined;
|
||||
const shown = new Set(
|
||||
@@ -134,7 +135,7 @@ export const idReferencesOf = (
|
||||
ends: Ends,
|
||||
) => {
|
||||
const added = change.added.flatMap((pointer) => {
|
||||
const element = resolvePointer(ends.final, pointer);
|
||||
const element = resolvePointer(ends.final, pointer, "xml");
|
||||
return isXmlElement(element) ? [{ pointer, element }] : [];
|
||||
});
|
||||
const mirrors = added.flatMap(({ pointer, element }) => {
|
||||
|
||||
@@ -13,6 +13,14 @@ import {
|
||||
} from "./appTaskDraftAttributeText.js";
|
||||
import { draftCounts } from "./appTaskDraftCounts.js";
|
||||
import { nestedRecordChecks } from "./appTaskDraftNested.js";
|
||||
import {
|
||||
generatedIdsInAdditions,
|
||||
generatedRecordId,
|
||||
} from "./appTaskDraftJson.js";
|
||||
import {
|
||||
generatedReferenceNotes,
|
||||
type FileNote,
|
||||
} from "./appTaskDraftFileNotes.js";
|
||||
import { draftGuards } from "./appTaskDraftGuards.js";
|
||||
import {
|
||||
attributeKeyOf,
|
||||
@@ -40,6 +48,7 @@ import {
|
||||
resolvePointer,
|
||||
} from "./viewPointer.js";
|
||||
import {
|
||||
ARCHIVE,
|
||||
referenceRoots,
|
||||
valueFormat,
|
||||
type CheckTree,
|
||||
@@ -60,7 +69,11 @@ export const innermost = (pointers: readonly string[]): readonly string[] =>
|
||||
|
||||
/** Whether a check at a pointer is decided by name: no object or array reached by position, no array searched that the view may cut. */
|
||||
const byName = (view: JsonValue | null, pointer: string, tree: CheckTree) => {
|
||||
const located = locatePointer(view, pointer);
|
||||
const located = locatePointer(
|
||||
view,
|
||||
pointer,
|
||||
valueFormat(tree, view, pointer),
|
||||
);
|
||||
return (
|
||||
located.kind !== "unresolved" &&
|
||||
located.byPosition.length === 0 &&
|
||||
@@ -89,7 +102,12 @@ const isLayoutText = (
|
||||
tree.xml(view, pointer);
|
||||
|
||||
/** The tags of elements a pointer reaches by position, which no attribute names alone among their siblings. */
|
||||
const positionalTags = (view: JsonValue | null, pointer: string): string[] => {
|
||||
const positionalTags = (
|
||||
view: JsonValue | null,
|
||||
pointer: string,
|
||||
tree: CheckTree,
|
||||
): string[] => {
|
||||
if (!tree.xml(view, pointer)) return [];
|
||||
const segments = pointer.split("/");
|
||||
return segments.flatMap((segment, index) => {
|
||||
if (segments[index - 1] !== "children" || !/^\d+$/u.test(segment))
|
||||
@@ -97,6 +115,7 @@ const positionalTags = (view: JsonValue | null, pointer: string): string[] => {
|
||||
const element = resolvePointer(
|
||||
view,
|
||||
segments.slice(0, index + 1).join("/"),
|
||||
valueFormat(tree, view, pointer),
|
||||
);
|
||||
return isXmlElement(element) ? [element.name] : [];
|
||||
});
|
||||
@@ -124,6 +143,7 @@ export interface NamedDraft {
|
||||
readonly valuesCut: boolean;
|
||||
readonly guards: Suggestion[];
|
||||
readonly facts: NamedFacts;
|
||||
readonly notes?: readonly FileNote[];
|
||||
}
|
||||
|
||||
/** Noise checks a document keeps when its change is nothing else, so an author still sees one. */
|
||||
@@ -145,6 +165,9 @@ interface CollectContext {
|
||||
readonly limit: NamedOptions["limit"];
|
||||
readonly texts: WholeTextSuggestions;
|
||||
readonly noise: (pointer: string) => NoiseReason | undefined;
|
||||
readonly generatedId: (pointer: string) => boolean;
|
||||
readonly generatedIds: ReadonlySet<string>;
|
||||
readonly generatedReferences: string[];
|
||||
}
|
||||
|
||||
/** Whether a check reads a pointer that is noise. */
|
||||
@@ -187,13 +210,17 @@ const unnamed = (
|
||||
) => {
|
||||
if (
|
||||
collected.context.texts.covers(pointer) ||
|
||||
locatePointer(view, pointer).byPosition.length === 0
|
||||
locatePointer(
|
||||
view,
|
||||
pointer,
|
||||
valueFormat(collected.context.tree, view, pointer),
|
||||
).byPosition.length === 0
|
||||
)
|
||||
return;
|
||||
collected.positional.count += 1;
|
||||
if (collected.context.noise(pointer) === undefined)
|
||||
collected.positional.real += 1;
|
||||
for (const tag of positionalTags(view, pointer))
|
||||
for (const tag of positionalTags(view, pointer, collected.context.tree))
|
||||
collected.positional.tags.add(tag);
|
||||
};
|
||||
|
||||
@@ -223,7 +250,11 @@ const equals = (
|
||||
const { file, ends, tree, texts, noise } = collected.context;
|
||||
const list =
|
||||
noise(pointer) === undefined ? collected.values : collected.noise.values;
|
||||
const before = resolvePointer(ends.baseline, pointer);
|
||||
const before = resolvePointer(
|
||||
ends.baseline,
|
||||
pointer,
|
||||
valueFormat(tree, ends.baseline, pointer),
|
||||
);
|
||||
// Text whose whitespace alone changed, as a formula moved to a line of its own, holds what the seed did.
|
||||
const relaid =
|
||||
typeof value === "string" &&
|
||||
@@ -231,12 +262,22 @@ const equals = (
|
||||
collapseWhitespace(value) === collapseWhitespace(before) &&
|
||||
/\/children\/[^/]+$/u.test(pointer) &&
|
||||
tree.xml(ends.final, pointer);
|
||||
const generatedReference =
|
||||
typeof value === "string" &&
|
||||
collected.context.generatedIds.has(value) &&
|
||||
Array.isArray(
|
||||
resolvePointer(ends.final, pointer.replace(/\/[^/]+$/u, ""), "zip"),
|
||||
);
|
||||
if (generatedReference) collected.context.generatedReferences.push(pointer);
|
||||
if (
|
||||
!room(collected, list) ||
|
||||
texts.covers(pointer) ||
|
||||
value === undefined ||
|
||||
(isRecord(value) && Object.keys(value).length > 0) ||
|
||||
isStamp(value) ||
|
||||
(valueFormat(tree, ends.final, pointer) === "json" &&
|
||||
collected.context.generatedId(pointer)) ||
|
||||
generatedReference ||
|
||||
(tree.xml(ends.final, pointer) && inSplitDate(ends.final, pointer)) ||
|
||||
isLayoutText(tree, ends.final, pointer, value) ||
|
||||
relaid
|
||||
@@ -334,7 +375,16 @@ const addedChecks = (
|
||||
elementChecks(collected, pointer, value);
|
||||
else if (collected.context.texts.covers(pointer)) return;
|
||||
else if (Array.isArray(value) && value.length > 0) {
|
||||
const { keys } = pairMembers(value, []).newer;
|
||||
const { keys } = pairMembers(
|
||||
value,
|
||||
[],
|
||||
undefined,
|
||||
valueFormat(
|
||||
collected.context.tree,
|
||||
collected.context.ends.final,
|
||||
pointer,
|
||||
),
|
||||
).newer;
|
||||
value.forEach((item, index) =>
|
||||
addedChecks(
|
||||
collected,
|
||||
@@ -352,7 +402,11 @@ const addedChecks = (
|
||||
/** A check that a value is gone, only where the entry still has a view to lack it in. */
|
||||
const removedCheck = (collected: Collected, pointer: string) => {
|
||||
const { file, ends, tree, texts } = collected.context;
|
||||
const before = resolvePointer(ends.baseline, pointer);
|
||||
const before = resolvePointer(
|
||||
ends.baseline,
|
||||
pointer,
|
||||
valueFormat(tree, ends.baseline, pointer),
|
||||
);
|
||||
if (
|
||||
texts.covers(pointer) ||
|
||||
isLayoutText(tree, ends.baseline, pointer, before) ||
|
||||
@@ -372,7 +426,7 @@ const removedCheck = (collected: Collected, pointer: string) => {
|
||||
|
||||
/** The element a member pointer names at the seed: there, or, for one keyed by its new name as `renameKeys` keys it, the member it pairs with. */
|
||||
const atSeed = (ends: AddedContext["ends"], owner: string) =>
|
||||
resolvePointer(ends.baseline, owner) ?? pairedAtSeed(ends, owner);
|
||||
resolvePointer(ends.baseline, owner, "xml") ?? pairedAtSeed(ends, owner);
|
||||
|
||||
/**
|
||||
* A check that the identifier an element was renamed from names nothing at
|
||||
@@ -399,11 +453,11 @@ const renamedFrom = (collected: Collected, pointer: string) => {
|
||||
)
|
||||
return;
|
||||
const at = `${list}/${escapeToken(`[name=${before.name}][@${key}=${old}]`)}`;
|
||||
const seed = locatePointer(ends.baseline, at);
|
||||
const seed = locatePointer(ends.baseline, at, "xml");
|
||||
if (
|
||||
seed.kind === "value" &&
|
||||
seed.value === before &&
|
||||
locatePointer(ends.final, at).kind === "none" &&
|
||||
locatePointer(ends.final, at, "xml").kind === "none" &&
|
||||
tree.root(ends.final, at) === at &&
|
||||
named(collected, ends.baseline, at) &&
|
||||
named(collected, ends.final, at)
|
||||
@@ -425,18 +479,30 @@ const collectChecks = (
|
||||
fieldLong: [],
|
||||
};
|
||||
const { ends, tree } = context;
|
||||
const runs = attributeTexts(collected, change);
|
||||
const runs = attributeTexts(collected, xmlChange(change, ends, tree));
|
||||
for (const pointer of change.added.filter((at) => !runs(at, ends.final))) {
|
||||
const root = tree.root(ends.final, pointer);
|
||||
if (root !== undefined)
|
||||
addedChecks(collected, root, resolvePointer(ends.final, root));
|
||||
addedChecks(
|
||||
collected,
|
||||
root,
|
||||
resolvePointer(ends.final, root, valueFormat(tree, ends.final, root)),
|
||||
);
|
||||
}
|
||||
for (const pointer of innermost(change.changed))
|
||||
if (
|
||||
tree.root(ends.final, pointer) === pointer &&
|
||||
!runs(pointer, ends.final)
|
||||
) {
|
||||
equals(collected, pointer, resolvePointer(ends.final, pointer));
|
||||
equals(
|
||||
collected,
|
||||
pointer,
|
||||
resolvePointer(
|
||||
ends.final,
|
||||
pointer,
|
||||
valueFormat(tree, ends.final, pointer),
|
||||
),
|
||||
);
|
||||
// An id the app renumbered, or other noise, renamed nothing.
|
||||
if (context.noise(pointer) === undefined) renamedFrom(collected, pointer);
|
||||
}
|
||||
@@ -445,6 +511,18 @@ const collectChecks = (
|
||||
return { ...collected, runs };
|
||||
};
|
||||
|
||||
/** Only XML entries can contain elements and formatting runs. */
|
||||
const xmlChange = (
|
||||
change: DesktopStateDifference,
|
||||
ends: AddedContext["ends"],
|
||||
tree: CheckTree,
|
||||
): DesktopStateDifference => ({
|
||||
...change,
|
||||
added: change.added.filter((pointer) => tree.xml(ends.final, pointer)),
|
||||
changed: change.changed.filter((pointer) => tree.xml(ends.final, pointer)),
|
||||
removed: change.removed.filter((pointer) => tree.xml(ends.baseline, pointer)),
|
||||
});
|
||||
|
||||
/**
|
||||
* Take the `attributeTextCheck` of each attribute that may hold runs of
|
||||
* text; whether a change at a pointer is one a check took reads: the text
|
||||
@@ -510,8 +588,27 @@ export const namedDraft = (
|
||||
tree: CheckTree,
|
||||
options: NamedOptions,
|
||||
): NamedDraft => {
|
||||
const change = diffNamedMembers(ends.baseline, ends.final);
|
||||
const noise = changeNoise(change, ends, tree.xml);
|
||||
const change = diffNamedMembers(
|
||||
ends.baseline,
|
||||
ends.final,
|
||||
tree === ARCHIVE ? "zip" : "xml",
|
||||
);
|
||||
const generatedId =
|
||||
tree === ARCHIVE ? generatedRecordId(ends, "zip") : () => false;
|
||||
const generatedIds =
|
||||
tree === ARCHIVE
|
||||
? generatedIdsInAdditions(
|
||||
ends,
|
||||
"zip",
|
||||
change.added,
|
||||
(pointer) => valueFormat(tree, ends.final, pointer) === "json",
|
||||
generatedId,
|
||||
)
|
||||
: new Set<string>();
|
||||
const generatedReferences: string[] = [];
|
||||
const noise = changeNoise(change, ends, (view, pointer) =>
|
||||
valueFormat(tree, view, pointer),
|
||||
);
|
||||
const named = (view: JsonValue | null, pointer: string) =>
|
||||
byName(view, pointer, tree) && !tree.leavesOut(view, pointer);
|
||||
const texts = wholeTextSuggestions(file, change, ends, {
|
||||
@@ -523,7 +620,17 @@ export const namedDraft = (
|
||||
noise: (pointer) => noise(pointer) !== undefined,
|
||||
});
|
||||
const checks = collectChecks(
|
||||
{ file, ends, tree, limit: options.limit, texts, noise },
|
||||
{
|
||||
file,
|
||||
ends,
|
||||
tree,
|
||||
limit: options.limit,
|
||||
texts,
|
||||
noise,
|
||||
generatedId,
|
||||
generatedIds,
|
||||
generatedReferences,
|
||||
},
|
||||
change,
|
||||
);
|
||||
const noisy = (check: Suggestion) => isNoisy(noise, check);
|
||||
@@ -537,7 +644,7 @@ export const namedDraft = (
|
||||
covered: texts.covers,
|
||||
coveredRun: checks.runs,
|
||||
},
|
||||
change,
|
||||
xmlChange(change, ends, tree),
|
||||
);
|
||||
const clean = [
|
||||
...texts.suggestions.filter((check) => !noisy(check)),
|
||||
@@ -578,7 +685,10 @@ export const namedDraft = (
|
||||
noisy: (pointer) => noise(pointer) !== undefined,
|
||||
chars: options.limit.chars,
|
||||
},
|
||||
suggested,
|
||||
suggested.filter(
|
||||
(check) =>
|
||||
"pointer" in check && tree.xml(ends.final, check.pointer),
|
||||
),
|
||||
),
|
||||
]
|
||||
.slice(0, MAX_GUARDS)
|
||||
@@ -589,6 +699,7 @@ export const namedDraft = (
|
||||
values,
|
||||
valuesCut: cut,
|
||||
guards,
|
||||
notes: generatedReferenceNotes(file, generatedReferences),
|
||||
facts: factsOf(checks, texts, {
|
||||
listed,
|
||||
noise,
|
||||
|
||||
@@ -84,7 +84,9 @@ const cousinsOf = (
|
||||
): Element[] =>
|
||||
likes.flatMap((like) =>
|
||||
recordsIn(
|
||||
steps.length === 0 ? like : resolvePointer(like, `/${steps.join("/")}`),
|
||||
steps.length === 0
|
||||
? like
|
||||
: resolvePointer(like, `/${steps.join("/")}`, "xml"),
|
||||
).filter((record) => record.name === tag),
|
||||
);
|
||||
|
||||
@@ -201,7 +203,11 @@ export const nestedRecordChecks = (
|
||||
element: JsonValue,
|
||||
): DesktopTaskCheckInput[] => {
|
||||
if (!isXmlElement(element)) return [];
|
||||
const list = resolvePointer(context.final, pointer.replace(/\/[^/]+$/u, ""));
|
||||
const list = resolvePointer(
|
||||
context.final,
|
||||
pointer.replace(/\/[^/]+$/u, ""),
|
||||
"xml",
|
||||
);
|
||||
const likes = (Array.isArray(list) ? list : []).filter(
|
||||
(item) =>
|
||||
item !== element && isXmlElement(item) && item.name === element.name,
|
||||
|
||||
@@ -143,7 +143,12 @@ const isRecord = (
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
/** The elements a pointer steps into through `children`, read from the view, outermost first. */
|
||||
const elementsAlong = (view: JsonValue | null, pointer: string): Element[] => {
|
||||
const elementsAlong = (
|
||||
view: JsonValue | null,
|
||||
pointer: string,
|
||||
format: string,
|
||||
): Element[] => {
|
||||
if (format !== "xml") return [];
|
||||
const segments = pointer.split("/");
|
||||
const elements: Element[] = [];
|
||||
for (let index = 2; index < segments.length; index += 1) {
|
||||
@@ -151,6 +156,7 @@ const elementsAlong = (view: JsonValue | null, pointer: string): Element[] => {
|
||||
const element = resolvePointer(
|
||||
view,
|
||||
segments.slice(0, index + 1).join("/"),
|
||||
"xml",
|
||||
);
|
||||
if (isXmlElement(element)) elements.push(element);
|
||||
}
|
||||
@@ -219,11 +225,12 @@ const volatileAt = (
|
||||
xml: boolean,
|
||||
) =>
|
||||
(typeof value === "string" &&
|
||||
(isVolatileValue(
|
||||
value,
|
||||
elements.some(isBookkeeping) ||
|
||||
(key !== undefined && isBookkeepingName(key)),
|
||||
) ||
|
||||
((xml &&
|
||||
isVolatileValue(
|
||||
value,
|
||||
elements.some(isBookkeeping) ||
|
||||
(key !== undefined && isBookkeepingName(key)),
|
||||
)) ||
|
||||
isGarbled(value) ||
|
||||
fresh)) ||
|
||||
(key !== undefined && ID_COUNTER.test(key.split(":").at(-1) ?? key)) ||
|
||||
@@ -244,8 +251,12 @@ const freshIds = (
|
||||
): ReadonlySet<string> =>
|
||||
new Set(
|
||||
change.added.flatMap((pointer) => {
|
||||
const element = resolvePointer(ends.final, pointer);
|
||||
return isXmlElement(element)
|
||||
const element = resolvePointer(
|
||||
ends.final,
|
||||
pointer,
|
||||
ends.format(ends.final, pointer),
|
||||
);
|
||||
return ends.format(ends.final, pointer) === "xml" && isXmlElement(element)
|
||||
? Object.entries(element.attributes).flatMap(([key, value]) =>
|
||||
/^(?:[^:]*:)?(?:x|uu|g)?id$/iu.test(key) &&
|
||||
typeof value === "string" &&
|
||||
@@ -263,7 +274,7 @@ const isSlots = (element: Element) => /^(?:[^:]*:)?slots$/u.test(element.name);
|
||||
|
||||
/** Whether a pointer is a date the app stamps in parts, or inside one. */
|
||||
export const inSplitDate = (view: JsonValue | null, pointer: string): boolean =>
|
||||
stampedAlong(elementsAlong(view, pointer));
|
||||
stampedAlong(elementsAlong(view, pointer, "xml"));
|
||||
|
||||
/** Whether an element holds a date the app stamps in parts, `named` when a tag it sits in names bookkeeping. */
|
||||
const holdsStamped = (value: JsonValue, named: boolean): boolean =>
|
||||
@@ -279,8 +290,8 @@ export const holdsSplitDate = (
|
||||
view: JsonValue | null,
|
||||
pointer: string,
|
||||
): boolean => {
|
||||
const along = elementsAlong(view, pointer);
|
||||
const value = resolvePointer(view, pointer);
|
||||
const along = elementsAlong(view, pointer, "xml");
|
||||
const value = resolvePointer(view, pointer, "xml");
|
||||
return (
|
||||
stampedAlong(along) ||
|
||||
(value !== undefined && holdsStamped(value, along.some(isBookkeeping)))
|
||||
@@ -303,6 +314,7 @@ const VIEW_SETTING =
|
||||
|
||||
/** A file's views at its two ends. */
|
||||
interface Ends {
|
||||
readonly format: (view: JsonValue | null, pointer: string) => string;
|
||||
readonly baseline: JsonValue | null;
|
||||
readonly final: JsonValue | null;
|
||||
}
|
||||
@@ -330,12 +342,26 @@ const positionalRenumbering = (ends: Ends) => {
|
||||
const owner = ownerOfAttribute(pointer);
|
||||
const list = owner.split("/").slice(0, -1).join("/");
|
||||
const element =
|
||||
resolvePointer(ends.final, owner) ?? resolvePointer(ends.baseline, owner);
|
||||
if (!list.endsWith("/children") || !isXmlElement(element)) return false;
|
||||
resolvePointer(ends.final, owner, ends.format(ends.final, owner)) ??
|
||||
resolvePointer(ends.baseline, owner, ends.format(ends.baseline, owner));
|
||||
if (
|
||||
ends.format(ends.final, owner) !== "xml" ||
|
||||
!list.endsWith("/children") ||
|
||||
!isXmlElement(element)
|
||||
)
|
||||
return false;
|
||||
let keys = lists.get(list);
|
||||
if (keys === undefined) {
|
||||
const newer = resolvePointer(ends.final, list);
|
||||
const older = resolvePointer(ends.baseline, list);
|
||||
const newer = resolvePointer(
|
||||
ends.final,
|
||||
list,
|
||||
ends.format(ends.final, list),
|
||||
);
|
||||
const older = resolvePointer(
|
||||
ends.baseline,
|
||||
list,
|
||||
ends.format(ends.baseline, list),
|
||||
);
|
||||
keys =
|
||||
Array.isArray(newer) && Array.isArray(older)
|
||||
? positionalKeys(newer, older)
|
||||
@@ -366,16 +392,21 @@ const upkeepOf = (
|
||||
change: Pick<DesktopStateDifference, "added" | "changed" | "removed">,
|
||||
ends: Ends,
|
||||
noise: (pointer: string) => NoiseReason | undefined,
|
||||
xml: (view: JsonValue | null, pointer: string) => boolean,
|
||||
) => {
|
||||
const added = new Set(change.added);
|
||||
const gained = (pointer: string) =>
|
||||
ends.format(ends.final, pointer) === "xml" &&
|
||||
added.has(pointer) &&
|
||||
xml(ends.final, pointer) &&
|
||||
attributeKeyOf(pointer) === "loext:tab-stop-distance" &&
|
||||
pointer.startsWith("/entries/[name=styles.xml]/view/") &&
|
||||
[ends.baseline, ends.final].every((view) =>
|
||||
isXmlElement(resolvePointer(view, ownerOfAttribute(pointer))),
|
||||
isXmlElement(
|
||||
resolvePointer(
|
||||
view,
|
||||
ownerOfAttribute(pointer),
|
||||
ends.format(view, ownerOfAttribute(pointer)),
|
||||
),
|
||||
),
|
||||
);
|
||||
const edited = new Set<string>();
|
||||
/** How many elements of an entry gained one attribute key. One gain is an edit; a repeated gain is the app filling a default in. */
|
||||
@@ -399,7 +430,7 @@ const upkeepOf = (
|
||||
// A style a user names, as "Quotations" given to a paragraph, is an edit; one the app numbers, as `P1`, its upkeep.
|
||||
const automatic = (pointer: string, key: string) =>
|
||||
[ends.baseline, ends.final].every((view) => {
|
||||
const value = resolvePointer(view, pointer);
|
||||
const value = resolvePointer(view, pointer, ends.format(view, pointer));
|
||||
return (
|
||||
value === undefined ||
|
||||
(typeof value === "string" && isAutomaticStyle(key, value))
|
||||
@@ -408,16 +439,19 @@ const upkeepOf = (
|
||||
const styled = (pointer: string) => {
|
||||
const key = attributeKeyOf(pointer);
|
||||
const view =
|
||||
resolvePointer(ends.final, pointer) === undefined
|
||||
resolvePointer(ends.final, pointer, ends.format(ends.final, pointer)) ===
|
||||
undefined
|
||||
? ends.baseline
|
||||
: ends.final;
|
||||
return (
|
||||
xml(view, pointer) &&
|
||||
ends.format(view, pointer) === "xml" &&
|
||||
(ENTRY_VIEW.exec(pointer)?.[0] === "/entries/[name=styles.xml]/view/" ||
|
||||
(key !== undefined &&
|
||||
/style-name$/iu.test(key) &&
|
||||
automatic(pointer, key)) ||
|
||||
elementsAlong(view, pointer).some(({ name }) => STYLE_AREA.test(name)))
|
||||
elementsAlong(view, pointer, ends.format(view, pointer)).some(
|
||||
({ name }) => STYLE_AREA.test(name),
|
||||
))
|
||||
);
|
||||
};
|
||||
// The user's edit is elsewhere when a change no other rule calls noise lies outside the styles.
|
||||
@@ -439,12 +473,15 @@ const upkeepOf = (
|
||||
? 0
|
||||
: (filled.get(entry)?.get(key) ?? 0);
|
||||
const view =
|
||||
resolvePointer(ends.final, pointer) === undefined
|
||||
resolvePointer(ends.final, pointer, ends.format(ends.final, pointer)) ===
|
||||
undefined
|
||||
? ends.baseline
|
||||
: ends.final;
|
||||
const inStyleArea = elementsAlong(view, pointer).some(({ name }) =>
|
||||
STYLE_AREA.test(name),
|
||||
);
|
||||
const inStyleArea = elementsAlong(
|
||||
view,
|
||||
pointer,
|
||||
ends.format(view, pointer),
|
||||
).some(({ name }) => STYLE_AREA.test(name));
|
||||
const automaticName =
|
||||
key !== undefined && /style-name$/iu.test(key) && automatic(pointer, key);
|
||||
// One attribute gained in styles.xml is the edit. A repeated fill-in, an automatic style name, or a style area the app rewrites is upkeep.
|
||||
@@ -490,8 +527,10 @@ const derivedLayout = (
|
||||
ends: Ends,
|
||||
) => {
|
||||
const textAt = (view: JsonValue | null, pointer: string) => {
|
||||
const element = resolvePointer(view, pointer);
|
||||
return isXmlElement(element) ? elementText(element) : undefined;
|
||||
const element = resolvePointer(view, pointer, ends.format(view, pointer));
|
||||
return ends.format(view, pointer) === "xml" && isXmlElement(element)
|
||||
? elementText(element)
|
||||
: undefined;
|
||||
};
|
||||
const known = new Map<string, boolean>();
|
||||
const retexted = (pointer: string) => {
|
||||
@@ -504,7 +543,11 @@ const derivedLayout = (
|
||||
};
|
||||
const layout = new Set(
|
||||
change.changed.filter((pointer) => {
|
||||
const value = resolvePointer(ends.final, pointer);
|
||||
const value = resolvePointer(
|
||||
ends.final,
|
||||
pointer,
|
||||
ends.format(ends.final, pointer),
|
||||
);
|
||||
const owner = ownerOfAttribute(pointer);
|
||||
if (
|
||||
attributeKeyOf(pointer) === undefined ||
|
||||
@@ -550,13 +593,16 @@ const drawingLayout = (
|
||||
noise: (pointer: string) => NoiseReason | undefined,
|
||||
) => {
|
||||
const viewOf = (pointer: string) =>
|
||||
resolvePointer(ends.final, pointer) === undefined
|
||||
resolvePointer(ends.final, pointer, ends.format(ends.final, pointer)) ===
|
||||
undefined
|
||||
? ends.baseline
|
||||
: ends.final;
|
||||
const drawn = (pointer: string) =>
|
||||
elementsAlong(viewOf(pointer), pointer).some(({ name }) =>
|
||||
DRAWING.test(name.split(":").at(-1) ?? name),
|
||||
);
|
||||
elementsAlong(
|
||||
viewOf(pointer),
|
||||
pointer,
|
||||
ends.format(viewOf(pointer), pointer),
|
||||
).some(({ name }) => DRAWING.test(name.split(":").at(-1) ?? name));
|
||||
// A model element added or removed can resize the drawing. A rename or
|
||||
// other edit outside it does not make a deliberate resize into layout.
|
||||
const elsewhere = [...change.added, ...change.removed].some(
|
||||
@@ -566,7 +612,11 @@ const drawingLayout = (
|
||||
!drawn(pointer),
|
||||
);
|
||||
return (pointer: string) => {
|
||||
const value = resolvePointer(viewOf(pointer), pointer);
|
||||
const value = resolvePointer(
|
||||
viewOf(pointer),
|
||||
pointer,
|
||||
ends.format(viewOf(pointer), pointer),
|
||||
);
|
||||
return (
|
||||
elsewhere &&
|
||||
attributeKeyOf(pointer) !== undefined &&
|
||||
@@ -605,7 +655,7 @@ const namesLayout = (
|
||||
[
|
||||
attributeKeyOf(pointer) ?? "",
|
||||
...elements.flatMap((at) => {
|
||||
const element = resolvePointer(view, at);
|
||||
const element = resolvePointer(view, at, "xml");
|
||||
return isXmlElement(element)
|
||||
? [
|
||||
element.name,
|
||||
@@ -635,20 +685,26 @@ const namesLayout = (
|
||||
*/
|
||||
export const changeNoise = (
|
||||
change: Pick<DesktopStateDifference, "added" | "changed" | "removed">,
|
||||
ends: Ends,
|
||||
xml: (view: JsonValue | null, pointer: string) => boolean,
|
||||
input: Omit<Ends, "format">,
|
||||
format: Ends["format"],
|
||||
): ((pointer: string) => NoiseReason | undefined) => {
|
||||
const isXmlPointer = (pointer: string) =>
|
||||
xml(
|
||||
resolvePointer(ends.final, pointer) === undefined
|
||||
const ends: Ends = { ...input, format };
|
||||
const isXmlPointer = (pointer: string) => {
|
||||
const view =
|
||||
resolvePointer(ends.final, pointer, ends.format(ends.final, pointer)) ===
|
||||
undefined
|
||||
? ends.baseline
|
||||
: ends.final,
|
||||
pointer,
|
||||
);
|
||||
: ends.final;
|
||||
return ends.format(view, pointer) === "xml";
|
||||
};
|
||||
const xmlChange = {
|
||||
added: change.added.filter((pointer) => xml(ends.final, pointer)),
|
||||
added: change.added.filter(
|
||||
(pointer) => ends.format(ends.final, pointer) === "xml",
|
||||
),
|
||||
changed: change.changed.filter(isXmlPointer),
|
||||
removed: change.removed.filter((pointer) => xml(ends.baseline, pointer)),
|
||||
removed: change.removed.filter(
|
||||
(pointer) => ends.format(ends.baseline, pointer) === "xml",
|
||||
),
|
||||
};
|
||||
const byPosition = positionalRenumbering(ends);
|
||||
const fresh = freshIds(xmlChange, ends);
|
||||
@@ -671,13 +727,14 @@ export const changeNoise = (
|
||||
const base = (pointer: string): NoiseReason | undefined => {
|
||||
if (known.has(pointer)) return known.get(pointer);
|
||||
const view =
|
||||
resolvePointer(ends.final, pointer) === undefined
|
||||
resolvePointer(ends.final, pointer, ends.format(ends.final, pointer)) ===
|
||||
undefined
|
||||
? ends.baseline
|
||||
: ends.final;
|
||||
const isXml = xml(view, pointer);
|
||||
const isXml = ends.format(view, pointer) === "xml";
|
||||
const key = isXml ? attributeKeyOf(pointer) : undefined;
|
||||
const elements = isXml ? elementsAlong(view, pointer) : [];
|
||||
const value = resolvePointer(view, pointer);
|
||||
const elements = elementsAlong(view, pointer, ends.format(view, pointer));
|
||||
const value = resolvePointer(view, pointer, ends.format(view, pointer));
|
||||
const reason: NoiseReason | undefined =
|
||||
elements.some(showsView) || (key !== undefined && isViewStateName(key))
|
||||
? "view_state"
|
||||
@@ -689,7 +746,7 @@ export const changeNoise = (
|
||||
elements,
|
||||
key,
|
||||
value,
|
||||
freshAt(pointer, key, value),
|
||||
isXml && freshAt(pointer, key, value),
|
||||
isXml,
|
||||
)
|
||||
? "volatile"
|
||||
@@ -704,7 +761,7 @@ export const changeNoise = (
|
||||
known.set(pointer, reason);
|
||||
return reason;
|
||||
};
|
||||
const upkeep = upkeepOf(change, ends, base, xml);
|
||||
const upkeep = upkeepOf(change, ends, base);
|
||||
const layout = derivedLayout(change, ends);
|
||||
const drawing = drawingLayout(change, ends, base);
|
||||
const mirror = mirrorsOf(xmlChange, ends);
|
||||
|
||||
@@ -9,7 +9,7 @@ const FILE = "Documents/doc.xml";
|
||||
|
||||
/** The members of the array at a pointer into an XML text's view. */
|
||||
const membersAt = (text: string, pointer: string): JsonValue[] => {
|
||||
const members = resolvePointer(xml(text), pointer);
|
||||
const members = resolvePointer(xml(text), pointer, "xml");
|
||||
if (!Array.isArray(members)) throw new Error(`no array at ${pointer}`);
|
||||
return members;
|
||||
};
|
||||
@@ -114,6 +114,8 @@ describe("identifiers an app numbers by position", () => {
|
||||
const pairing = pairMembers(
|
||||
tasksOf(plan(...INSERTED)),
|
||||
tasksOf(plan(...SEED)),
|
||||
undefined,
|
||||
"xml",
|
||||
);
|
||||
|
||||
expect(pairing.newer.keys).toEqual([
|
||||
@@ -179,6 +181,8 @@ describe("identifiers an app numbers by position", () => {
|
||||
pairMembers(
|
||||
membersAt(after, "/root/children"),
|
||||
membersAt(before, "/root/children"),
|
||||
undefined,
|
||||
"xml",
|
||||
),
|
||||
).toEqual({
|
||||
newer: {
|
||||
@@ -211,7 +215,8 @@ describe("identifiers an app numbers by position", () => {
|
||||
const items = (text: string) => membersAt(text, "/root/children");
|
||||
|
||||
expect(
|
||||
pairMembers(items(stable(1, 3)), items(stable(1, 2, 3))).newer.keys,
|
||||
pairMembers(items(stable(1, 3)), items(stable(1, 2, 3)), undefined, "xml")
|
||||
.newer.keys,
|
||||
).toEqual(["[name=item][@id=1]", "[name=item][@id=3]"]);
|
||||
});
|
||||
});
|
||||
@@ -248,7 +253,12 @@ describe("an element renamed", () => {
|
||||
const layers = (top: string) =>
|
||||
membersAt(stack(top, { numbered: false }), "/root/children/0/children");
|
||||
|
||||
const pairing = pairMembers(layers("Sun"), layers("Untitled"));
|
||||
const pairing = pairMembers(
|
||||
layers("Sun"),
|
||||
layers("Untitled"),
|
||||
undefined,
|
||||
"xml",
|
||||
);
|
||||
|
||||
const keys = [
|
||||
"[name=layer][@src=data/sun.png]",
|
||||
@@ -265,7 +275,12 @@ describe("an element renamed", () => {
|
||||
const layers = (top: string) =>
|
||||
membersAt(stack(top), "/root/children/0/children");
|
||||
|
||||
const pairing = pairMembers(layers("Sun"), layers("Untitled"));
|
||||
const pairing = pairMembers(
|
||||
layers("Sun"),
|
||||
layers("Untitled"),
|
||||
undefined,
|
||||
"xml",
|
||||
);
|
||||
|
||||
expect(pairing.newer.keys[0]).toBe("[name=layer][@name=Sun]");
|
||||
expect(pairing.older.keys[0]).toBe("[name=layer][@name=Untitled]");
|
||||
@@ -356,6 +371,8 @@ describe("members named by a child's text or a generated id", () => {
|
||||
map(node("ID_1749680464", "Tent")),
|
||||
"/root/children/0/children",
|
||||
),
|
||||
undefined,
|
||||
"xml",
|
||||
);
|
||||
|
||||
expect(pairing.newer.keys).toEqual([
|
||||
|
||||
@@ -48,8 +48,8 @@ const sharedField = (
|
||||
step: ListStep,
|
||||
outer: ListStep,
|
||||
) => {
|
||||
const records = resolvePointer(view, outer.list);
|
||||
const record = resolvePointer(view, `${outer.list}/${outer.member}`);
|
||||
const records = resolvePointer(view, outer.list, "xml");
|
||||
const record = resolvePointer(view, `${outer.list}/${outer.member}`, "xml");
|
||||
const inner = outer.rest.slice(0, outer.rest.length - step.rest.length);
|
||||
return (
|
||||
Array.isArray(records) &&
|
||||
@@ -59,7 +59,7 @@ const sharedField = (
|
||||
item !== record &&
|
||||
isXmlElement(item) &&
|
||||
item.name === record.name &&
|
||||
resolvePointer(item, `/${inner.join("/")}`) !== undefined,
|
||||
resolvePointer(item, `/${inner.join("/")}`, "xml") !== undefined,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -104,20 +104,20 @@ const namedByIdentifier = (pointer: string) => {
|
||||
*/
|
||||
const textlessAt = (pointer: string, ends: Ends) =>
|
||||
[ends.baseline, ends.final].some((view) => {
|
||||
const value = resolvePointer(view, pointer);
|
||||
const value = resolvePointer(view, pointer, "xml");
|
||||
return isXmlElement(value) && elementText(value) === "";
|
||||
});
|
||||
|
||||
/** Whether both ends hold an element at a pointer. */
|
||||
const keptAt = (element: string, ends: Ends) =>
|
||||
[ends.baseline, ends.final].every((view) =>
|
||||
isXmlElement(resolvePointer(view, element)),
|
||||
isXmlElement(resolvePointer(view, element, "xml")),
|
||||
);
|
||||
|
||||
/** Whether an element holds text of its own, at either end. */
|
||||
const mixedAt = (element: string, ends: Ends) =>
|
||||
[ends.baseline, ends.final].some((view) =>
|
||||
holdsText(resolvePointer(view, element)),
|
||||
holdsText(resolvePointer(view, element, "xml")),
|
||||
);
|
||||
|
||||
/** Where a text check may go: an element as the diff names it, the pointer the check names it by, and whether an identifier is in that pointer. */
|
||||
@@ -134,8 +134,8 @@ interface Candidate {
|
||||
*/
|
||||
const byOwnAttribute = (element: string, ends: Ends): Candidate | undefined => {
|
||||
const parent = parentOf(element);
|
||||
const before = resolvePointer(ends.baseline, element);
|
||||
const after = resolvePointer(ends.final, element);
|
||||
const before = resolvePointer(ends.baseline, element, "xml");
|
||||
const after = resolvePointer(ends.final, element, "xml");
|
||||
if (parent === undefined || !isXmlElement(before) || !isXmlElement(after))
|
||||
return undefined;
|
||||
const attributes = attributesOf(after);
|
||||
@@ -163,8 +163,8 @@ const byOwnAttribute = (element: string, ends: Ends): Candidate | undefined => {
|
||||
const selector = `[name=${after.name}][@${key}=${value}]`;
|
||||
const pointer = `${parent}/children/${selector.replaceAll("~", "~0").replaceAll("/", "~1")}`;
|
||||
if (
|
||||
resolvePointer(ends.baseline, pointer) === before &&
|
||||
resolvePointer(ends.final, pointer) === after
|
||||
resolvePointer(ends.baseline, pointer, "xml") === before &&
|
||||
resolvePointer(ends.final, pointer, "xml") === after
|
||||
)
|
||||
return { element, pointer, identifier: isIdentifier(key) };
|
||||
}
|
||||
@@ -186,7 +186,7 @@ const isField = (pointer: string, ends: Ends) => {
|
||||
parent !== undefined &&
|
||||
tag !== undefined &&
|
||||
[ends.baseline, ends.final].every((view) => {
|
||||
const record = resolvePointer(view, parent);
|
||||
const record = resolvePointer(view, parent, "xml");
|
||||
const fields = isXmlElement(record)
|
||||
? record.children.filter(isXmlElement)
|
||||
: [];
|
||||
@@ -284,10 +284,10 @@ const outsideRuns = (pointer: string, ends: Ends): readonly Candidate[] => {
|
||||
// A record directly under the document can still hold a save time. Leave a formatting run out when the paragraph remains.
|
||||
if (
|
||||
stopped !== undefined &&
|
||||
holdsNoisyText(resolvePointer(ends.final, stopped))
|
||||
holdsNoisyText(resolvePointer(ends.final, stopped, "xml"))
|
||||
) {
|
||||
const narrower = withoutRuns(chain, (element) =>
|
||||
holdsNoisyText(resolvePointer(ends.final, element)),
|
||||
holdsNoisyText(resolvePointer(ends.final, element, "xml")),
|
||||
);
|
||||
if (narrower.length > 0)
|
||||
return narrower.map((element) => ({
|
||||
@@ -304,7 +304,7 @@ const outsideRuns = (pointer: string, ends: Ends): readonly Candidate[] => {
|
||||
named = parent;
|
||||
}
|
||||
const noisy = (element: string) =>
|
||||
holdsNoisyText(resolvePointer(ends.final, element));
|
||||
holdsNoisyText(resolvePointer(ends.final, element, "xml"));
|
||||
// An ancestor whose text joins a timestamp fails a later save that only restamps it.
|
||||
if (noisy(named)) {
|
||||
const narrower = withoutRuns(
|
||||
@@ -418,7 +418,7 @@ const inText = (view: JsonValue | null, pointer: string): boolean => {
|
||||
parent !== undefined;
|
||||
parent = parentOf(parent)
|
||||
) {
|
||||
const element = resolvePointer(view, parent);
|
||||
const element = resolvePointer(view, parent, "xml");
|
||||
if (
|
||||
isXmlElement(element) &&
|
||||
TEXT_TAG.test(element.name.split(":").at(-1) ?? element.name)
|
||||
@@ -455,7 +455,7 @@ const listOf = (
|
||||
)
|
||||
return false;
|
||||
const like = (view: JsonValue | null) => {
|
||||
const list = resolvePointer(view, holder);
|
||||
const list = resolvePointer(view, holder, "xml");
|
||||
return isXmlElement(list)
|
||||
? list.children.flatMap((child) =>
|
||||
isXmlElement(child) &&
|
||||
@@ -559,7 +559,9 @@ const candidateAt = (
|
||||
) => {
|
||||
const pointer = place.name(named);
|
||||
const now =
|
||||
pointer === undefined ? undefined : resolvePointer(ends.final, pointer);
|
||||
pointer === undefined
|
||||
? undefined
|
||||
: resolvePointer(ends.final, pointer, "xml");
|
||||
return pointer === undefined || !isXmlElement(now)
|
||||
? undefined
|
||||
: { pointer, now };
|
||||
@@ -580,7 +582,7 @@ const textCheck = (
|
||||
const found = candidateAt(context, candidate);
|
||||
if (found === undefined) return undefined;
|
||||
const { pointer, now } = found;
|
||||
const before = resolvePointer(context.ends.baseline, pointer);
|
||||
const before = resolvePointer(context.ends.baseline, pointer, "xml");
|
||||
if (!added && !isXmlElement(before)) return undefined;
|
||||
// A timestamp between other text is not part of the check. Joining across it asks for text the file never contains.
|
||||
if (holdsNoisyText(now)) return undefined;
|
||||
@@ -629,7 +631,7 @@ const textHolders = (change: Listed, ends: Ends, place: TextPlacement) => {
|
||||
view: JsonValue | null,
|
||||
how: "changed" | "added" | "removed",
|
||||
) => {
|
||||
const value = resolvePointer(view, pointer);
|
||||
const value = resolvePointer(view, pointer, "xml");
|
||||
const parent = parentOf(pointer);
|
||||
// Noise, such as a time the app stamps, never makes its ancestor's whole text a check.
|
||||
if (place.noise(pointer)) return;
|
||||
|
||||
@@ -143,11 +143,20 @@ export const presenceValues = (
|
||||
const from = created
|
||||
? { baseline: tree.empty, final: ends.final }
|
||||
: { baseline: ends.baseline, final: tree.empty };
|
||||
const change = oneEnd(diffNamedMembers(from.baseline, from.final), created);
|
||||
const change = oneEnd(
|
||||
diffNamedMembers(
|
||||
from.baseline,
|
||||
from.final,
|
||||
tree === ARCHIVE ? "zip" : "xml",
|
||||
),
|
||||
created,
|
||||
);
|
||||
return draftValues(change, {
|
||||
ends: from,
|
||||
listed: tree.listed,
|
||||
noise: changeNoise(change, from, tree.xml),
|
||||
noise: changeNoise(change, from, (view, pointer) =>
|
||||
valueFormat(tree, view, pointer),
|
||||
),
|
||||
format: (view, pointer) => valueFormat(tree, view, pointer),
|
||||
referenceRoots: (view) => referenceRoots(tree, view),
|
||||
});
|
||||
|
||||
@@ -78,8 +78,12 @@ describe("draftTaskFiles values", () => {
|
||||
expect(removed).toMatchObject({ before: '<tag name="old"/>', after: null });
|
||||
// A scalar's before and after are what its pointer reads at each end.
|
||||
expect(changed).toMatchObject({ before: "Draft", after: "Final" });
|
||||
expect(resolvePointer(xml(before), changed?.pointer ?? "")).toBe("Draft");
|
||||
expect(resolvePointer(xml(after), changed?.pointer ?? "")).toBe("Final");
|
||||
expect(resolvePointer(xml(before), changed?.pointer ?? "", "xml")).toBe(
|
||||
"Draft",
|
||||
);
|
||||
expect(resolvePointer(xml(after), changed?.pointer ?? "", "xml")).toBe(
|
||||
"Final",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a value that is another element's key, by the attribute's stem where several share it", () => {
|
||||
@@ -896,6 +900,7 @@ describe("draftTaskFiles values' other pointers", () => {
|
||||
resolvePointer(
|
||||
xml(poster("Jazz Festival")),
|
||||
changed?.alternatives?.[0] ?? "",
|
||||
"xml",
|
||||
),
|
||||
).toBe("Jazz Festival");
|
||||
});
|
||||
|
||||
@@ -338,7 +338,7 @@ const referencesOf = (
|
||||
view: JsonValue | null,
|
||||
pointer: string,
|
||||
): ValueReference[] => {
|
||||
const value = resolvePointer(view, pointer);
|
||||
const value = resolvePointer(view, pointer, "xml");
|
||||
if (isXmlElement(value))
|
||||
return uniqueReferences([
|
||||
...attributeReferences(resolve, value),
|
||||
@@ -360,15 +360,24 @@ const referencesOf = (
|
||||
const segments = pointer.split("/");
|
||||
const attribute =
|
||||
segments.at(-2) === "attributes" ? attributeKeyOf(pointer) : undefined;
|
||||
const owner = resolvePointer(view, segments.slice(0, -2).join("/"));
|
||||
const owner = resolvePointer(view, segments.slice(0, -2).join("/"), "xml");
|
||||
return [resolve(value, attribute, owner) ?? []].flat();
|
||||
};
|
||||
|
||||
/** What a pointer names at the seed; an attribute of a member keyed by its new name, as a renamed layer, read from the member it pairs with there. */
|
||||
const seedValue = ({ ends }: ValueContext, pointer: string) => {
|
||||
const seedValue = ({ ends, format }: ValueContext, pointer: string) => {
|
||||
const key = attributeKeyOf(pointer);
|
||||
const seen = resolvePointer(ends.baseline, pointer);
|
||||
if (seen !== undefined || key === undefined) return seen;
|
||||
const seen = resolvePointer(
|
||||
ends.baseline,
|
||||
pointer,
|
||||
format(ends.baseline, pointer),
|
||||
);
|
||||
if (
|
||||
seen !== undefined ||
|
||||
key === undefined ||
|
||||
format(ends.final, pointer) !== "xml"
|
||||
)
|
||||
return seen;
|
||||
const member = pairedAtSeed(ends, pointer.split("/").slice(0, -2).join("/"));
|
||||
return isXmlElement(member) ? member.attributes[key] : undefined;
|
||||
};
|
||||
@@ -382,7 +391,7 @@ const entryOf = (
|
||||
): DraftValue => {
|
||||
const { baseline, final } = context.ends;
|
||||
const view = kind === "removed" ? baseline : final;
|
||||
const located = locatePointer(view, pointer);
|
||||
const located = locatePointer(view, pointer, context.format(view, pointer));
|
||||
const found = located.kind === "value";
|
||||
// A text node added or removed is reached only by its index among its element's children.
|
||||
const listPointer = pointer.replace(/\/\d+$/u, "");
|
||||
@@ -391,8 +400,10 @@ const entryOf = (
|
||||
context.format(view, pointer) === "xml" &&
|
||||
kind !== "changed" &&
|
||||
/\/children\/\d+$/u.test(pointer) &&
|
||||
Array.isArray(resolvePointer(view, listPointer)) &&
|
||||
isXmlElement(resolvePointer(view, listPointer.replace(/\/children$/u, "")));
|
||||
Array.isArray(resolvePointer(view, listPointer, "xml")) &&
|
||||
isXmlElement(
|
||||
resolvePointer(view, listPointer.replace(/\/children$/u, ""), "xml"),
|
||||
);
|
||||
const note: ValueNote | undefined =
|
||||
found && located.byPosition.length > 0
|
||||
? "by_position"
|
||||
@@ -425,7 +436,7 @@ const entryOf = (
|
||||
kind === "removed"
|
||||
? null
|
||||
: compactValue(
|
||||
resolvePointer(final, pointer),
|
||||
resolvePointer(final, pointer, context.format(final, pointer)),
|
||||
VALUE_LIMITS.chars,
|
||||
context.format(final, pointer),
|
||||
),
|
||||
@@ -443,8 +454,8 @@ const entryOf = (
|
||||
* whole, as a document's root or a FreeCAD object of property lists.
|
||||
*/
|
||||
const fieldsOf = (view: JsonValue | null, pointer: string): string[] => {
|
||||
const value = resolvePointer(view, pointer);
|
||||
const list = resolvePointer(view, pointer.replace(/\/[^/]+$/u, ""));
|
||||
const value = resolvePointer(view, pointer, "xml");
|
||||
const list = resolvePointer(view, pointer.replace(/\/[^/]+$/u, ""), "xml");
|
||||
if (
|
||||
!isXmlElement(value) ||
|
||||
!/\/children\/[^/]+$/u.test(pointer) ||
|
||||
@@ -459,6 +470,8 @@ const fieldsOf = (view: JsonValue | null, pointer: string): string[] => {
|
||||
const { keys } = pairMembers(
|
||||
isXmlElement(element) ? element.children : [],
|
||||
[],
|
||||
undefined,
|
||||
"xml",
|
||||
).newer;
|
||||
return (isXmlElement(element) ? element.children : []).flatMap(
|
||||
(child, index) =>
|
||||
|
||||
@@ -58,7 +58,7 @@ def _viewless_entry(view, pointer: str):
|
||||
parts = pointer[1:].split("/")
|
||||
if len(parts) < 3 or parts[0] != "entries" or parts[2] != "view":
|
||||
return None
|
||||
kind, item, _ = _locate(view, "/entries/" + parts[1])
|
||||
kind, item, _ = _locate(view, "/entries/" + parts[1], "zip")
|
||||
if kind != "value" or not isinstance(item, dict):
|
||||
return None
|
||||
if isinstance(item.get("view_error"), str):
|
||||
@@ -88,7 +88,7 @@ def _zip_leaves_out(view, pointer: str) -> bool:
|
||||
return False
|
||||
parts = pointer[1:].split("/")
|
||||
if parts[0] == "entries" and len(parts) > 1:
|
||||
kind, entry, _ = _locate(view, "/entries/" + parts[1])
|
||||
kind, entry, _ = _locate(view, "/entries/" + parts[1], "zip")
|
||||
return _entry_leaves_out(entry) if kind == "value" else view.get("truncated") is True
|
||||
entries = view.get("entries") if isinstance(view.get("entries"), list) else []
|
||||
return view.get("truncated") is True or any(_entry_leaves_out(entry) for entry in entries)
|
||||
@@ -107,6 +107,16 @@ def _truncated(form, view) -> bool:
|
||||
for table in (tables.values() if isinstance(tables, dict) else []))
|
||||
|
||||
|
||||
def _selector_format(entry, view, pointer):
|
||||
if not isinstance(entry, dict) or entry.get("format") != "zip":
|
||||
return None if entry is None else entry.get("format")
|
||||
parts = pointer[1:].split("/")
|
||||
if len(parts) < 2 or parts[0] != "entries":
|
||||
return "zip"
|
||||
kind, item, _ = _locate(view, "/entries/" + parts[1], "zip")
|
||||
return item.get("format", "zip") if kind == "value" and isinstance(item, dict) else "zip"
|
||||
|
||||
|
||||
def _view_of(check, entry, form):
|
||||
"""The view, whether it is truncated, and why the check cannot read it; an absent file reads as empty."""
|
||||
if entry is None:
|
||||
@@ -168,7 +178,7 @@ def _xml_text(entry, view, pointer: str, found) -> bool:
|
||||
parts = pointer[1:].split("/")
|
||||
if len(parts) < 2 or parts[0] != "entries":
|
||||
return False
|
||||
kind, item, _ = _locate(view, "/entries/" + parts[1])
|
||||
kind, item, _ = _locate(view, "/entries/" + parts[1], "zip")
|
||||
return kind == "value" and isinstance(item, dict) and item.get("format") == "xml"
|
||||
|
||||
|
||||
@@ -189,7 +199,7 @@ def _value(check, entry):
|
||||
view, truncated, problem = _view_of(check, entry, None)
|
||||
if problem is not None:
|
||||
return False, problem
|
||||
kind, found, searched = ("none", None, []) if entry is None else _locate(view, check["pointer"])
|
||||
kind, found, searched = ("none", None, []) if entry is None else _locate(view, check["pointer"], entry.get("format"))
|
||||
at = f"{check['file']} at {check['pointer']}"
|
||||
viewless = _viewless_entry(view, check["pointer"]) if entry is not None and entry.get("format") == "zip" else None
|
||||
if viewless is not None:
|
||||
@@ -226,13 +236,13 @@ def _collapse_whitespace(text: str) -> str:
|
||||
return WHITESPACE_RUN.sub(" ", text).strip(" ")
|
||||
|
||||
|
||||
def _text_at(view, pointer: str) -> bool:
|
||||
def _text_at(view, pointer: str, form) -> bool:
|
||||
"""Whether a string a pointer reaches is text of the document: a member of an element's children (a text node)
|
||||
or attributes (an attribute's value), rather than an element's name or a field of the DOCTYPE."""
|
||||
segments = pointer.split("/")
|
||||
if len(segments) < 3 or segments[-2] not in ("children", "attributes"):
|
||||
return False
|
||||
kind, owner, _ = _locate(view, "/".join(segments[:-2]))
|
||||
kind, owner, _ = _locate(view, "/".join(segments[:-2]), form)
|
||||
return kind == "value" and _is_element(owner)
|
||||
|
||||
|
||||
@@ -244,7 +254,7 @@ def _not_xml_entry(check, view):
|
||||
return f"{check['file']} at {check['pointer']} does not point into an entry's view"
|
||||
if parts[1] != "entries":
|
||||
return None
|
||||
kind, item, _ = _locate(view, "/entries/" + parts[2])
|
||||
kind, item, _ = _locate(view, "/entries/" + parts[2], "zip")
|
||||
if kind != "value" or not isinstance(item, dict) or item.get("format") == "xml":
|
||||
return None
|
||||
# An entry without a view failed before this, so its format is one with a view.
|
||||
@@ -263,7 +273,7 @@ def _whole_text(check, entry):
|
||||
form = entry.get("format")
|
||||
if form not in ("xml", "zip"):
|
||||
return False, f"{check['file']} is read as {form}, not xml"
|
||||
kind, found, searched = _locate(view, check["pointer"])
|
||||
kind, found, searched = _locate(view, check["pointer"], entry.get("format"))
|
||||
viewless = _viewless_entry(view, check["pointer"]) if form == "zip" else None
|
||||
if viewless is not None:
|
||||
return False, f"{check['file']} entry {viewless[0]} {viewless[1]}"
|
||||
@@ -282,7 +292,7 @@ def _whole_text(check, entry):
|
||||
return False, f"{at} has no value" if found is None else f"{at}: {found}"
|
||||
# Only an element has attributes of its own and of its descendants to join.
|
||||
attribute = check.get("text_attribute")
|
||||
if not ((attribute is None and _text_at(view, check["pointer"])) if isinstance(found, str) else _is_element(found)):
|
||||
if not ((attribute is None and _text_at(view, check["pointer"], entry.get("format"))) if isinstance(found, str) else _is_element(found)):
|
||||
what = "an element, a text node or an attribute value" if attribute is None else "an element"
|
||||
return False, f"{at} is {_shown(found)}, not {what}"
|
||||
# A text node is whole. An element's text in a cut view may lack what the cut dropped from its middle.
|
||||
@@ -318,7 +328,7 @@ def _members(check, entry):
|
||||
form = None if entry is None else entry.get("format")
|
||||
# The empty pointer names the whole view, as a JSON file whose top level is an array.
|
||||
kind, found, searched = (("none", None, []) if entry is None else ("value", view, []) if pointer == ""
|
||||
else _locate(view, pointer))
|
||||
else _locate(view, pointer, form))
|
||||
at = check["file"] if pointer == "" else f"{check['file']} at {pointer}"
|
||||
viewless = _viewless_entry(view, pointer) if form == "zip" else None
|
||||
if viewless is not None:
|
||||
@@ -338,7 +348,8 @@ def _members(check, entry):
|
||||
return _undecided(check)
|
||||
# The parser refuses a matching that is no selector.
|
||||
conditions = None if matching is None else _selector(matching)
|
||||
n = len(found) if matching is None else sum(1 for item in found if conditions and _selects(item, conditions))
|
||||
matching_form = _selector_format(entry, view, pointer) if matching is not None else None
|
||||
n = len(found) if matching is None else sum(1 for item in found if conditions and _selects(item, conditions, matching_form))
|
||||
return _compared(check, n, f"member(s) of {at}" + ("" if matching is None else f" matching {matching}"))
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { MAX_INSIDE_STEPS } from "./viewSelector.js";
|
||||
|
||||
/** The verifier's XML elements, pointers and selectors, kept equal to `viewPointer`'s; it relies on `INDEX` and `_collapse_whitespace`. */
|
||||
export const PYTHON_POINTER = String.raw`def _is_element(value) -> bool:
|
||||
return (isinstance(value, dict) and isinstance(value.get("name"), str) and isinstance(value.get("attributes"), dict)
|
||||
@@ -26,8 +28,8 @@ def _bracketed(token: str) -> bool:
|
||||
|
||||
|
||||
def _selector(token: str):
|
||||
"""A selector's conditions, [KEY=VALUE], [@KEY=VALUE] or [>KEY=VALUE] one or more times, or None when the token
|
||||
is not one."""
|
||||
"""A selector's conditions, [KEY=VALUE], [@KEY=VALUE] or [>PATH=VALUE] one or more times, or None when the token
|
||||
is not one, or a path has an empty step or more than ${String(MAX_INSIDE_STEPS)}."""
|
||||
if not _bracketed(token):
|
||||
return None
|
||||
conditions = []
|
||||
@@ -35,7 +37,8 @@ def _selector(token: str):
|
||||
key, equals, value = part.partition("=")
|
||||
prefix = key[:1] if key[:1] in ("@", ">") else ""
|
||||
name = key[len(prefix):]
|
||||
if not equals or not name:
|
||||
steps = name.split("/")
|
||||
if not equals or not name or (prefix == ">" and (len(steps) > ${String(MAX_INSIDE_STEPS)} or "" in steps)):
|
||||
return None
|
||||
conditions.append((prefix, name, value))
|
||||
return conditions
|
||||
@@ -63,28 +66,53 @@ def _text_without_stamps(node) -> str:
|
||||
return "".join(_text_without_stamps(child) for child in node["children"])
|
||||
|
||||
|
||||
def _has_child_text(member, key: str, value: str) -> bool:
|
||||
"""Whether an element has a child element named key whose text, its whitespace collapsed, is value. A nested
|
||||
save time is not part of the name. A child that is only a time still matches that time."""
|
||||
if not _is_element(member):
|
||||
return False
|
||||
for child in member["children"]:
|
||||
if not _is_element(child) or child["name"] != key:
|
||||
def _condition_text(child) -> str:
|
||||
"""A child's text as a condition reads it: whitespace collapsed, without a nested save time unless it is only
|
||||
a time."""
|
||||
plain = _collapse_whitespace(_text_without_stamps(child))
|
||||
return plain if plain else _collapse_whitespace(_element_text(child))
|
||||
|
||||
|
||||
def _element_holds(element, steps, value: str) -> bool:
|
||||
"""Whether an element has a child element named the first step that holds value along the rest: at the last step
|
||||
its text, whitespace collapsed, or with NAME@ATTR its attribute ATTR. Each element on the way is visited once."""
|
||||
step, rest = steps[0], steps[1:]
|
||||
name, at, attribute = step.partition("@") if not rest else (step, "", "")
|
||||
for child in element["children"]:
|
||||
if not _is_element(child) or child["name"] != name:
|
||||
continue
|
||||
plain = _collapse_whitespace(_text_without_stamps(child))
|
||||
text = plain if plain else _collapse_whitespace(_element_text(child))
|
||||
if text == value:
|
||||
if rest:
|
||||
if _element_holds(child, rest, value):
|
||||
return True
|
||||
elif at:
|
||||
if child["attributes"].get(attribute) == value:
|
||||
return True
|
||||
elif _condition_text(child) == value:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _selects(member, conditions) -> bool:
|
||||
def _holds_inside(member, path: str, value: str, form) -> bool:
|
||||
"""Whether a >PATH condition's path inside a member holds value: in an XML element as _element_holds reads it, in
|
||||
a JSON record the string at those keys."""
|
||||
steps = path.split("/")
|
||||
if form == "xml":
|
||||
return _is_element(member) and _element_holds(member, steps, value)
|
||||
found = member
|
||||
for step in steps:
|
||||
if not isinstance(found, dict) or step not in found:
|
||||
return False
|
||||
found = found[step]
|
||||
return isinstance(found, str) and found == value
|
||||
|
||||
|
||||
def _selects(member, conditions, form) -> bool:
|
||||
"""Whether an array member meets every condition; selectors compare strings only."""
|
||||
if not isinstance(member, dict):
|
||||
return False
|
||||
for prefix, key, value in conditions:
|
||||
if prefix == ">":
|
||||
if not _has_child_text(member, key, value):
|
||||
if not _holds_inside(member, key, value, form):
|
||||
return False
|
||||
continue
|
||||
fields = member.get("attributes") if prefix == "@" else member
|
||||
@@ -93,7 +121,7 @@ def _selects(member, conditions) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _step(items: list, token: str, last: bool):
|
||||
def _step(items: list, token: str, last: bool, form):
|
||||
"""One step into an array, by index or by a selector that must match one member, or none when it is the last."""
|
||||
if INDEX.fullmatch(token):
|
||||
# A canonical decimal longer than the array length cannot name a member.
|
||||
@@ -105,7 +133,7 @@ def _step(items: list, token: str, last: bool):
|
||||
if conditions is None:
|
||||
return ("unresolved", f"{token} is not a selector of the form [KEY=VALUE]") if _bracketed(token) else (
|
||||
"none", None)
|
||||
matched = [item for item in items if _selects(item, conditions)]
|
||||
matched = [item for item in items if _selects(item, conditions, form)]
|
||||
if len(matched) == 1:
|
||||
return "member", matched[0]
|
||||
if len(matched) > 1:
|
||||
@@ -113,21 +141,24 @@ def _step(items: list, token: str, last: bool):
|
||||
return ("none" if last else "unresolved"), f"{token} matches no member"
|
||||
|
||||
|
||||
def _locate(view, pointer: str):
|
||||
def _locate(view, pointer: str, form):
|
||||
"""Where a pointer leads: ("value", value), ("none", why or None) or ("unresolved", problem), with the pointers
|
||||
to the arrays a selector searched on the way."""
|
||||
current, searched = view, []
|
||||
root_form = form
|
||||
segments = pointer[1:].split("/")
|
||||
for position, segment in enumerate(segments):
|
||||
token = segment.replace("~1", "/").replace("~0", "~")
|
||||
if isinstance(current, list):
|
||||
if not INDEX.fullmatch(token) and _selector(token) is not None:
|
||||
searched.append("".join("/" + part for part in segments[:position]))
|
||||
kind, found = _step(current, token, position == len(segments) - 1)
|
||||
kind, found = _step(current, token, position == len(segments) - 1, form)
|
||||
if kind != "member":
|
||||
return kind, found, searched
|
||||
current = found
|
||||
elif isinstance(current, dict) and token in current:
|
||||
if root_form == "zip" and position == 2 and segments[0] == "entries" and token == "view":
|
||||
form = current.get("format")
|
||||
current = current[token]
|
||||
else:
|
||||
return "none", None, searched
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import { isGeneratedId } from "./attributeRank.js";
|
||||
import { isXmlElement } from "./viewPointer.js";
|
||||
import { isTimestamp } from "./appTask.js";
|
||||
import {
|
||||
isGeneratedId,
|
||||
isNameLikeKey,
|
||||
isPlaceholder,
|
||||
} from "./attributeRank.js";
|
||||
import { MAX_INSIDE_STEPS, spellCondition } from "./viewSelector.js";
|
||||
|
||||
/** A field naming a JSON record, as TreeLine's `uid`: `id`, `uid`, `uuid`, `guid`, `key`, or a name ending in `_id`, `-id` or `Id`. */
|
||||
const ID_FIELD = /^(?:_?id|uid|uuid|guid|key)$|[_-]id$|[a-z]Id$/u;
|
||||
/** Selector marks are not JSON record field names. */
|
||||
export const isRecordIdField = (field: string): boolean =>
|
||||
ID_FIELD.test(field) && !/^[@>]|[=[\]]/u.test(field);
|
||||
/** The record's own id, ahead of a relationship such as `parent_id`, which also matches `ID_FIELD`. */
|
||||
const OWN_ID = ["uid", "uuid", "guid", "id", "key"];
|
||||
|
||||
/** A field naming what kind of record a member is, as TreeLine's `format="BOOK"`. */
|
||||
export const KIND_FIELD = /^(?:type|kind|format|category)$/iu;
|
||||
|
||||
/** The longest value a field names a record by. */
|
||||
const MAX_NAME = 64;
|
||||
/** The most kind fields and paths a version's records are counted by, each a pass over them, so odd records cost no more. */
|
||||
const MAX_TALLIES = 64;
|
||||
|
||||
const isRecord = (
|
||||
value: JsonValue | undefined,
|
||||
): value is Record<string, JsonValue> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const isJsonRecord = (value: JsonValue): value is Record<string, JsonValue> =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
!isXmlElement(value);
|
||||
isRecord(value);
|
||||
|
||||
/** Whether every member holds a string at a key a selector can carry, each its own. */
|
||||
const namesEach = (members: readonly JsonValue[], key: string) => {
|
||||
@@ -26,6 +44,155 @@ const namesEach = (members: readonly JsonValue[], key: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
/** The string at a path of keys inside a record, as a `>` condition reads a JSON record. */
|
||||
const stringAt = (record: JsonValue, path: readonly string[]) => {
|
||||
let found: JsonValue | undefined = record;
|
||||
for (const key of path)
|
||||
found =
|
||||
isRecord(found) && Object.hasOwn(found, key) ? found[key] : undefined;
|
||||
return typeof found === "string" ? found : undefined;
|
||||
};
|
||||
|
||||
/** A value a user gave a record, which another save of the same edit repeats: short, worded, no time or token an app makes. */
|
||||
const isName = (value: string) =>
|
||||
value.length <= MAX_NAME &&
|
||||
/[\p{L}\d]/u.test(value) &&
|
||||
!value.includes("][") &&
|
||||
!isTimestamp(value) &&
|
||||
!isGeneratedId(value) &&
|
||||
!isPlaceholder(value);
|
||||
|
||||
/** The first path, nearest first and up to `MAX_INSIDE_STEPS` keys deep, of a record's name-like string fields that `names` accepts, as TreeLine's `data/Title`. */
|
||||
const namePath = (
|
||||
record: Record<string, JsonValue>,
|
||||
names: (path: readonly string[]) => boolean,
|
||||
): readonly string[] | undefined => {
|
||||
let level = [{ path: [] as string[], fields: record }];
|
||||
for (let depth = 0; depth < MAX_INSIDE_STEPS; depth += 1) {
|
||||
const next: typeof level = [];
|
||||
for (const { path, fields } of level)
|
||||
for (const [key, value] of Object.entries(fields))
|
||||
if (key === "" || /[=[\]/]/u.test(key)) continue;
|
||||
else if (isRecord(value))
|
||||
next.push({ path: [...path, key], fields: value });
|
||||
else if (
|
||||
typeof value === "string" &&
|
||||
isNameLikeKey(key) &&
|
||||
isName(value) &&
|
||||
names([...path, key])
|
||||
)
|
||||
return [...path, key];
|
||||
level = next;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** The field a record's kind is kept in, as `format`, when it holds a string a selector can carry. */
|
||||
const kindOf = (record: Record<string, JsonValue>) =>
|
||||
Object.keys(record).find((key) => {
|
||||
const value = record[key];
|
||||
return (
|
||||
KIND_FIELD.test(key) && typeof value === "string" && !value.includes("][")
|
||||
);
|
||||
});
|
||||
|
||||
/** How many of a version's records hold a record's kind and its string at a path, each kind field and path read over the version once; undefined past `MAX_TALLIES` of them. */
|
||||
const tally = (members: readonly JsonValue[]) => {
|
||||
const counted = new Map<string, Map<string, number>>();
|
||||
return (
|
||||
kind: string | undefined,
|
||||
path: readonly string[],
|
||||
of: JsonValue,
|
||||
): number | undefined => {
|
||||
const held = (member: JsonValue) => {
|
||||
const name = stringAt(member, path);
|
||||
const sort = kind === undefined ? "" : stringAt(member, [kind]);
|
||||
return name === undefined || sort === undefined
|
||||
? undefined
|
||||
: `${sort}\u0000${name}`;
|
||||
};
|
||||
const id = JSON.stringify([kind ?? null, path]);
|
||||
let counts = counted.get(id);
|
||||
if (counts === undefined) {
|
||||
if (counted.size >= MAX_TALLIES) return undefined;
|
||||
counts = new Map();
|
||||
for (const member of members) {
|
||||
const at = held(member);
|
||||
if (at !== undefined) counts.set(at, (counts.get(at) ?? 0) + 1);
|
||||
}
|
||||
counted.set(id, counts);
|
||||
}
|
||||
const at = held(of);
|
||||
return at === undefined ? 0 : (counts.get(at) ?? 0);
|
||||
};
|
||||
};
|
||||
|
||||
/** The selector naming a record by its kind and its string at a path. */
|
||||
const spellName = (
|
||||
record: Record<string, JsonValue>,
|
||||
kind: string | undefined,
|
||||
path: readonly string[],
|
||||
) => {
|
||||
const value = stringAt(record, path);
|
||||
if (value === undefined) return undefined;
|
||||
const key = path.join("/");
|
||||
// A field of the record's own is its plain condition, unless its key reads as a prefix.
|
||||
const prefix = path.length === 1 && !/^[@>]/u.test(key) ? "" : ">";
|
||||
const sort =
|
||||
kind === undefined
|
||||
? ""
|
||||
: spellCondition({
|
||||
prefix: "",
|
||||
key: kind,
|
||||
value: stringAt(record, [kind]) ?? "",
|
||||
});
|
||||
return `${sort}${spellCondition({ prefix, key, value })}`;
|
||||
};
|
||||
|
||||
type Tally = ReturnType<typeof tally>;
|
||||
|
||||
/**
|
||||
* The selector naming a record only one version holds by its kind and its
|
||||
* first name-like field that names it alone there and nothing in the other
|
||||
* version, as TreeLine's new book by `[format=BOOK][>data/Title=The Silk Roads]`;
|
||||
* undefined when none does.
|
||||
*/
|
||||
const nameOf = (record: JsonValue, here: Tally, there: Tally) => {
|
||||
if (!isRecord(record)) return undefined;
|
||||
const kind = kindOf(record);
|
||||
const path = namePath(
|
||||
record,
|
||||
(at) => here(kind, at, record) === 1 && there(kind, at, record) === 0,
|
||||
);
|
||||
return path === undefined ? undefined : spellName(record, kind, path);
|
||||
};
|
||||
|
||||
/**
|
||||
* The selector naming a record both versions hold by its kind and its first
|
||||
* name-like field that holds the same string in both and names it alone in
|
||||
* each, as TreeLine's kept category by `[format=CATEGORY][>data/NAME=Science]`;
|
||||
* undefined when none does.
|
||||
*/
|
||||
const keptNameOf = (
|
||||
newer: JsonValue,
|
||||
older: JsonValue,
|
||||
counts: { readonly newer: Tally; readonly older: Tally },
|
||||
) => {
|
||||
if (!isRecord(newer) || !isRecord(older)) return undefined;
|
||||
const kind = kindOf(newer);
|
||||
const same = (path: readonly string[]) =>
|
||||
stringAt(newer, path) === stringAt(older, path);
|
||||
const path = namePath(
|
||||
newer,
|
||||
(at) =>
|
||||
(kind === undefined || same([kind])) &&
|
||||
same(at) &&
|
||||
counts.newer(kind, at, newer) === 1 &&
|
||||
counts.older(kind, at, older) === 1,
|
||||
);
|
||||
return path === undefined ? undefined : spellName(newer, kind, path);
|
||||
};
|
||||
|
||||
/** One version's side of a pairing: each member's key, and the other version's member it pairs with. */
|
||||
interface Side {
|
||||
readonly keys: readonly string[];
|
||||
@@ -34,26 +201,33 @@ interface Side {
|
||||
|
||||
/**
|
||||
* Pair the JSON records of two versions of an array by an id field each
|
||||
* holds alone at both ends (`ID_FIELD`), as TreeLine's nodes by
|
||||
* `[uid=7584d494d9f511f1a873edd4f1920549]`, whatever order the app saves
|
||||
* them in; a member only one version holds is named by its id too, unless
|
||||
* the app generated it (`isGeneratedId`), when another save would not
|
||||
* repeat it and only position names the member. Undefined when the members
|
||||
* are not all JSON records or no such field names each.
|
||||
* holds alone at both ends (`ID_FIELD`), as TreeLine's nodes by their `uid`,
|
||||
* whatever order the app saves them in, and name each by that id, as
|
||||
* `[id=chores]`, unless the app generated it (`isGeneratedId`): then a
|
||||
* member both versions hold by a name-like field inside it (`keptNameOf`),
|
||||
* else by its id, as `[uid=7584d494d9f511f1a873edd4f1920549]`; and a member
|
||||
* only one version holds, which another save would give another id, by a
|
||||
* name-like field inside it (`nameOf`), else only by position. Undefined
|
||||
* when the members are not all JSON records or no such field names each.
|
||||
*/
|
||||
export const pairRecordsById = (
|
||||
newer: readonly JsonValue[],
|
||||
older: readonly JsonValue[],
|
||||
): { readonly newer: Side; readonly older: Side } | undefined => {
|
||||
):
|
||||
| {
|
||||
readonly newer: Side;
|
||||
readonly older: Side;
|
||||
readonly idField: string;
|
||||
readonly generated: readonly boolean[];
|
||||
}
|
||||
| undefined => {
|
||||
const [first] = [...newer, ...older];
|
||||
if (!isJsonRecord(first ?? null) || ![...newer, ...older].every(isJsonRecord))
|
||||
return undefined;
|
||||
const key = Object.keys(first ?? {})
|
||||
.filter(
|
||||
(field) =>
|
||||
ID_FIELD.test(field) &&
|
||||
// `[@attr]` and `[>child]` are selector marks, not a JSON field's name.
|
||||
!/^[@>]|[=[\]]/u.test(field) &&
|
||||
isRecordIdField(field) &&
|
||||
namesEach(newer, field) &&
|
||||
namesEach(older, field),
|
||||
)
|
||||
@@ -67,20 +241,41 @@ export const pairRecordsById = (
|
||||
if (key === undefined) return undefined;
|
||||
const idOf = (member: JsonValue) =>
|
||||
isJsonRecord(member) ? String(member[key]) : "";
|
||||
const side = (
|
||||
const counts = { newer: tally(newer), older: tally(older) };
|
||||
const partnersOf = (
|
||||
members: readonly JsonValue[],
|
||||
others: readonly JsonValue[],
|
||||
): Side => {
|
||||
) => {
|
||||
const where = new Map(others.map((other, index) => [idOf(other), index]));
|
||||
const partners = members.map((member) => where.get(idOf(member)));
|
||||
return {
|
||||
keys: members.map((member, index) =>
|
||||
partners[index] === undefined && isGeneratedId(idOf(member))
|
||||
? String(index)
|
||||
: `[${key}=${idOf(member)}]`,
|
||||
),
|
||||
partners,
|
||||
};
|
||||
return members.map((member) => where.get(idOf(member)));
|
||||
};
|
||||
const partners = {
|
||||
newer: partnersOf(newer, older),
|
||||
older: partnersOf(older, newer),
|
||||
};
|
||||
const idKey = (member: JsonValue) => `[${key}=${idOf(member)}]`;
|
||||
// A kept member is named from its newer version, so both versions key it alike.
|
||||
const newerKeys = newer.map((member, index) => {
|
||||
const partner = partners.newer[index];
|
||||
if (!isGeneratedId(idOf(member))) return idKey(member);
|
||||
return partner === undefined
|
||||
? (nameOf(member, counts.newer, counts.older) ?? String(index))
|
||||
: (keptNameOf(member, older[partner] ?? null, counts) ?? idKey(member));
|
||||
});
|
||||
const olderKeys = older.map((member, index) => {
|
||||
const partner = partners.older[index];
|
||||
if (partner !== undefined) return newerKeys[partner] ?? idKey(member);
|
||||
return isGeneratedId(idOf(member))
|
||||
? (nameOf(member, counts.older, counts.newer) ?? String(index))
|
||||
: idKey(member);
|
||||
});
|
||||
return {
|
||||
newer: { keys: newerKeys, partners: partners.newer },
|
||||
older: { keys: olderKeys, partners: partners.older },
|
||||
idField: key,
|
||||
generated: newer.map(
|
||||
(member, index) =>
|
||||
partners.newer[index] === undefined && isGeneratedId(idOf(member)),
|
||||
),
|
||||
};
|
||||
return { newer: side(newer, older), older: side(older, newer) };
|
||||
};
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
savePositionKeys,
|
||||
} from "./positionalKeys.js";
|
||||
import {
|
||||
childConditions,
|
||||
childCandidates,
|
||||
condition,
|
||||
countUnspelledPairs,
|
||||
holdingCondition,
|
||||
@@ -61,9 +61,11 @@ const pairsOf = (paired: readonly (Attribute & { tier: number })[]) =>
|
||||
* by itself, a name-like key, a worded value or a small integer first; then
|
||||
* two of the best `MAX_PAIRED` of those together; then a coordinate, a long
|
||||
* number or a token by itself, and an id an ODF app numbers anew on each save
|
||||
* (`isSaveNumberedId`); then a child element's text, as Osmo's record
|
||||
* by `[>first_name=Delphine]` (`childConditions`), for an element no
|
||||
* identifier names; last, an id the app generates (`isGeneratedId`), as
|
||||
* (`isSaveNumberedId`); then what its children hold, for an element no
|
||||
* identifier names (`childCandidates`): a child element's text, as Osmo's
|
||||
* record by `[>first_name=Delphine]`, an identifier a child carries, as
|
||||
* Glade's `<child>` by `[>object@id=submit_button]`, then text a child joins
|
||||
* from its own elements; last, an id the app generates (`isGeneratedId`), as
|
||||
* Freeplane's `ID_329023692`, or a GUID in any attribute, as VYM's `uuid`,
|
||||
* after an identifier numbering the members by save position
|
||||
* (`savePositionKeys`), as Parley's `<entry id="6">`, where a child's text
|
||||
@@ -81,11 +83,12 @@ const candidatesOf = (
|
||||
names: Map<string, number>,
|
||||
positional: ReadonlySet<string>,
|
||||
saved: ReadonlySet<string>,
|
||||
json = false,
|
||||
): Candidates | undefined => {
|
||||
if (!isRecord(member)) return undefined;
|
||||
const name = own(member, "name");
|
||||
if (typeof name !== "string" || !selectable(name)) return undefined;
|
||||
const attributes = own(member, "attributes");
|
||||
const attributes = json ? undefined : own(member, "attributes");
|
||||
const usable = (
|
||||
isRecord(attributes) ? Object.entries(attributes) : []
|
||||
).flatMap(([key, value]) =>
|
||||
@@ -97,7 +100,7 @@ const candidatesOf = (
|
||||
: [],
|
||||
);
|
||||
// An id the app generates names a kept member, but no new one another save would key alike, so it ranks last.
|
||||
const children = childConditions(member);
|
||||
const children = json ? [] : childCandidates(member);
|
||||
// An id numbering the members by save position names one after a child's text does.
|
||||
const placed =
|
||||
children.length === 0
|
||||
@@ -426,15 +429,16 @@ const pairElements = (
|
||||
newer: readonly JsonValue[],
|
||||
older: readonly JsonValue[],
|
||||
place?: ListPlace,
|
||||
json = false,
|
||||
): { readonly newer: PairedSide; readonly older: PairedSide } => {
|
||||
const names = new Map<string, number>();
|
||||
const positional = positionalKeys(newer, older);
|
||||
const saved = savePositionKeys(newer, older);
|
||||
const newCandidates = newer.map((member) =>
|
||||
candidatesOf(member, names, positional, saved),
|
||||
candidatesOf(member, names, positional, saved, json),
|
||||
);
|
||||
const oldCandidates = older.map((member) =>
|
||||
candidatesOf(member, names, positional, saved),
|
||||
candidatesOf(member, names, positional, saved, json),
|
||||
);
|
||||
const newCounts = countIds(newCandidates);
|
||||
const oldCounts = countIds(oldCandidates);
|
||||
@@ -531,9 +535,11 @@ const pairElements = (
|
||||
export const pairMembers = (
|
||||
newer: readonly JsonValue[],
|
||||
older: readonly JsonValue[],
|
||||
place?: ListPlace,
|
||||
place: ListPlace | undefined,
|
||||
format: string | null | undefined,
|
||||
): { readonly newer: PairedSide; readonly older: PairedSide } =>
|
||||
pairRecordsById(newer, older) ?? pairElements(newer, older, place);
|
||||
(format === "xml" ? undefined : pairRecordsById(newer, older)) ??
|
||||
pairElements(newer, older, place, format !== "xml");
|
||||
|
||||
/**
|
||||
* The member a pointer names at the end, as its seed version: the other
|
||||
@@ -550,16 +556,18 @@ export const pairedAtSeed = (
|
||||
): JsonValue | undefined => {
|
||||
const list = /^(?<list>.*\/children)\/[^/]+$/u.exec(member)?.groups?.list;
|
||||
const newer =
|
||||
list === undefined ? undefined : resolvePointer(ends.final, list);
|
||||
list === undefined ? undefined : resolvePointer(ends.final, list, "xml");
|
||||
const older =
|
||||
list === undefined ? undefined : resolvePointer(ends.baseline, list);
|
||||
const element = resolvePointer(ends.final, member);
|
||||
list === undefined ? undefined : resolvePointer(ends.baseline, list, "xml");
|
||||
const element = resolvePointer(ends.final, member, "xml");
|
||||
if (list === undefined || !Array.isArray(newer) || !Array.isArray(older))
|
||||
return undefined;
|
||||
const index = newer.indexOf(element ?? null);
|
||||
const partner =
|
||||
index < 0
|
||||
? undefined
|
||||
: pairMembers(newer, older, holdsTagOnly(list)).newer.partners[index];
|
||||
: pairMembers(newer, older, holdsTagOnly(list), "xml").newer.partners[
|
||||
index
|
||||
];
|
||||
return partner === undefined ? undefined : older[partner];
|
||||
};
|
||||
@@ -1,6 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { childConditions } from "./selectorCandidates.js";
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import { childCandidates, childConditions } from "./selectorCandidates.js";
|
||||
|
||||
const element = (
|
||||
name: string,
|
||||
attributes: Record<string, string>,
|
||||
children: JsonValue[] = [],
|
||||
): JsonValue => ({ name, attributes, children });
|
||||
|
||||
describe("childCandidates", () => {
|
||||
it("ranks a child's own text, then an identifier a child carries, then text a child joins from its own elements, and no id an app generates", () => {
|
||||
const child = element("child", {}, [
|
||||
element("object", { class: "GtkButton", id: "submit_button" }, [
|
||||
element("property", { name: "label" }, ["Send"]),
|
||||
]),
|
||||
element("packing", {}, [element("property", {}, ["1"])]),
|
||||
element("note", {}, ["Primary"]),
|
||||
element("layer", { id: "{18eadb5d-04fc-4448-a0f9-1f9a3dedaff7}" }),
|
||||
element("node", { ID: "ID_329023692" }),
|
||||
element("shape", { "draw:id": "id2" }),
|
||||
element("cell", { name: "B2" }),
|
||||
]);
|
||||
|
||||
expect(childCandidates(child)).toEqual([
|
||||
"[>note=Primary]",
|
||||
"[>object@id=submit_button]",
|
||||
"[>cell@name=B2]",
|
||||
"[>object=Send]",
|
||||
"[>packing=1]",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("childConditions", () => {
|
||||
it("stops reading a child's text once it is past what a selector names a member by, however long the text", () => {
|
||||
|
||||
@@ -2,9 +2,11 @@ import type { JsonValue } from "../jsonValue.js";
|
||||
import { isTimestamp } from "./appTask.js";
|
||||
import {
|
||||
isGeneratedId,
|
||||
isGuid,
|
||||
isIdentifyingAttribute,
|
||||
isNameLikeKey,
|
||||
isPlaceholder,
|
||||
isSaveNumberedId,
|
||||
rankAttributes,
|
||||
} from "./attributeRank.js";
|
||||
import { selectorOf, spellCondition, type Condition } from "./viewSelector.js";
|
||||
@@ -85,6 +87,38 @@ const shortText = (element: JsonValue): string | undefined => {
|
||||
return text;
|
||||
};
|
||||
|
||||
/** A tag a `>` condition's step can carry: no `/` or `@`, which a path reads as its own. */
|
||||
const stepTag = (tag: JsonValue | undefined): tag is string =>
|
||||
typeof tag === "string" && tag !== "" && !/[=[\]/@]/u.test(tag);
|
||||
|
||||
/** The element children of a member, none for any other value. */
|
||||
const elementsOf = (member: JsonValue): Record<string, JsonValue>[] => {
|
||||
const children = isRecord(member) ? own(member, "children") : undefined;
|
||||
return (Array.isArray(children) ? children : []).filter(isRecord);
|
||||
};
|
||||
|
||||
/** A child's text a condition could name an element by, and whether the child joins it from elements of its own. */
|
||||
const childTexts = (member: JsonValue) =>
|
||||
elementsOf(member).flatMap((child) => {
|
||||
const key = own(child, "name");
|
||||
const value = stepTag(key) ? shortText(child) : undefined;
|
||||
return stepTag(key) &&
|
||||
value !== undefined &&
|
||||
/[\p{L}\d]/u.test(value) &&
|
||||
!value.includes("][") &&
|
||||
!isTimestamp(value) &&
|
||||
!isGeneratedId(value) &&
|
||||
!isPlaceholder(value) &&
|
||||
!/\d{10}/u.test(value)
|
||||
? [{ key, value, joined: elementsOf(child).length > 0 }]
|
||||
: [];
|
||||
});
|
||||
|
||||
const spelledInside = (found: readonly Attribute[]) =>
|
||||
rankAttributes(found).map(({ key, value }) =>
|
||||
spellCondition({ prefix: ">", key, value }),
|
||||
);
|
||||
|
||||
/**
|
||||
* The `[>CHILD=VALUE]` conditions that could name an element by a child's
|
||||
* text, best first by `attributeTier`: a name-like tag, as Osmo's
|
||||
@@ -92,30 +126,53 @@ const shortText = (element: JsonValue): string | undefined => {
|
||||
* A child whose text is long, blank, a time, or a token or id a program
|
||||
* generates names nothing a later save keeps.
|
||||
*/
|
||||
export const childConditions = (member: JsonValue): string[] => {
|
||||
const children = isRecord(member) ? own(member, "children") : undefined;
|
||||
const found = (Array.isArray(children) ? children : []).flatMap((child) => {
|
||||
const key = isRecord(child) ? own(child, "name") : undefined;
|
||||
const value = typeof key === "string" ? shortText(child) : undefined;
|
||||
return typeof key === "string" &&
|
||||
value !== undefined &&
|
||||
key !== "" &&
|
||||
!/[=[\]]/u.test(key) &&
|
||||
/[\p{L}\d]/u.test(value) &&
|
||||
!value.includes("][") &&
|
||||
!isTimestamp(value) &&
|
||||
!isGeneratedId(value) &&
|
||||
!isPlaceholder(value) &&
|
||||
!/\d{10}/u.test(value)
|
||||
? [{ key, value }]
|
||||
export const childConditions = (member: JsonValue): string[] => [
|
||||
...new Set(spelledInside(childTexts(member))),
|
||||
];
|
||||
|
||||
/**
|
||||
* The `[>CHILD@ATTR=VALUE]` conditions that could name an element by an
|
||||
* identifier a child carries, as a GtkBuilder `<child>` by its
|
||||
* `<object id="submit_button">`: none an app generates or numbers anew as it
|
||||
* saves.
|
||||
*/
|
||||
const childIdConditions = (member: JsonValue): string[] =>
|
||||
elementsOf(member).flatMap((child) => {
|
||||
const tag = own(child, "name");
|
||||
const attributes = own(child, "attributes");
|
||||
return stepTag(tag) && isRecord(attributes)
|
||||
? Object.entries(attributes).flatMap(([key, value]) =>
|
||||
typeof value === "string" &&
|
||||
isIdentifyingAttribute(key) &&
|
||||
!/[=[\]/@]/u.test(key) &&
|
||||
/[\p{L}\d]/u.test(value) &&
|
||||
!value.includes("][") &&
|
||||
!isTimestamp(value) &&
|
||||
!isPlaceholder(value) &&
|
||||
!isGeneratedId(value) &&
|
||||
!isGuid(value) &&
|
||||
!isSaveNumberedId(key, value)
|
||||
? [spellCondition({ prefix: ">", key: `${tag}@${key}`, value })]
|
||||
: [],
|
||||
)
|
||||
: [];
|
||||
});
|
||||
|
||||
/**
|
||||
* The conditions that could name an element by its children, best first: a
|
||||
* child's own text, an identifier a child carries, then text a child joins
|
||||
* from elements of its own, as GtkBuilder's `<packing>` joins its properties
|
||||
* into `TrueTrue1`, which moving the widget changes; each kind ranked as
|
||||
* `childConditions` ranks them.
|
||||
*/
|
||||
export const childCandidates = (member: JsonValue): string[] => {
|
||||
const texts = childTexts(member);
|
||||
return [
|
||||
...new Set(
|
||||
rankAttributes(found).map(({ key, value }) =>
|
||||
spellCondition({ prefix: ">", key, value }),
|
||||
),
|
||||
),
|
||||
...new Set([
|
||||
...spelledInside(texts.filter(({ joined }) => !joined)),
|
||||
...childIdConditions(member),
|
||||
...spelledInside(texts.filter(({ joined }) => joined)),
|
||||
]),
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import { diffStateViews } from "./stateChanges.js";
|
||||
import { diffNamedMembers, diffStateViews } from "./stateChanges.js";
|
||||
import { keyfileView } from "./stateFormats.js";
|
||||
import { resolvePointer } from "./viewPointer.js";
|
||||
import { locatePointer, resolvePointer } from "./viewPointer.js";
|
||||
|
||||
describe("diffStateViews", () => {
|
||||
it("reports added and removed lines as a multiset", () => {
|
||||
@@ -336,7 +336,7 @@ describe("diffStateViews selector lookups", () => {
|
||||
]);
|
||||
for (const key of Object.keys(keyed))
|
||||
expect(
|
||||
resolvePointer({ items: listed }, `/items/${key}`) === undefined,
|
||||
resolvePointer({ items: listed }, `/items/${key}`, "xml") === undefined,
|
||||
).toBe(difference.removed.includes(`/items/${key}`));
|
||||
});
|
||||
|
||||
@@ -369,6 +369,51 @@ describe("diffStateViews selector lookups", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("archive entry format changes", () => {
|
||||
it("names each end by selectors its own declared format can resolve", () => {
|
||||
const node = (title: string) => ({
|
||||
name: "node",
|
||||
attributes: {},
|
||||
children: [{ name: "title", attributes: {}, children: [title] }],
|
||||
});
|
||||
const archive = (format: string, titles: string[]): JsonValue => ({
|
||||
entries: [
|
||||
{
|
||||
name: "data",
|
||||
format,
|
||||
view: {
|
||||
root: { name: "doc", attributes: {}, children: titles.map(node) },
|
||||
},
|
||||
view_error: null,
|
||||
},
|
||||
],
|
||||
entry_count: 1,
|
||||
truncated: false,
|
||||
});
|
||||
for (const [beforeFormat, afterFormat] of [
|
||||
["json", "xml"],
|
||||
["xml", "json"],
|
||||
] as const) {
|
||||
const before = archive(beforeFormat, ["Old", "Keep"]);
|
||||
const after = archive(afterFormat, ["New", "Keep", "More"]);
|
||||
const diff = diffNamedMembers(before, after, "zip");
|
||||
expect(
|
||||
[...diff.added, ...diff.changed].some((pointer) =>
|
||||
pointer.includes("/view/"),
|
||||
),
|
||||
).toBe(true);
|
||||
for (const pointer of diff.removed)
|
||||
expect(locatePointer(before, pointer, "zip").kind, pointer).toBe(
|
||||
"value",
|
||||
);
|
||||
for (const pointer of [...diff.added, ...diff.changed])
|
||||
expect(locatePointer(after, pointer, "zip").kind, pointer).toBe(
|
||||
"value",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffStateViews zip member pairing", () => {
|
||||
it("compares counterparts by the evaluator's pointer rules, a bracketed key against an array as a selector", () => {
|
||||
const keyed = { "[name=x]": 1 };
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { JsonValue } from "../jsonValue.js";
|
||||
import type { DesktopStateFormat } from "./stateFormats.js";
|
||||
import { holdsTagOnly, pairMembers } from "./memberPairing.js";
|
||||
import { memberLookup } from "./viewPointer.js";
|
||||
import { zipLeavesOut } from "./zipView.js";
|
||||
import { zipEntryAt, zipLeavesOut } from "./zipView.js";
|
||||
|
||||
const MAX_LISTED = 100;
|
||||
|
||||
@@ -64,6 +64,10 @@ const ownEntry = (value: JsonValue): string => {
|
||||
interface WalkOptions {
|
||||
readonly keyed?: boolean;
|
||||
readonly outermost?: boolean;
|
||||
readonly format?: (
|
||||
pointer: string,
|
||||
older: boolean,
|
||||
) => string | null | undefined;
|
||||
}
|
||||
|
||||
/** What a walk notes, and how; `older` when it walks the earlier version against the later one. */
|
||||
@@ -77,16 +81,33 @@ interface Noting extends WalkOptions {
|
||||
const keyedMembers = (
|
||||
from: readonly JsonValue[],
|
||||
other: JsonValue | undefined,
|
||||
at: { readonly older: boolean; readonly list: string },
|
||||
at: {
|
||||
readonly older: boolean;
|
||||
readonly list: string;
|
||||
readonly format: string | null | undefined;
|
||||
readonly otherFormat: string | null | undefined;
|
||||
},
|
||||
) => {
|
||||
const others = Array.isArray(other) ? other : [];
|
||||
// Different entry formats cannot share selector semantics. Preserve each
|
||||
// side's resolvable positions instead of inventing cross-format identities.
|
||||
if (
|
||||
Array.isArray(other) &&
|
||||
at.format !== at.otherFormat &&
|
||||
at.format != null &&
|
||||
at.otherFormat != null
|
||||
)
|
||||
return from.map((_item, index) => ({
|
||||
key: String(index),
|
||||
counterpart: others[index],
|
||||
}));
|
||||
const hold = holdsTagOnly(at.list);
|
||||
const pairing = at.older
|
||||
? pairMembers(others, from, hold)
|
||||
: pairMembers(from, others, hold);
|
||||
? pairMembers(others, from, hold, at.format)
|
||||
: pairMembers(from, others, hold, at.format);
|
||||
const older = at.older;
|
||||
const side = older ? pairing.older : pairing.newer;
|
||||
const lookup = memberLookup(other);
|
||||
const lookup = memberLookup(other, [], at.otherFormat);
|
||||
return side.keys.map((key, index) => {
|
||||
const partner = side.partners[index];
|
||||
// Once the array became another type, a member's counterpart is what its pointer names there.
|
||||
@@ -127,19 +148,41 @@ const walk = (
|
||||
const members = keyedMembers(from, other, {
|
||||
older: into.older === true,
|
||||
list: path.segments.map((segment) => `/${segment}`).join(""),
|
||||
format: into.format?.(
|
||||
path.segments.map((segment) => `/${segment}`).join(""),
|
||||
into.older === true,
|
||||
),
|
||||
otherFormat: into.format?.(
|
||||
path.segments.map((segment) => `/${segment}`).join(""),
|
||||
into.older !== true,
|
||||
),
|
||||
});
|
||||
from.forEach((item, index) => {
|
||||
const member = members[index];
|
||||
if (member !== undefined) visit(member.key, item, member.counterpart);
|
||||
});
|
||||
} else if (Array.isArray(from)) {
|
||||
const lookup = memberLookup(other);
|
||||
const lookup = memberLookup(
|
||||
other,
|
||||
[],
|
||||
into.format?.(
|
||||
path.segments.map((segment) => `/${segment}`).join(""),
|
||||
into.older !== true,
|
||||
),
|
||||
);
|
||||
from.forEach((item, index) =>
|
||||
visit(String(index), item, lookup(String(index))),
|
||||
);
|
||||
} else if (isRecord(from)) {
|
||||
// The keys go to the lookup together, so an array compared with an object is searched once per field set.
|
||||
const lookup = memberLookup(other, Object.keys(from));
|
||||
const lookup = memberLookup(
|
||||
other,
|
||||
Object.keys(from),
|
||||
into.format?.(
|
||||
path.segments.map((segment) => `/${segment}`).join(""),
|
||||
into.older !== true,
|
||||
),
|
||||
);
|
||||
for (const [key, item] of Object.entries(from))
|
||||
visit(key, item, lookup(key));
|
||||
}
|
||||
@@ -329,7 +372,7 @@ export const diffStateViews = (
|
||||
): DesktopStateDifference => {
|
||||
const difference =
|
||||
(format === "xml" || format === "json") && options.named === true
|
||||
? namedOrPositional(before, after)
|
||||
? namedOrPositional(before, after, format)
|
||||
: compareViews(format, before, after);
|
||||
// Only REA's own readers write coverage flags; JSON and key files are data.
|
||||
const flagged = format === "text" || format === "xml" || format === "sqlite";
|
||||
@@ -344,8 +387,12 @@ export const diffStateViews = (
|
||||
const namedOrPositional = (
|
||||
before: JsonValue,
|
||||
after: JsonValue,
|
||||
format: "xml" | "json",
|
||||
): DesktopStateDifference => {
|
||||
const named = compareTrees(before, after, { keyed: true });
|
||||
const named = compareTrees(before, after, {
|
||||
keyed: true,
|
||||
format: () => format,
|
||||
});
|
||||
return named.added_count + named.removed_count + named.changed_count > 0
|
||||
? named
|
||||
: compareTrees(before, after);
|
||||
@@ -363,7 +410,10 @@ const compareViews = (
|
||||
case "xml":
|
||||
return compareTrees(before, after);
|
||||
case "zip":
|
||||
return compareTrees(before, after, { keyed: true });
|
||||
return compareTrees(before, after, {
|
||||
keyed: true,
|
||||
format: archiveFormat(after, before),
|
||||
});
|
||||
case "keyfile":
|
||||
case "dconf":
|
||||
return compareMaps("keys", keyEntries(before), keyEntries(after));
|
||||
@@ -387,5 +437,21 @@ const compareViews = (
|
||||
export const diffNamedMembers = (
|
||||
before: JsonValue,
|
||||
after: JsonValue,
|
||||
format: "json" | "xml" | "zip" = "xml",
|
||||
): DesktopStateDifference =>
|
||||
compareTrees(before, after, { keyed: true, outermost: true });
|
||||
compareTrees(before, after, {
|
||||
keyed: true,
|
||||
outermost: true,
|
||||
format: format === "zip" ? archiveFormat(after, before) : () => format,
|
||||
});
|
||||
|
||||
const archiveFormat =
|
||||
(after: JsonValue, before: JsonValue) =>
|
||||
(pointer: string, older: boolean): string | undefined => {
|
||||
const entry = zipEntryAt(older ? before : after, pointer);
|
||||
return typeof entry === "object" &&
|
||||
entry !== null &&
|
||||
typeof entry.format === "string"
|
||||
? entry.format
|
||||
: undefined;
|
||||
};
|
||||
@@ -31,19 +31,22 @@ describe("locatePointer", () => {
|
||||
locatePointer(
|
||||
VIEW,
|
||||
"/root/children/[name=Object][@name=Hole]/attributes/type",
|
||||
"xml",
|
||||
),
|
||||
).toMatchObject({ kind: "value", value: "Part::Cylinder" });
|
||||
expect(
|
||||
locatePointer(VIEW, "/root/children/[@name=Pad]/name"),
|
||||
locatePointer(VIEW, "/root/children/[@name=Pad]/name", "xml"),
|
||||
).toMatchObject({
|
||||
kind: "value",
|
||||
value: "Object",
|
||||
});
|
||||
expect(locatePointer(VIEW, "/entries/[name=a~1b.xml]/size")).toMatchObject({
|
||||
expect(
|
||||
locatePointer(VIEW, "/entries/[name=a~1b.xml]/size", "xml"),
|
||||
).toMatchObject({
|
||||
kind: "value",
|
||||
value: 1,
|
||||
});
|
||||
expect(locatePointer(VIEW, "/root/children/0")).toMatchObject({
|
||||
expect(locatePointer(VIEW, "/root/children/0", "xml")).toMatchObject({
|
||||
kind: "value",
|
||||
value: "text between",
|
||||
});
|
||||
@@ -54,61 +57,68 @@ describe("locatePointer", () => {
|
||||
locatePointer(
|
||||
VIEW,
|
||||
"/root/children/[name=Object][@name=Hole]/attributes/type",
|
||||
"xml",
|
||||
),
|
||||
).toMatchObject({ searched: ["/root/children"], byPosition: [] });
|
||||
expect(locatePointer(VIEW, "/root/children/1/name")).toMatchObject({
|
||||
expect(locatePointer(VIEW, "/root/children/1/name", "xml")).toMatchObject({
|
||||
searched: [],
|
||||
byPosition: ["/root/children"],
|
||||
});
|
||||
expect(locatePointer(VIEW, "/root/children/0")).toMatchObject({
|
||||
expect(locatePointer(VIEW, "/root/children/0", "xml")).toMatchObject({
|
||||
byPosition: [],
|
||||
});
|
||||
expect(
|
||||
locatePointer(VIEW, "/root/children/[@type=Part::Box]"),
|
||||
locatePointer(VIEW, "/root/children/[@type=Part::Box]", "xml"),
|
||||
).toMatchObject({ kind: "unresolved", searched: ["/root/children"] });
|
||||
});
|
||||
|
||||
it("names no value when the last selector matches nothing, and is unresolved otherwise", () => {
|
||||
expect(
|
||||
locatePointer(VIEW, "/root/children/[name=Object][@name=Gone]"),
|
||||
locatePointer(VIEW, "/root/children/[name=Object][@name=Gone]", "xml"),
|
||||
).toMatchObject({
|
||||
kind: "none",
|
||||
reason: "[name=Object][@name=Gone] matches no member",
|
||||
});
|
||||
expect(
|
||||
locatePointer(VIEW, "/root/children/[name=Object][@name=Gone]/name"),
|
||||
locatePointer(
|
||||
VIEW,
|
||||
"/root/children/[name=Object][@name=Gone]/name",
|
||||
"xml",
|
||||
),
|
||||
).toMatchObject({
|
||||
kind: "unresolved",
|
||||
problem: "[name=Object][@name=Gone] matches no member",
|
||||
});
|
||||
expect(
|
||||
locatePointer(VIEW, "/root/children/[@type=Part::Box]"),
|
||||
locatePointer(VIEW, "/root/children/[@type=Part::Box]", "xml"),
|
||||
).toMatchObject({
|
||||
kind: "unresolved",
|
||||
problem: "[@type=Part::Box] matches 2 members, not one",
|
||||
});
|
||||
expect(locatePointer(VIEW, "/root/children/[Object]")).toMatchObject({
|
||||
kind: "unresolved",
|
||||
problem: "[Object] is not a selector of the form [KEY=VALUE]",
|
||||
});
|
||||
expect(locatePointer(VIEW, "/root/children/[=x]")).toMatchObject({
|
||||
expect(locatePointer(VIEW, "/root/children/[Object]", "xml")).toMatchObject(
|
||||
{
|
||||
kind: "unresolved",
|
||||
problem: "[Object] is not a selector of the form [KEY=VALUE]",
|
||||
},
|
||||
);
|
||||
expect(locatePointer(VIEW, "/root/children/[=x]", "xml")).toMatchObject({
|
||||
kind: "unresolved",
|
||||
});
|
||||
});
|
||||
|
||||
it("compares strings only and keeps an ordinary key on an object", () => {
|
||||
expect(locatePointer(VIEW, "/entries/[name=3]")).toMatchObject({
|
||||
expect(locatePointer(VIEW, "/entries/[name=3]", "xml")).toMatchObject({
|
||||
kind: "none",
|
||||
reason: "[name=3] matches no member",
|
||||
});
|
||||
expect(locatePointer({ "[a=b]": 1 }, "/[a=b]")).toMatchObject({
|
||||
expect(locatePointer({ "[a=b]": 1 }, "/[a=b]", "xml")).toMatchObject({
|
||||
kind: "value",
|
||||
value: 1,
|
||||
});
|
||||
expect(locatePointer(VIEW, "/root/children/01")).toMatchObject({
|
||||
expect(locatePointer(VIEW, "/root/children/01", "xml")).toMatchObject({
|
||||
kind: "none",
|
||||
});
|
||||
expect(locatePointer(null, "/x")).toMatchObject({ kind: "none" });
|
||||
expect(locatePointer(null, "/x", "xml")).toMatchObject({ kind: "none" });
|
||||
});
|
||||
|
||||
it("names a member by a child element's text, its whitespace collapsed, alike in locatePointer and memberLookup", () => {
|
||||
@@ -125,8 +135,8 @@ describe("locatePointer", () => {
|
||||
element("group", {}, [element("first", {}, ["Bob"])]),
|
||||
];
|
||||
const named = (token: string) => [
|
||||
locatePointer({ members }, `/members/${token}`),
|
||||
memberLookup(members, [token])(token),
|
||||
locatePointer({ members }, `/members/${token}`, "xml"),
|
||||
memberLookup(members, [token], "xml")(token),
|
||||
];
|
||||
|
||||
expect(named("[>first=Ann Lee]")).toEqual([
|
||||
@@ -150,20 +160,72 @@ describe("locatePointer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("locatePointer and memberLookup by a path inside a member", () => {
|
||||
const named = (
|
||||
members: JsonValue[],
|
||||
format: "json" | "xml",
|
||||
selector: string,
|
||||
) => {
|
||||
const pointer = `/members/${selector.replaceAll("~", "~0").replaceAll("/", "~1")}`;
|
||||
const located = locatePointer({ members }, pointer, format);
|
||||
const looked = memberLookup(members, [selector], format)(selector);
|
||||
return located.kind === "value" && located.value === looked
|
||||
? looked
|
||||
: [located.kind, looked];
|
||||
};
|
||||
it("reads paths in JSON records", () => {
|
||||
const silk = {
|
||||
data: { Title: "The Silk Roads", Year: "2015" },
|
||||
format: "BOOK",
|
||||
};
|
||||
const spqr = { data: { Title: "SPQR", Year: 2015 }, format: "BOOK" };
|
||||
const nested = {
|
||||
data: { Title: { text: "SPQR" }, Year: "1999" },
|
||||
format: "BOOK",
|
||||
};
|
||||
const members: JsonValue[] = [silk, spqr, nested];
|
||||
const find = (selector: string) => named(members, "json", selector);
|
||||
expect(find("[format=BOOK][>data/Title=The Silk Roads]")).toBe(silk);
|
||||
expect(find("[>data/Year=2015]")).toBe(silk);
|
||||
expect(find("[>data/Title/text=SPQR]")).toBe(nested);
|
||||
expect(find("[>format=BOOK][>data/Title=SPQR]")).toBe(spqr);
|
||||
expect(find("[>data/Title=Missing]")).toEqual(["none", undefined]);
|
||||
expect(find("[>data/Title/text/x=SPQR]")).toEqual([
|
||||
"unresolved",
|
||||
undefined,
|
||||
]);
|
||||
expect(find("[>data//Title=SPQR]")).toEqual(["unresolved", undefined]);
|
||||
});
|
||||
it("reads XML child text and attributes", () => {
|
||||
const button = (id: string, label: string) =>
|
||||
element("child", {}, [
|
||||
element("object", { id }, [element("property", {}, [label])]),
|
||||
]);
|
||||
const submit = button("submit_button", "\n Send ");
|
||||
const cancel = button("cancel_button", "Cancel");
|
||||
const find = (selector: string) => named([submit, cancel], "xml", selector);
|
||||
expect(find("[>object@id=submit_button]")).toBe(submit);
|
||||
expect(find("[>object/property=Send]")).toBe(submit);
|
||||
expect(find("[name=child][>object/property=Cancel]")).toBe(cancel);
|
||||
expect(find("[>object@id/property=Cancel]")).toEqual(["none", undefined]);
|
||||
expect(find("[>object/property@id=x]")).toEqual(["none", undefined]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pairMembers", () => {
|
||||
it("keys each member by the most general selector naming it alone, else by position", () => {
|
||||
const children = (VIEW as { root: { children: JsonValue[] } }).root
|
||||
.children;
|
||||
const entries = (VIEW as { entries: JsonValue[] }).entries;
|
||||
|
||||
expect(pairMembers(children, []).newer.keys).toEqual([
|
||||
expect(pairMembers(children, [], undefined, "xml").newer.keys).toEqual([
|
||||
"0",
|
||||
"[name=Object][@name=Plate]",
|
||||
"[name=Object][@name=Hole]",
|
||||
"[name=Object][@name=Pad]",
|
||||
"[name=Note]",
|
||||
]);
|
||||
expect(pairMembers(entries, []).newer.keys).toEqual([
|
||||
expect(pairMembers(entries, [], undefined, "xml").newer.keys).toEqual([
|
||||
"[name=a/b.xml]",
|
||||
"1",
|
||||
"2",
|
||||
@@ -178,6 +240,8 @@ describe("pairMembers", () => {
|
||||
pairMembers(
|
||||
[item({ b: "y" }), item({ a: "x" })],
|
||||
[item({ a: "x", b: "y" })],
|
||||
undefined,
|
||||
"xml",
|
||||
),
|
||||
).toEqual({
|
||||
newer: { keys: ["[name=item][@b=y]", "1"], partners: [0, undefined] },
|
||||
@@ -192,6 +256,8 @@ describe("pairMembers", () => {
|
||||
element("Object", { name: "Hole" }),
|
||||
],
|
||||
[element("Object", { name: "Plate" })],
|
||||
undefined,
|
||||
"xml",
|
||||
);
|
||||
|
||||
expect(pairing).toEqual({
|
||||
@@ -213,6 +279,8 @@ describe("pairMembers", () => {
|
||||
element("item", { color: "red", id: "1" }),
|
||||
element("item", { id: "2" }),
|
||||
],
|
||||
undefined,
|
||||
"xml",
|
||||
);
|
||||
|
||||
const keys = ["[name=item][@id=1]", "[name=item][@id=2]"];
|
||||
@@ -229,7 +297,12 @@ describe("pairMembers", () => {
|
||||
|
||||
const keys = ["[name=g][@inkscape:label=note]", "[name=g][@id=layer]"];
|
||||
expect(
|
||||
pairMembers([note("scale(2)"), layer], [note("scale(1)"), layer]),
|
||||
pairMembers(
|
||||
[note("scale(2)"), layer],
|
||||
[note("scale(1)"), layer],
|
||||
undefined,
|
||||
"xml",
|
||||
),
|
||||
).toEqual({
|
||||
newer: { keys, partners: [0, 1] },
|
||||
older: { keys, partners: [0, 1] },
|
||||
@@ -245,6 +318,8 @@ describe("pairMembers", () => {
|
||||
pairMembers(
|
||||
[plate, object("Hole001", "3")],
|
||||
[plate, object("Hole", "2")],
|
||||
undefined,
|
||||
"xml",
|
||||
),
|
||||
).toEqual({
|
||||
newer: {
|
||||
@@ -259,7 +334,7 @@ describe("pairMembers", () => {
|
||||
});
|
||||
|
||||
it("pairs members no selector names by position", () => {
|
||||
expect(pairMembers(["a", "b", "c"], ["a", "x"])).toEqual({
|
||||
expect(pairMembers(["a", "b", "c"], ["a", "x"], undefined, "xml")).toEqual({
|
||||
newer: { keys: ["0", "1", "2"], partners: [0, 1, undefined] },
|
||||
older: { keys: ["0", "1"], partners: [0, 1] },
|
||||
});
|
||||
@@ -273,6 +348,8 @@ describe("pairMembers", () => {
|
||||
element("Save", { at: "2026-11-04T10:13:00Z", id: "2" }),
|
||||
],
|
||||
[],
|
||||
undefined,
|
||||
"xml",
|
||||
).newer.keys,
|
||||
).toEqual(["[name=Save][@id=1]", "[name=Save][@id=2]"]);
|
||||
});
|
||||
@@ -289,6 +366,8 @@ describe("pairMembers by ranked attributes", () => {
|
||||
const pairing = pairMembers(
|
||||
[later("2", "3"), later("2", "4"), later("3", "3"), later("3", "4")],
|
||||
[cell("3", "4"), cell("2", "4"), cell("2", "3"), cell("3", "3")],
|
||||
undefined,
|
||||
"xml",
|
||||
);
|
||||
|
||||
expect(pairing.newer).toEqual({
|
||||
@@ -317,7 +396,7 @@ describe("pairMembers by ranked attributes", () => {
|
||||
cell("2", "2"),
|
||||
];
|
||||
|
||||
expect(pairMembers(cells, cells).newer.keys).toEqual([
|
||||
expect(pairMembers(cells, cells, undefined, "xml").newer.keys).toEqual([
|
||||
"[name=Cell][@id=x]",
|
||||
// Its pair is the identified cell's too, so position keys it.
|
||||
"1",
|
||||
@@ -326,7 +405,12 @@ describe("pairMembers by ranked attributes", () => {
|
||||
"[name=Cell][@Col=2][@Row=2]",
|
||||
]);
|
||||
// The cell at row 2, column 2 moved to column 3: no longer the same pair.
|
||||
const moved = pairMembers([...cells.slice(0, 4), cell("2", "3")], cells);
|
||||
const moved = pairMembers(
|
||||
[...cells.slice(0, 4), cell("2", "3")],
|
||||
cells,
|
||||
undefined,
|
||||
"xml",
|
||||
);
|
||||
expect(moved.newer.partners[4]).toBeUndefined();
|
||||
expect(moved.newer.keys[4]).toBe("[name=Cell][@Col=3]");
|
||||
});
|
||||
@@ -346,13 +430,13 @@ describe("pairMembers by ranked attributes", () => {
|
||||
const plain = element("Cell", { Row: "1", Col: "1", X: "12.75" });
|
||||
const cells = [plain, styled];
|
||||
|
||||
const { newer } = pairMembers(cells, cells);
|
||||
const { newer } = pairMembers(cells, cells, undefined, "xml");
|
||||
expect(newer).toEqual({
|
||||
keys: ["[name=Cell][@X=12.75]", "[name=Cell][@Font=Sans]"],
|
||||
partners: [0, 1],
|
||||
});
|
||||
for (const [index, key] of newer.keys.entries())
|
||||
expect(locatePointer(cells, `/${key}`)).toMatchObject({
|
||||
expect(locatePointer(cells, `/${key}`, "xml")).toMatchObject({
|
||||
kind: "value",
|
||||
value: cells[index],
|
||||
});
|
||||
@@ -362,7 +446,8 @@ describe("pairMembers by ranked attributes", () => {
|
||||
element("Cell", { Row: "2", Col: "1" }),
|
||||
];
|
||||
expect(
|
||||
pairMembers([plain, ...others], [styled, ...others]).newer.keys[0],
|
||||
pairMembers([plain, ...others], [styled, ...others], undefined, "xml")
|
||||
.newer.keys[0],
|
||||
).toBe("[name=Cell][@X=12.75]");
|
||||
});
|
||||
|
||||
@@ -371,8 +456,12 @@ describe("pairMembers by ranked attributes", () => {
|
||||
element("PAGEOBJECT", { XPOS: x, YPOS: "12.5", ANNAME: name });
|
||||
|
||||
expect(
|
||||
pairMembers([frame("146.83", "Headline"), frame("20.5", "Details")], [])
|
||||
.newer.keys,
|
||||
pairMembers(
|
||||
[frame("146.83", "Headline"), frame("20.5", "Details")],
|
||||
[],
|
||||
undefined,
|
||||
"xml",
|
||||
).newer.keys,
|
||||
).toEqual([
|
||||
"[name=PAGEOBJECT][@ANNAME=Headline]",
|
||||
"[name=PAGEOBJECT][@ANNAME=Details]",
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import { collapseWhitespace, isTimestamp } from "./appTask.js";
|
||||
import { bracketed, selectorOf, type Condition } from "./viewSelector.js";
|
||||
import {
|
||||
bracketed,
|
||||
insideSteps,
|
||||
selectorOf,
|
||||
type Condition,
|
||||
} from "./viewSelector.js";
|
||||
|
||||
/** The arrays a pointer stepped into, each as the pointer to it. */
|
||||
interface ArraySteps {
|
||||
@@ -74,7 +79,7 @@ export const elementText = (element: JsonValue, attribute?: string): string => {
|
||||
return parts.join("");
|
||||
};
|
||||
|
||||
/** The field a condition reads from a member, when it is a string; a child's text is none, since several children may hold one. */
|
||||
/** The field a condition reads from a member, when it is a string; what a path inside it reads is none, since several elements may hold one. */
|
||||
const fieldOf = (member: JsonValue, { prefix, key }: Condition) => {
|
||||
if (!isRecord(member) || prefix === ">") return undefined;
|
||||
const fields = prefix === "@" ? own(member, "attributes") : member;
|
||||
@@ -89,34 +94,74 @@ const textWithoutStamps = (node: JsonValue): string => {
|
||||
return node.children.map((child) => textWithoutStamps(child)).join("");
|
||||
};
|
||||
|
||||
/** Whether an element has a child element named `key` whose text, its whitespace collapsed, is `value`. */
|
||||
const hasChildText = (member: JsonValue, { key, value }: Condition) =>
|
||||
isXmlElement(member) &&
|
||||
member.children.some((child) => {
|
||||
if (!isXmlElement(child) || child.name !== key) return false;
|
||||
// A nested save time is not part of the name. A child that is only a time still matches that time.
|
||||
const plain = collapseWhitespace(textWithoutStamps(child));
|
||||
const text = plain === "" ? collapseWhitespace(elementText(child)) : plain;
|
||||
return text === value;
|
||||
});
|
||||
/** A child's text as a condition reads it: whitespace collapsed, without a nested save time unless it is only a time. */
|
||||
const conditionText = (child: JsonValue): string => {
|
||||
const plain = collapseWhitespace(textWithoutStamps(child));
|
||||
return plain === "" ? collapseWhitespace(elementText(child)) : plain;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an element has a child element named the first step that holds
|
||||
* `value` along the rest: at the last step its text, whitespace collapsed,
|
||||
* or with `NAME@ATTR` its attribute ATTR. Each element on the way is visited once.
|
||||
*/
|
||||
const elementHolds = (
|
||||
element: { readonly children: readonly JsonValue[] },
|
||||
steps: readonly string[],
|
||||
value: string,
|
||||
): boolean => {
|
||||
const [step = "", ...rest] = steps;
|
||||
const at = rest.length === 0 ? step.indexOf("@") : -1;
|
||||
const name = at < 0 ? step : step.slice(0, at);
|
||||
return element.children.some(
|
||||
(child) =>
|
||||
isXmlElement(child) &&
|
||||
child.name === name &&
|
||||
(rest.length > 0
|
||||
? elementHolds(child, rest, value)
|
||||
: at < 0
|
||||
? conditionText(child) === value
|
||||
: own(child.attributes, step.slice(at + 1)) === value),
|
||||
);
|
||||
};
|
||||
|
||||
/** Whether a `>` condition's path inside a member holds its value: in an XML element as `elementHolds` reads it, in a JSON record the string at those keys. */
|
||||
const holdsInside = (
|
||||
member: JsonValue,
|
||||
{ key, value }: Condition,
|
||||
format: string | null | undefined,
|
||||
) => {
|
||||
const steps = insideSteps(key) ?? [];
|
||||
if (format === "xml")
|
||||
return isXmlElement(member) && elementHolds(member, steps, value);
|
||||
let found: JsonValue | undefined = member;
|
||||
for (const step of steps)
|
||||
found = isRecord(found) ? own(found, step) : undefined;
|
||||
return found === value;
|
||||
};
|
||||
|
||||
/** Whether an array member meets every condition; selectors compare strings only. */
|
||||
const selects = (member: JsonValue, conditions: readonly Condition[]) =>
|
||||
const selects = (
|
||||
member: JsonValue,
|
||||
conditions: readonly Condition[],
|
||||
format: string | null | undefined,
|
||||
) =>
|
||||
isRecord(member) &&
|
||||
conditions.every((condition) =>
|
||||
condition.prefix === ">"
|
||||
? hasChildText(member, condition)
|
||||
? holdsInside(member, condition, format)
|
||||
: fieldOf(member, condition) === condition.value,
|
||||
);
|
||||
|
||||
/** Whether a member meets a selector's conditions, or undefined when the string is no selector. */
|
||||
export const selectorTest = (
|
||||
selector: string,
|
||||
format: string | null | undefined,
|
||||
): ((member: JsonValue) => boolean) | undefined => {
|
||||
const conditions = selectorOf(selector);
|
||||
return conditions === undefined
|
||||
? undefined
|
||||
: (member) => selects(member, conditions);
|
||||
: (member) => selects(member, conditions, format);
|
||||
};
|
||||
|
||||
/** One step into an array: by index, or by a selector that must match one member, or none when it is the last. */
|
||||
@@ -124,6 +169,7 @@ const arrayStep = (
|
||||
items: readonly JsonValue[],
|
||||
token: string,
|
||||
last: boolean,
|
||||
format: string | null | undefined,
|
||||
): { readonly member: JsonValue } | Stepped => {
|
||||
if (INDEX.test(token)) {
|
||||
const index = Number(token);
|
||||
@@ -139,7 +185,7 @@ const arrayStep = (
|
||||
problem: `${token} is not a selector of the form [KEY=VALUE]`,
|
||||
}
|
||||
: { kind: "none" };
|
||||
const matched = items.filter((item) => selects(item, conditions));
|
||||
const matched = items.filter((item) => selects(item, conditions, format));
|
||||
if (matched.length === 1) return { member: matched[0] ?? null };
|
||||
if (matched.length > 1)
|
||||
return {
|
||||
@@ -156,17 +202,22 @@ const arrayStep = (
|
||||
* Follow an RFC 6901 pointer through a view, where a segment applied to an
|
||||
* array may also be a selector: `[KEY=VALUE]` one or more times, naming the one
|
||||
* member whose string field KEY equals VALUE, with `@KEY` whose `attributes`
|
||||
* hold it, as REA's XML view keeps them, or with `>KEY` that has a child
|
||||
* element KEY of that text. So `/root/children/[name=Object][@name=Hole]`
|
||||
* is the `Object` element named `Hole`, wherever it sits among its siblings.
|
||||
* hold it, as REA's XML view keeps them, or with `>PATH` that holds it up to
|
||||
* three steps inside: a child element KEY of that text, `KEY/SUB` a grandchild,
|
||||
* `KEY@ATTR` a child's attribute, or in a JSON record the string at those keys.
|
||||
* So `/root/children/[name=Object][@name=Hole]` is the `Object` element named
|
||||
* `Hole`, wherever it sits among its siblings. A step is decoded before it is
|
||||
* read as a selector, so a path's `/` is written `~1`.
|
||||
*/
|
||||
export const locatePointer = (
|
||||
view: JsonValue | null,
|
||||
pointer: string,
|
||||
format: string | null | undefined,
|
||||
): Located => {
|
||||
if (pointer === "")
|
||||
return { kind: "value", value: view, searched: [], byPosition: [] };
|
||||
let current: JsonValue = view;
|
||||
let currentFormat = format;
|
||||
const segments = pointer.slice(1).split("/");
|
||||
const steps = { searched: [] as string[], byPosition: [] as string[] };
|
||||
for (const [position, segment] of segments.entries()) {
|
||||
@@ -179,14 +230,28 @@ export const locatePointer = (
|
||||
const indexed = INDEX.test(token);
|
||||
if (!indexed && selectorOf(token) !== undefined)
|
||||
steps.searched.push(array);
|
||||
const step = arrayStep(current, token, position === segments.length - 1);
|
||||
const step = arrayStep(
|
||||
current,
|
||||
token,
|
||||
position === segments.length - 1,
|
||||
currentFormat,
|
||||
);
|
||||
if ("kind" in step) return { ...step, ...steps };
|
||||
if (indexed && typeof step.member === "object" && step.member !== null)
|
||||
steps.byPosition.push(array);
|
||||
current = step.member;
|
||||
} else if (isRecord(current) && Object.hasOwn(current, token))
|
||||
} else if (isRecord(current) && Object.hasOwn(current, token)) {
|
||||
// The archive entry is metadata until its view is entered.
|
||||
if (
|
||||
format === "zip" &&
|
||||
position === 2 &&
|
||||
segments[0] === "entries" &&
|
||||
token === "view"
|
||||
)
|
||||
currentFormat =
|
||||
typeof current.format === "string" ? current.format : undefined;
|
||||
current = current[token] ?? null;
|
||||
else return { kind: "none", ...steps };
|
||||
} else return { kind: "none", ...steps };
|
||||
}
|
||||
return { kind: "value", value: current, ...steps };
|
||||
};
|
||||
@@ -247,12 +312,13 @@ const selectorGroups = (tokens: Iterable<string>) => {
|
||||
const selectedMembers = (
|
||||
items: readonly JsonValue[],
|
||||
tokens: Iterable<string>,
|
||||
format: string | null | undefined,
|
||||
): ReadonlyMap<string, JsonValue> => {
|
||||
const selected = new Map<string, JsonValue>();
|
||||
const listed = [...tokens];
|
||||
for (const token of listed) {
|
||||
const meets = selectorOf(token)?.some(readsChild)
|
||||
? selectorTest(token)
|
||||
? selectorTest(token, format)
|
||||
: undefined;
|
||||
const matched = meets === undefined ? [] : items.filter(meets);
|
||||
if (matched.length === 1) selected.set(token, matched[0] ?? null);
|
||||
@@ -280,12 +346,13 @@ const selectedMembers = (
|
||||
*/
|
||||
export const memberLookup = (
|
||||
value: JsonValue | undefined,
|
||||
tokens: Iterable<string> = [],
|
||||
tokens: Iterable<string>,
|
||||
format: string | null | undefined,
|
||||
): ((token: string) => JsonValue | undefined) => {
|
||||
if (!Array.isArray(value))
|
||||
return (token) =>
|
||||
isRecord(value) && Object.hasOwn(value, token) ? value[token] : undefined;
|
||||
const selected = selectedMembers(value, tokens);
|
||||
const selected = selectedMembers(value, tokens, format);
|
||||
return (token) =>
|
||||
INDEX.test(token) ? value[Number(token)] : selected.get(token);
|
||||
};
|
||||
@@ -294,7 +361,8 @@ export const memberLookup = (
|
||||
export const resolvePointer = (
|
||||
view: JsonValue | null,
|
||||
pointer: string,
|
||||
format: string | null | undefined,
|
||||
): JsonValue | undefined => {
|
||||
const located = locatePointer(view, pointer);
|
||||
const located = locatePointer(view, pointer, format);
|
||||
return located.kind === "value" ? located.value : undefined;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* One condition of a selector, equal to a value: a member's string field, with
|
||||
* `@` one of its attributes, or with `>` the text of a child element of its own.
|
||||
* `@` one of its attributes, or with `>` what a path of steps inside it holds
|
||||
* (`insideSteps`).
|
||||
*/
|
||||
export interface Condition {
|
||||
readonly prefix: "" | "@" | ">";
|
||||
@@ -8,6 +9,17 @@ export interface Condition {
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
/** The most `/`-separated steps a `>` condition's path takes into a member. */
|
||||
export const MAX_INSIDE_STEPS = 3;
|
||||
|
||||
/** A `>` condition's path as its steps; undefined when one is empty or there are more than `MAX_INSIDE_STEPS`. */
|
||||
export const insideSteps = (key: string): readonly string[] | undefined => {
|
||||
const steps = key.split("/");
|
||||
return steps.length <= MAX_INSIDE_STEPS && !steps.includes("")
|
||||
? steps
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** A condition as a selector spells it, such as `[@Col=3]`. */
|
||||
export const spellCondition = ({ prefix, key, value }: Condition) =>
|
||||
`[${prefix}${key}=${value}]`;
|
||||
@@ -26,7 +38,8 @@ export const selectorOf = (
|
||||
const key = equals < 0 ? "" : part.slice(0, equals);
|
||||
const prefix = key.startsWith("@") ? "@" : key.startsWith(">") ? ">" : "";
|
||||
const name = key.slice(prefix.length);
|
||||
if (name === "") return undefined;
|
||||
if (name === "" || (prefix === ">" && insideSteps(name) === undefined))
|
||||
return undefined;
|
||||
conditions.push({ prefix, key: name, value: part.slice(equals + 1) });
|
||||
}
|
||||
return conditions;
|
||||
|
||||
@@ -138,7 +138,7 @@ export const zipEntryAt = (
|
||||
): Record<string, JsonValue> | "none" | undefined => {
|
||||
const [first, second] = pointer.slice(1).split("/");
|
||||
if (first !== "entries" || second === undefined) return undefined;
|
||||
const entry = locatePointer(view, `/entries/${second}`);
|
||||
const entry = locatePointer(view, `/entries/${second}`, "zip");
|
||||
if (entry.kind !== "value") return "none";
|
||||
return isRecord(entry.value) ? entry.value : undefined;
|
||||
};
|
||||
|
||||
@@ -59,7 +59,7 @@ const element = (id) => {
|
||||
return { name: tag, attributes, children: [] };
|
||||
};
|
||||
const members = [element("1"), element("2")];
|
||||
const { newer, older } = pairMembers(members, members);
|
||||
const { newer, older } = pairMembers(members, members, undefined, "xml");
|
||||
console.log(JSON.stringify({ keys: newer.keys.map((key) => key.replace(tag, "T")), partners: newer.partners, same: older.keys.every((key, index) => key === newer.keys[index]) }));
|
||||
`,
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ ${PYTHON_TASK_EVALUATOR}
|
||||
results = []
|
||||
for case in json.load(sys.stdin):
|
||||
items, pointer = case["items"], case["pointer"]
|
||||
located = _locate(items, pointer)
|
||||
located = _locate(items, pointer, "json")
|
||||
check = {"id": "absent", "kind": "value_absent", "file": "state.json", "pointer": pointer}
|
||||
verdict = evaluate([check], {"state.json": {"format": "json", "view": items, "truncated": False}})[0]
|
||||
results.append([located[0], located[1], verdict["result"]])
|
||||
@@ -41,7 +41,7 @@ print(json.dumps(results))
|
||||
);
|
||||
expect(results).toEqual(
|
||||
cases.map(({ items, pointer }) => {
|
||||
const located = locatePointer(items, pointer);
|
||||
const located = locatePointer(items, pointer, "json");
|
||||
return [
|
||||
located.kind,
|
||||
located.kind === "value" ? located.value : null,
|
||||
|
||||
Reference in new issue
Block a user