///| /// `docx batch [--dry-run]` — authors a NEW document /// from a `docx.batch/1` or `/2` op script (see docs/agent-json-schemas.md; /// `/2` adds `comment` ops). /// Fresh-document-only, fail-closed: the output path must not exist (the /// batch writer creates documents; it cannot preserve the parts of an /// existing file the reader does not model). The built package is checked /// with the structural validator, then written atomically: a unique /// same-directory temp file created with CreateNew, published with a /// no-replace rename so a file that appears after the up-front check is /// never clobbered. async fn run_batch(matches : @argparse.Matches) -> Unit { let output = required_value(matches, "output") let script_path = required_value(matches, "script") if @afs.exists(output) { raise @cli.CliError( "docx: refusing to write '\{output}': it already exists (batch creates NEW documents only; it cannot preserve parts of an existing file)", ) } let script_text = @afs.read_file(script_path).text() @batch.check_script_text(script_text) catch { BatchError(message) => raise @cli.CliError("error: \{message}") } let script_json = @json.parse(script_text) catch { err => raise @cli.CliError("error: the script is not valid JSON: \{repr(err)}") } let script = @batch.parse_batch(script_json) catch { BatchError(message) => raise @cli.CliError("error: \{message}") } // Image paths resolve relative to the current directory. let images : Map[String, Bytes] = Map([]) for path in script.image_paths() { images[path] = @afs.read_file(path).binary() catch { err => { let at = match script.image_reference(path) { Some(at) => " (referenced at \{at})" None => "" } raise @cli.CliError( "docx: could not read image file '\{path}'\{at}: \{err}", ) } } } let body = script.build_body(images) catch { BatchError(message) => raise @cli.CliError("error: \{message}") } // Comment specs re-validate in the writer's constructor (notably the // lexical xsd:dateTime check); BatchComment::at() attributes any // failure to the exact script op. let batch_comments = script.build_comments() catch { BatchError(message) => raise @cli.CliError("error: \{message}") } let comments = [] for comment in batch_comments { let spec = match (comment.reply_to(), comment.from_block(), comment.to_block()) { (Some(parent), _, _) => @docx2html.comment_reply( author=comment.author(), initials?=comment.initials(), date?=comment.date(), reply_to=parent, done?=comment.done(), comment.body(), ) catch { err => raise @cli.CliError( "error: \{comment.at()}: \{docx_error_message(err)}", ) } (None, Some(from), Some(to)) => @docx2html.comment_spec( author=comment.author(), initials?=comment.initials(), date?=comment.date(), from~, to~, done?=comment.done(), comment.body(), ) catch { err => raise @cli.CliError( "error: \{comment.at()}: \{docx_error_message(err)}", ) } _ => // Unreachable: the parser always sets exactly one of the two. raise @cli.CliError( "docx: internal error — \{comment.at()} has neither anchor nor reply target", ) } comments.push(spec) } let batch_footnotes = script.build_footnotes() catch { BatchError(message) => raise @cli.CliError("error: \{message}") } let batch_endnotes = script.build_endnotes() catch { BatchError(message) => raise @cli.CliError("error: \{message}") } let footnotes = [] for note in batch_footnotes { footnotes.push( @docx2html.note_spec(note.body()) catch { err => raise @cli.CliError("error: \{note.at()}: \{docx_error_message(err)}") }, ) } let endnotes = [] for note in batch_endnotes { endnotes.push( @docx2html.note_spec(note.body()) catch { err => raise @cli.CliError("error: \{note.at()}: \{docx_error_message(err)}") }, ) } let batch_headers = script.build_headers() catch { BatchError(message) => raise @cli.CliError("error: \{message}") } let batch_footers = script.build_footers() catch { BatchError(message) => raise @cli.CliError("error: \{message}") } let headers = [] for story in batch_headers { headers.push(story_spec(story)) } let footers = [] for story in batch_footers { footers.push(story_spec(story)) } let bytes = @docx2html.write_docx_with_annotations( body, comments~, footnotes~, endnotes~, headers~, footers~, ) catch { err => raise @cli.CliError(locate_writer_failure(script, body, err)) } // Belt: the writer's output is dual-tier gated in tests, but re-check the // structural tier before anything touches disk. let findings = @opc.validate_docx_package(bytes) if findings.length() > 0 { raise @cli.CliError( "docx: internal error — the built package failed validation: \{findings[0]}", ) } let mut ops_summary = "\{script.op_count()} op(s)" if script.comment_count() > 0 { ops_summary += ", \{script.comment_count()} comment(s)" } let (footnote_count, endnote_count) = script.note_counts() if footnote_count > 0 { ops_summary += ", \{footnote_count} footnote(s)" } if endnote_count > 0 { ops_summary += ", \{endnote_count} endnote(s)" } if matches.flags.get_or_default("dry-run", false) { println("ok (dry run): \{ops_summary}, nothing written") return } write_atomically(output, bytes) println("created \{output} (\{ops_summary})") } ///| fn docx_error_message(err : Error) -> String { match err { @docx2html.Unsupported(message~) | @docx2html.InvalidZip(message~) | @docx2html.InvalidXml(message~) | @docx2html.MissingPart(message~) | @docx2html.ResourceLimit(message~, ..) => message // The docx package's own error type (annotate planning is called // directly, not through the compat bridge). @core.Unsupported(message~) | @core.InvalidZip(message~) | @core.InvalidXml(message~) | @core.MissingPart(message~) | @core.ResourceLimit(message~, ..) => message other => repr(other) } } ///| /// Most script problems are rejected at parse time with an `ops[i]` /// address; the writer only fails on DYNAMIC content (image bytes whose /// header will not parse, table grid geometry). Every CONTENT op builds /// exactly one body element — comment ops build none — so re-running the /// writer over each element alone finds the failing BLOCK, and the /// script's block->op mapping names the right op even when comment ops /// are interleaved. Cheap, error-path-only, and it cannot diverge from /// the writer's real rules because it IS the writer. /// Builds one header/footer writer spec, attributing a refusal to the exact /// script op. fn story_spec( story : @batch.BatchStory, ) -> @docx_impl.HeaderFooterSpec raise @cli.CliError { @docx2html.header_footer_spec(variant=story.variant(), story.body()) catch { err => raise CliError("error: \{story.at()}: \{docx_error_message(err)}") } } ///| fn locate_writer_failure( script : @batch.BatchScript, body : Array[@docx2html.DocumentElement], err : Error, ) -> String { let message = docx_error_message(err) for index, element in body { let failed = try { let _ = @docx2html.write_docx([element]) false } catch { _ => true } if failed { match script.block_op(index) { Some(op) => return "error: ops[\{op}]: \{message}" None => return "error: \{message}" } } } "error: \{message}" } ///| /// Atomic publication: a unique same-directory temp (CreateNew never /// truncates an existing file or follows a symlink into other data) and a /// no-replace rename (a destination created after the up-front existence /// check fails the rename instead of being overwritten). Only the /// EXCLUSIVE-CREATE step retries under another suffix — once a temp is /// ours, any later failure (write, close, rename) cleans it up and /// aborts; retrying those would leak partial files under 99 names. async fn write_atomically(output : String, bytes : Bytes) -> Unit { for attempt in 0..<100 { let temp = "\{output}.tmp-\{attempt}" // sync=Data matches write_file's default: the data is durable before // the rename can publish it. let file = @afs.open(temp, mode=WriteOnly, create_mode=CreateNew, sync=Data) catch { // Only an existing name (leftover or concurrent temp) is worth the // next suffix; EACCES/ENOENT/etc. would fail identically 100 times. @os_error.OSError(_, ..) as os_error if os_error.is_EEXIST() => continue err => raise @cli.CliError( "docx: could not create a temporary file '\{temp}': \{err}", ) } // The temp is ours from here on: any failure *or cancellation* must // close the handle and remove the temp. `catch` no longer observes // cancellation, so the cleanup lives in `errdefer`; each block bounds // it to the region that still owns the handle / the temp path. { errdefer { file.close() @afs.remove(temp) catch { _ => () } } file.write(bytes) catch { err => raise @cli.CliError("docx: could not write '\{temp}': \{err}") } } file.close() { errdefer (@afs.remove(temp) catch { _ => () }) @afs.rename(temp, output, replace=false) catch { err => raise @cli.CliError( "docx: could not write '\{output}' (did it appear concurrently?): \{err}", ) } } return } raise @cli.CliError( "docx: could not create a temporary file next to '\{output}' (100 attempts); remove stale .tmp-* files", ) } ///| fn batch_command() -> @argparse.Command { Command( "batch", about="Author a NEW document from a docx.batch/1 or /2 op script", flags=[ FlagArg( "dry-run", long="dry-run", about="Parse, build, and validate without writing anything.", ), ], positionals=[ PositionArg( "output", about="Output .docx path (must not exist).", num_args=ValueRange(lower=1, upper=1), ), PositionArg( "script", about="Path to the docx.batch/1 or /2 JSON script; image paths inside the script resolve relative to the current directory.", num_args=ValueRange(lower=1, upper=1), ), ], ) }