translate-plan and translate evaluate the translatable rule separately; plan approves what translate refuses #275
Labels
No labels
bug
enhancement
needs-triage
ready-for-agent
ready-for-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/deckle#275
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
GET /api/page/translate-planpromises whatPOST /api/page/translatewill do, but the two evaluate "can this page be translated intolang, and which subpages will be created" separately, and the copies differ. One read-only function ininternal/pageopsmust own that decision, withTranslatePlanreporting from its result andTranslateexecuting it.Current behavior
pageops.TranslatePlanchecks only that nolangtranslation of the page exists, then counts every untranslated page below it.pageops.Translateadditionally refuses the page when a sibling holds its address inlang(409 translation.addressTaken) and skips every subpage whose address is taken or whose source does not parse. It also silently skips a subpage whose rendering fails (l10n.TranslateDocument, front matter, serializer), whichdocs/api.mddoes not list as a skip reason.Reproduced on
mainatbf70703with the sibling collisionTestTranslationSlugCollisionalready builds (a sibling'sdetranslation carryingslug = 'hosting') plus a subpage whose source does not parse:services/hosting/_index.md: plan answers{subpages: 0}with 200, translate answers409 translation.addressTaken.services/_index.mdwith subpages: plan answers{subpages: 2}, translate creates no subpage and lists both inskipped.The handler comment on
handlePageTranslatePlanandTranslatePlan's doc comment ("Its guards are Translate's") both claim the guards are the same. So do the client comments onfetchTranslatePlanandTranslateDialog.Desired behavior
internal/pageopscomputes the outcome of a translate: the page's own refusal (translation exists, address taken, source does not parse) and, with subpages, the list of subpages that will be created and the list that will be skipped with the reason (address taken, source does not parse).Translateruns that function and then performs only the writes (stamps, new files, rollback) from its result. Its documented error set is unchanged except as the third decision says.TranslatePlanruns the same function and reports from its result: it refuses exactly whatTranslaterefuses, andsubpagescounts only pages the translate will actually create.Decisions
{subpages: number}, counting only pages the translate will create. The dialog's only question is "also create the N subpages", and the translate's answer already listsskippedafterwards. Askippedcount or list on the plan is a separate enhancement.422 translation.sourceParse {page, detail}for the page itself, exactly as the translate answers it.l10n.TranslateDocument, the front-matter write or the serializer is not a page state, depends onmode(which the plan does not have), and is undocumented as a skip. It answers the same400/422the page itself would. This keeps the owner function free ofmode,labeland schemas, and makes the code match whatdocs/api.mdalready says.Key interfaces
pageops.TranslatePlan(root, page, lang) (int, error)andpageops.Translate(root, TranslateParams) (TranslateResult, error)keep their signatures; both call the new owner.planTranslation(root, page, lang string, subpages bool), returns the pages to create, the pages to skip, and whatever it already read (parsed sources, stamped bytes) soTranslateneed not read again. Name and exact shape are the implementer's.TranslateResult.Skippedkeeps its meaning: subpages left untranslated because of the two documented reasons.translationAddressTakenbuilds the page tree on every call, so a plan over N subpages builds it N+1 times; building it once inside the owner is welcome but not required.internal/pageops/translation.go(both copies andtranslationBytes),internal/pageops/translation_settings.go(translationAddressTaken),internal/server/translation_pages.go(both handlers),web/src/api/translate.tsandweb/src/components/TranslateDialog.tsx(comments only).Acceptance criteria
lang, and which subpages will be created" is evaluated in exactly one function ininternal/pageops, called by bothTranslatePlanandTranslate.TranslatePlananswers409 translation.addressTakenwith the same args asTranslatewhen a sibling holds the page's address inlang.TranslatePlananswers422 translation.sourceParsewhen the page's own source does not parse, asTranslatedoes.len(Created) - 1of the translate that follows in the same state, including when a subpage's address is taken or its source does not parse.Skipped.GET /api/page/translate-plananswers409 translation.addressTakenand422 translation.sourceParsethrough the route, with the same body the translate route gives.docs/api.md### GET /api/page/translate-plan: the sentence "It neither checks the address … nor parses the source" and the "subpages the translate would then skip … are counted" clause are gone;409 translation.addressTakenand422 translation.sourceParseare in its errors.### POST /api/page/translatedescribes the third decision if its wording needs it.TranslatePlan,fetchTranslatePlanandTranslateDialogcomments no longer claim guards the code does not share; they name the actual set.TestTranslationErrorCodesininternal/pageops/codes_test.gocovers the plan'stranslation.addressTaken.How to verify
go test ./internal/pageops ./internal/serverandgo vet ./...pass.internal/pageops/translation_test.go: the sibling collision fromTestTranslationSlugCollision, asserting the plan's error kind and code equal the translate's; and a subpage set with one address taken and one unparsable source, asserting the plan's count equals the following translate's created subpages (the #280 pattern: executed plan equals dry run).internal/server/translation_pages_test.go: the plan route answers409 translation.addressTakenwith the translate route's args.web/srctests unchanged and passing (npm testinweb/); the wire shape does not change.docs/docs_test.goand the route test ininternal/serverpass after thedocs/api.mdedit.Assumptions to check
web/srccode relies on a translate answer listing a render-failed subpage inskipped(third decision);web/src/api/translate.tsparsesskippedbut the dialog does not display it.mode,labelor schemas, or the plan route cannot call it.Out of scope
skippedcount or list on the plan answer, and any dialog change to show it.translationAddressTaken's rule) or the translate's write and rollback behaviour.scaffold/site/AGENTS.md: no skill walks through translating a page and the manual does not describe this route. The theme is not involved.Blocked by
None - can start immediately
Original report
Found while reviewing #274 (docs/api.md for #264). The docs describe the route as it behaves today; this is the code fix.
Problem
GET /api/page/translate-planpromises whatPOST /api/page/translatewill do, but the two evaluate the "can this page be translated into lang" rule separately, and the copies differ.pageops.TranslatePlan(internal/pageops/translation.go:177) checks only that nolangtranslation of the page exists, then countsuntranslatedBelow.pageops.Translate(translation.go:244) additionally:lang(translationAddressTaken→409 translation.addressTaken);translationBytes), listing it inSkipped.So the dialog's plan can answer
{subpages: 3}and the translate then either fails with409 translation.addressTakenor creates fewer pages than it announced. The handler comment onhandlePageTranslatePlan(internal/server/translation_pages.go:137-141) claims it "refuses exactly what POST /api/page/translate would", which is false.TranslatePlan's own doc comment ("Its guards are Translate's") is false too.This is the same class of defect as the media
folder/delete-planvsfolder/deletemismatch in #273, and the CLAUDE.md rule "every rule has exactly one owner": a predicate (is this page translatable into lang; which subpages would be created) evaluated in two places.Expected
One function owns the decision, and both the plan and the translate call it:
Translatethe pure, read-only part that computes the outcome: the page's own refusal (exists / address taken / source does not parse) and, with subpages, the list of subpages that will be created and the list that will be skipped, with the reason. Something likeplanTranslation(root, page, lang, mode, subpages) (translatePlan, error)ininternal/pageops, returning the pages to create and the pages to skip.Translateruns that function, then does only the writes (stamps, new files, rollback) from its result. Its error set stays as documented.TranslatePlanruns the same function and reports from its result. It then refuses exactly whatTranslaterefuses, andsubpagescounts only pages the translate will actually create.Decisions for the implementer (either is fine, say which in the PR):
{subpages}or grows askippedcount / list so the dialog can say "3 created, 1 skipped". Growing it is the more honest wire shape; keeping it is the smaller change.Translatedoes anyway and the plan currently avoids. The owner function decides this once; the plan should not special-case it.Where the rule is evaluated today (both copies)
internal/pageops/translation.go—TranslatePlan(:177) andTranslate(:244-300, the guards and the per-subpage skip loop).internal/server/translation_pages.gohandlePageTranslatePlan(:142) andhandlePageTranslate.web/src/components/TranslateDialog.tsx(:62-77) consumes{subpages}viaweb/src/api/translate.tsfetchTranslatePlan. It does not re-derive the rule; only the wire shape matters there.Tests
internal/pageops/translation_test.go: add a case where a sibling holds the address inlang— plan must answer the same 409 the translate does; and a case with a subpage whose address is taken or whose source does not parse — plan's count equalslen(Created)of the following translate.internal/server/translation_pages_test.go: the plan route answers409 translation.addressTakenwith the same args as the translate route.internal/pageops/codes_test.goif the plan gains the code.web/src/test/translateApi.test.tsandmockApi.tsonly if the wire shape changes.Docs
docs/api.md### GET /api/page/translate-plan(landed by #274): drop the sentence "It neither checks the address (translation.addressTaken) nor parses the source" and the "subpages the translate would then skip … are counted" clause; add409 translation.addressTakento its errors. The#post-apipagetranslatesection is unchanged unless the plan shape grows.scaffold/site/AGENTS.mddoes not describe this route, so neither changes. The theme is not involved.