Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/cli/code/edit.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Edit, EditError, type ParagraphContentSpec } from "@core";
import { warnPendingRevisions } from "../edit/pending-revisions-warning";
import { parseParagraphOptions } from "../insert/index";
import {
EXIT,
Expand Down Expand Up @@ -119,15 +120,23 @@ export async function run(args: string[]): Promise<number> {
const track = resolveTracked(document, Boolean(parsed.values.track));
const authorFlag = parsed.values.author as string | undefined;

const isRange = /^p\d+-p\d+$/.test(locator);
let resolvedAuthors: string[];
try {
if (/^p\d+-p\d+$/.test(locator)) {
if (isRange) {
const rangeRef = await resolveBlockRangeOrFail(document, locator);
if (typeof rangeRef === "number") return rangeRef;
new Edit(document).range(rangeRef, spec, { authorFlag, track });
resolvedAuthors = new Edit(document).range(rangeRef, spec, {
authorFlag,
track,
});
} else {
const blockRef = await resolveBlockOrFail(document, locator);
if (typeof blockRef === "number") return blockRef;
new Edit(document).paragraph(blockRef, spec, { authorFlag, track });
({ resolvedAuthors } = new Edit(document).paragraph(blockRef, spec, {
authorFlag,
track,
}));
}
} catch (error) {
if (error instanceof EditError) {
Expand All @@ -137,6 +146,10 @@ export async function run(args: string[]): Promise<number> {
}

await document.save(outputPath);
await warnPendingRevisions(locator, resolvedAuthors, {
paragraphLocator: isRange ? null : locator,
tracked: track,
});
await respondAck({
ok: true,
operation: "edit",
Expand Down
40 changes: 32 additions & 8 deletions src/cli/edit/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
locatorToBlockTarget,
MarkdownImport,
MarkdownImportError,
type ParagraphEditResult,
parseCellAt,
parseLocator,
type Run,
Expand Down Expand Up @@ -40,6 +41,7 @@ import {
respond,
respondAck,
} from "../respond";
import { warnPendingRevisions } from "./pending-revisions-warning";
import { parseTabsValue, resolveTabsDirective } from "./tabs";

type RawValues = Record<
Expand Down Expand Up @@ -157,6 +159,12 @@ export async function runEditBatch(
}

await document.save(outputPath);
for (const entry of resolved) {
await warnPendingRevisions(entry.locatorString, entry.resolvedAuthors, {
paragraphLocator: entry.paragraphLocator,
tracked: track,
});
}

await respondAck({
ok: true,
Expand Down Expand Up @@ -272,8 +280,14 @@ type ResolvedEntry = {
span: { start: number; end: number } | null;
/** Bare-cell targets repair Word's mandatory terminal paragraph after apply. */
cellParent?: XmlNode[];
/** The paragraph a span-edit suggestion addresses: `at` itself, or a bare
* cell's sole `tN:rRcC:p0` (a cell locator takes no `:S-E` span). */
paragraphLocator: string;
/** Performs the mutation via the `Edit` lens. Throws `EditError`. */
apply: () => void;
/** Filled by `apply` for a whole-paragraph content edit: the other authors
* whose pending revisions it resolved (warned after the save, issue #17). */
resolvedAuthors: string[];
};

/** Validate one JSONL entry and produce its `apply` closure. Markdown content
Expand Down Expand Up @@ -339,6 +353,7 @@ async function resolveEntry(
let blockRef: BlockReference;
let span: { start: number; end: number } | null;
let cellParent: XmlNode[] | undefined;
let paragraphLocator = at;
if (parseCellAt(at)) {
try {
const resolved = resolveCellParagraphReference(
Expand All @@ -348,6 +363,7 @@ async function resolveEntry(
);
blockRef = resolved.paragraph;
cellParent = resolved.cell.parent;
paragraphLocator = `${resolved.cell.id}:p0`;
} catch (error) {
if (error instanceof CellTargetError) {
throw new EntryError(
Expand Down Expand Up @@ -432,7 +448,9 @@ async function resolveEntry(
node: blockRef.node,
span: null,
...(cellParent ? { cellParent } : {}),
paragraphLocator,
apply,
resolvedAuthors: [],
};
}

Expand Down Expand Up @@ -465,6 +483,7 @@ async function resolveEntry(
| "clear"
| "props";

const resolvedAuthors: string[] = [];
const apply = await buildApply(
document,
raw,
Expand All @@ -473,14 +492,17 @@ async function resolveEntry(
blockRef,
span,
opts,
resolvedAuthors,
);
return {
index,
locatorString: at,
node: blockRef.node,
span,
...(cellParent ? { cellParent } : {}),
paragraphLocator,
apply,
resolvedAuthors,
};
}

Expand All @@ -494,6 +516,7 @@ async function buildApply(
blockRef: BlockReference,
span: { start: number; end: number } | null,
opts: EntryOptions,
resolvedAuthors: string[],
): Promise<() => void> {
const author = typeof raw.author === "string" ? raw.author : opts.authorFlag;
const clearTags =
Expand Down Expand Up @@ -590,18 +613,19 @@ async function buildApply(
opts,
);
const ride = readRunFormatRideAlong(raw, index, kind === "text");
if (!clearTags && !ride) return () => void contentNode();
return () => {
const node = contentNode();
const result = contentNode();
resolvedAuthors.push(...result.resolvedAuthors);
const edit = new Edit(document);
if (clearTags) edit.clearFormattingNode(node, null, clearTags);
if (ride) edit.setFormattingNode(node, null, ride);
if (clearTags) edit.clearFormattingNode(result.node, null, clearTags);
if (ride) edit.setFormattingNode(result.node, null, ride);
};
}

/** Build a closure that applies one whole-paragraph content edit and returns the
* resulting paragraph node. Split out so the combined content+clear path can
* clear that node afterward. */
/** Build a closure that applies one whole-paragraph content edit and returns
* the resulting paragraph node (plus the other authors whose pending revisions
* it resolved). Split out so the combined content+clear path can clear that
* node afterward. */
async function buildWholeParagraphContent(
document: Document,
raw: Record<string, unknown>,
Expand All @@ -610,7 +634,7 @@ async function buildWholeParagraphContent(
blockRef: BlockReference,
author: string | undefined,
opts: EntryOptions,
): Promise<() => XmlNode> {
): Promise<() => ParagraphEditResult> {
const paragraphOptions = readParagraphOptions(document, raw, index);
if (kind === "text") {
// Whole-paragraph empty "text" never reaches here — `resolveEntry`
Expand Down
53 changes: 37 additions & 16 deletions src/cli/edit/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
writeStdout,
} from "../respond";
import { runEditBatch } from "./batch";
import { warnPendingRevisions } from "./pending-revisions-warning";
import {
parseTabsValue,
resolveTabsDirective,
Expand Down Expand Up @@ -304,7 +305,7 @@ export async function run(args: string[]): Promise<number> {
document,
opts.locator,
);
return commitBlockEdit(document, paragraph, opts, cell.parent);
return commitBlockEdit(document, paragraph, opts, cell);
} catch (error) {
if (error instanceof CellTargetError) {
return fail(error.code, error.message, error.hint);
Expand Down Expand Up @@ -434,36 +435,46 @@ async function commitSpanEdit(

/** Single-block edit: paragraph content / props dispatch through the Edit
* lens. The lens handles tracked-vs-untracked, style ensures, and the
* formatting-preservation decision. */
* formatting-preservation decision. `cell` is set for a bare-cell target
* (`tN:rRcC`), whose sole paragraph is the edited block. */
async function commitBlockEdit(
document: Document,
blockRef: BlockReference,
opts: ValidatedOptions,
cellParent?: XmlNode[],
cell?: { id: string; parent: XmlNode[] },
): Promise<number> {
if (opts.dryRun) return respondDryRun(opts);

const track = resolveTracked(document, opts.trackFlag);
let resolvedAuthors: string[] = [];
try {
const edit = new Edit(document);
// The paragraph node the content edit produced — a combined `--clear`
// strips formatting from THIS node afterward (it may be a freshly spliced
// node, not the original blockRef).
let resultNode: XmlNode | null = null;
if (opts.spec.kind === "text" || opts.spec.kind === "runs") {
resultNode = edit.paragraph(blockRef, opts.spec, {
authorFlag: opts.authorFlag,
noFormatting: opts.noFormatting,
track,
});
({ node: resultNode, resolvedAuthors } = edit.paragraph(
blockRef,
opts.spec,
{
authorFlag: opts.authorFlag,
noFormatting: opts.noFormatting,
track,
},
));
} else if (opts.spec.kind === "markdown") {
const resolved = await resolveMarkdownBlocks(document, opts.spec);
if (typeof resolved === "number") return resolved;
resultNode = edit.paragraph(blockRef, resolved, {
authorFlag: opts.authorFlag,
noFormatting: opts.noFormatting,
track,
});
({ node: resultNode, resolvedAuthors } = edit.paragraph(
blockRef,
resolved,
{
authorFlag: opts.authorFlag,
noFormatting: opts.noFormatting,
track,
},
));
} else if (opts.spec.kind === "removeLine") {
// Empty `--text` on a whole paragraph removes the line (cell-safe — a
// table cell's last paragraph is blanked, not deleted). Same path as
Expand Down Expand Up @@ -505,8 +516,12 @@ async function commitBlockEdit(
throw error;
}

if (cellParent) ensureCellEndsWithParagraph(cellParent);
if (cell) ensureCellEndsWithParagraph(cell.parent);
await document.save(opts.outputPath);
await warnPendingRevisions(opts.locator, resolvedAuthors, {
paragraphLocator: cell ? `${cell.id}:p0` : opts.locator,
tracked: track,
});
return emitEditAck(opts);
}

Expand Down Expand Up @@ -566,10 +581,12 @@ async function commitRangeEdit(
return fail("USAGE", "Unsupported edit spec for range locator");
}

const track = resolveTracked(document, opts.trackFlag);
let resolvedAuthors: string[];
try {
new Edit(document).range(rangeRef, spec, {
resolvedAuthors = new Edit(document).range(rangeRef, spec, {
authorFlag: opts.authorFlag,
track: resolveTracked(document, opts.trackFlag),
track,
});
} catch (error) {
if (error instanceof EditError) {
Expand All @@ -579,6 +596,10 @@ async function commitRangeEdit(
}

await document.save(opts.outputPath);
await warnPendingRevisions(opts.locator, resolvedAuthors, {
paragraphLocator: null,
tracked: track,
});
return emitEditAck(opts);
}

Expand Down
26 changes: 26 additions & 0 deletions src/cli/edit/pending-revisions-warning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { writeStderr } from "../respond";

/** Warn that a content edit resolved other authors' pending tracked changes
* without review (issue #17). The edit still succeeds — this is the interim
* behavior until the rebuild keeps them — so the warning names the span-edit
* alternatives that DO keep them pending. `paragraphLocator` is the paragraph
* the `:START-END` example addresses: the edited locator by default, a bare
* cell's sole `tN:rRcC:p0` (a cell locator takes no span), or null for a
* multi-paragraph range. `tracked` carries the edit's own tracking into both
* suggestions, so following one records the change the same way. */
export async function warnPendingRevisions(
locator: string,
authors: string[],
{
paragraphLocator = locator,
tracked,
}: { paragraphLocator?: string | null; tracked: boolean },
): Promise<void> {
if (authors.length === 0) return;
const track = tracked ? " --track" : "";
await writeStderr(
`warning: ${locator} had pending tracked changes by ${authors.join(", ")}; ` +
"replacing whole paragraphs resolved them without review (they can no longer be accepted or rejected as theirs). " +
`To keep them pending, edit only the words that change: \`docx replace FILE OLD NEW${track}\` or \`docx edit FILE --at ${paragraphLocator ?? "pN"}:START-END --text NEW${track}\`.\n`,
);
}
Loading
Loading