@wix/himalaya-cli 0.1172.0 → 0.1174.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-v1/src/extensions.d.ts +13 -3
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-v1/src/products-v3.d.ts +13 -1
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-stores.mjs +10 -3
  4. package/dist/cli.mjs +38 -16
  5. package/dist/preview-assets.generated.bin +0 -0
  6. package/guides/sdk-reference.json +15 -3
  7. package/package.json +2 -2
  8. package/templates/beauty-salon-owner/dev/index.ts +172 -6
  9. package/templates/beauty-salon-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  10. package/templates/beauty-salon-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  11. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  12. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  13. package/templates/community-club-owner/dev/index.ts +257 -8
  14. package/templates/community-club-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  15. package/templates/community-club-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  16. package/templates/community-club-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  17. package/templates/community-club-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  18. package/templates/community-club-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  19. package/templates/events-meetups-owner/dev/index.ts +87 -4
  20. package/templates/events-meetups-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  21. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  22. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  23. package/templates/gym-fitness-owner/dev/index.ts +257 -8
  24. package/templates/gym-fitness-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  25. package/templates/gym-fitness-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  26. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  27. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  28. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  29. package/templates/nonprofit-cause-owner/dev/index.ts +87 -4
  30. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  31. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  32. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  33. package/templates/owner-lite/dev/index.ts +512 -17
  34. package/templates/owner-lite/tier5-src/_shared/owner-editor-value.ts +71 -5
  35. package/templates/owner-lite/tier5-src/_shared/owner-rich-guard.ts +1 -9
  36. package/templates/owner-lite/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  37. package/templates/owner-lite/tier5-src/himi-owner-lite-faq-question-form/index.ts +77 -10
  38. package/templates/owner-lite/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  39. package/templates/owner-lite/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  40. package/templates/podcast-creator-owner/dev/index.ts +257 -8
  41. package/templates/podcast-creator-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  42. package/templates/podcast-creator-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  43. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  44. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  45. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  46. package/templates/portfolio-creative-owner/dev/index.ts +172 -6
  47. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  48. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  49. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  50. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  51. package/templates/restaurant-owner/dev/index.ts +257 -8
  52. package/templates/restaurant-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  53. package/templates/restaurant-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  54. package/templates/restaurant-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  55. package/templates/restaurant-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  56. package/templates/restaurant-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  57. package/templates/retail-store-owner/dev/index.ts +172 -6
  58. package/templates/retail-store-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  59. package/templates/retail-store-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  60. package/templates/retail-store-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  61. package/templates/retail-store-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  62. package/templates/yoga-studio-owner/dev/index.ts +87 -4
  63. package/templates/yoga-studio-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  64. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  65. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  66. package/templates/events-meetups-owner/tier5-src/_shared/owner-rich-guard.ts +0 -35
  67. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-rich-guard.ts +0 -35
  68. package/templates/yoga-studio-owner/tier5-src/_shared/owner-rich-guard.ts +0 -35
@@ -8,7 +8,7 @@
8
8
  // drops nodes the owner never touched).
9
9
  // Inside the platform source this import is a relative path, so node test runs resolve it; an app's
10
10
  // copy imports "@himalaya/wix-rich-content".
11
- import { parseRicosJson, RICH_CONTENT_LIMITS, type RicosDocument } from "@himalaya/wix-rich-content";
11
+ import { parseRicosJson, isPlainRicos, RICH_CONTENT_LIMITS, type RicosDocument } from "@himalaya/wix-rich-content";
12
12
 
13
13
  export type EditorValue =
14
14
  | { kind: "absent" } // leave the stored body untouched
@@ -45,12 +45,78 @@ function overLimits(doc: RicosDocument): boolean {
45
45
  return false;
46
46
  }
47
47
 
48
- /** `raw` is `values[name]`; `seededJson` is the string the form published as the editor's seed. */
49
- export function editorValue(raw: unknown, seededJson: string): EditorValue {
50
- if (typeof raw !== "string" || raw === "") return { kind: "absent" };
51
- if (seededJson !== "" && raw === seededJson) return { kind: "unchanged" };
48
+ const TEXT_ONLY = new Set(["PARAGRAPH", "TEXT", "HEADING", "BULLETED_LIST", "ORDERED_LIST", "LIST_ITEM", "BLOCKQUOTE", "CODE_BLOCK"]);
49
+ /** No visible text and no non-text node (an image, a divider, an embed…): an empty body. */
50
+ export function isEmptyDocument(doc: RicosDocument): boolean {
51
+ const empty = (nodes: RicosDocument["nodes"] | undefined): boolean => (nodes ?? []).every((n) =>
52
+ TEXT_ONLY.has(String(n?.type)) && !String(n.textData?.text ?? "").trim() && empty(n.nodes));
53
+ return empty(doc.nodes);
54
+ }
55
+
56
+ /** A Ricos JSON string checked as every viewer would: the limits first, then the shape. */
57
+ function checkedDocument(raw: string): EditorValue {
52
58
  if (utf8Bytes(raw) > RICH_CONTENT_LIMITS.maxBytes) return { kind: "too_large" };
53
59
  const doc = parseRicosJson(raw);
54
60
  if (!doc) return { kind: "invalid" };
55
61
  return overLimits(doc) ? { kind: "too_large" } : { kind: "doc", doc, json: raw };
56
62
  }
63
+
64
+ /** `raw` is `values[name]`; `seededJson` is the string the form published as the editor's seed. */
65
+ export function editorValue(raw: unknown, seededJson: string): EditorValue {
66
+ if (typeof raw !== "string" || raw === "") return { kind: "absent" };
67
+ if (seededJson !== "" && raw === seededJson) return { kind: "unchanged" };
68
+ const checked = checkedDocument(raw);
69
+ // An empty editor over no body (a record that had none) writes nothing, rather than an empty document.
70
+ if (checked.kind === "doc" && seededJson === "" && isEmptyDocument(checked.doc)) return { kind: "unchanged" };
71
+ return checked;
72
+ }
73
+
74
+ // ── The per-field editor machinery every owner form shares (post body, event / product description,
75
+ // FAQ answer). Each form keeps `<key>Json` (the seed), `<key>Pending` and `<key>Dirty` in its state.
76
+
77
+ /** `<key>Pending`: until this run's load lands, the descriptor hides the editor — it seeds ONCE, so a
78
+ * provisional editor the owner typed into would keep that text over the real document.
79
+ * `<key>Dirty`: the editor's on_change fired since the field was loaded or saved. Once ready the bag
80
+ * ALWAYS holds a document — the engine's own re-serialization of the seed, not byte-equal to it — so
81
+ * only this says the owner edited the field. Every arm fires a pending on_change before it releases a
82
+ * Form submit, so an edit made inside the debounce window is reported before the submit reads it. */
83
+ export interface EditorGate { pending: boolean; dirty: boolean }
84
+
85
+ /** What a live submit does with `values[name]`: only an edit the editor reported counts. */
86
+ export function editorSubmitValue(raw: unknown, seededJson: string, gate: EditorGate): EditorValue {
87
+ return gate.pending || !gate.dirty ? { kind: "absent" } : editorValue(raw, seededJson);
88
+ }
89
+
90
+ /** A queued write's document. A replay runs in a FRESH worker with no editor, so the string is checked
91
+ * again (shape and limits); "absent" when the item carries none. Never "unchanged": it was an edit. */
92
+ export function queuedDocument(raw: unknown): EditorValue {
93
+ return typeof raw === "string" && raw !== "" ? checkedDocument(raw) : { kind: "absent" };
94
+ }
95
+
96
+ /** The editor's on_change: the field is now edited, and a confirmation of an earlier save is stale.
97
+ * No timing window: an arm fires the pending on_change BEFORE it releases a submit, so any on_change
98
+ * that arrives after a save carries an edit that save did not write. */
99
+ export function editorChanged<S extends { status: string; savedMessage: string }>(s: S): Pick<S, "status" | "savedMessage"> {
100
+ return { status: s.status === "saved" ? "idle" : s.status, savedMessage: "" };
101
+ }
102
+
103
+ /** The offline cache's verdict on the stored body (`<key>Protected`): rich unless KNOWN plain. From the
104
+ * document when the form knows it; otherwise plain only when the record was loaded as plain. A bundle
105
+ * rolled back past the editor reads this flag to decide whether its plain field may overwrite the body. */
106
+ export function cachedRichVerdict(doc: RicosDocument | null, knownPlain: boolean): "true" | "false" {
107
+ if (doc) return isPlainRicos(doc) ? "false" : "true";
108
+ return knownPlain ? "false" : "true";
109
+ }
110
+
111
+ /** Cold boot: the runtime lays this service's last persisted snapshot over its declared state, so the
112
+ * editor's first seed would be the document as it was when the screen last ran — and a Save after
113
+ * typing would write it over whatever the record holds now. Blank the hydrated seed and reset the gate
114
+ * before anything renders, in place (no action has run yet). `extra` resets further UI state. */
115
+ export function resetHydratedEditor(svc: unknown, keys: { json: string; pending: string; dirty: string }, pendingAtBoot: boolean, extra: Record<string, unknown> = {}): void {
116
+ const booted = (svc as { getState?: () => Record<string, unknown> | undefined }).getState?.();
117
+ if (!booted || typeof booted !== "object") return;
118
+ if (booted[keys.json]) booted[keys.json] = "";
119
+ booted[keys.pending] = pendingAtBoot;
120
+ booted[keys.dirty] = false;
121
+ Object.assign(booted, extra);
122
+ }
@@ -2,7 +2,7 @@
2
2
  // this file adds the read-only previews the forms show for a locked body, and the FAQ's legacy DraftJS answer.
3
3
  // Inside the platform source this import is a relative path, so node test runs resolve it; an app's copy
4
4
  // imports "@himalaya/wix-rich-content".
5
- import { htmlIsTrivial, isPlainRicos } from "@himalaya/wix-rich-content";
5
+ import { htmlIsTrivial } from "@himalaya/wix-rich-content";
6
6
 
7
7
  export { htmlIsTrivial };
8
8
 
@@ -16,14 +16,6 @@ export function htmlPreviewText(html: string | null | undefined): string {
16
16
  .split("\n").map((l) => l.replace(/\s+/g, " ").trim()).filter(Boolean).join("\n");
17
17
  }
18
18
 
19
- /** True when a plain text field can round-trip this Ricos document: no document styling, and
20
- * only paragraphs of undecorated text whose paragraph data is the wire default (Events v3 sends
21
- * `{ textStyle: { textAlignment: "AUTO" }, indentation: 0 }` on every paragraph). Anything else —
22
- * a heading, list, link, bold run, media node, alignment or indent — is rich, and so is a missing
23
- * document (callers decide what an absent body means). */
24
- export const ricosIsPlain = (doc: { nodes?: unknown; documentStyle?: unknown } | null | undefined): boolean =>
25
- !!doc && isPlainRicos(doc);
26
-
27
19
  /** Preview text for a legacy DraftJS document: its `blocks[].text` joined by a blank line, or ""
28
20
  * when the payload is not DraftJS JSON (never a throw). */
29
21
  export function draftjsText(raw: string | null | undefined): string {
@@ -10,9 +10,9 @@ import { defineService } from "@himalaya/state";
10
10
  import { getEvent, updateEvent, type WixEvent } from "@himalaya/wix-events";
11
11
  import { normalizeEvent } from "../_shared/himi-events.js";
12
12
  import { postBodyText, plainPostBody } from "../_shared/owner-post-content.js";
13
- // Events v3 sends default paragraphData on EVERY paragraph and an empty documentStyle; ricosIsPlain
14
- // reads both as plain.
15
- import { ricosIsPlain } from "../_shared/owner-rich-guard.js";
13
+ import { editorSubmitValue, queuedDocument, editorChanged, cachedRichVerdict, resetHydratedEditor } from "../_shared/owner-editor-value.js";
14
+ import { parseRicosJson } from "@himalaya/wix-rich-content";
15
+ import { ricosUploadService } from "@himalaya/wix-rich-content-editor";
16
16
  import { ownerLoadError, isBridgeUnavailable, isOwnerPreviewDemo, shouldQueueOwnerWrite } from "../_shared/owner-load-error.js";
17
17
  import { DEMO_EVENTS, DEMO_NOTE, DEMO_SAVE_NOTE, demoRefusalPatch } from "../_shared/owner-preview-demo.js";
18
18
  import { stashCopy, copyParam } from "../_shared/himi-copy.js";
@@ -39,14 +39,27 @@ interface EventFormState {
39
39
  id: string;
40
40
  heading: string;
41
41
  title: string;
42
- /** [rico] #3363 — the Events v3 Ricos `description` as text (read with `fields=TEXTS`). While
43
- * `descriptionProtected`, it is a read-only preview. */
42
+ /** The Events v3 Ricos `description` as text (read with `fields=TEXTS`): the plain field's value,
43
+ * the locked preview, and the cached row's mirror. */
44
44
  description: string;
45
- /** The description as loaded (or last saved). A save writes `description` only when the
45
+ /** The description as loaded (or last saved). The plain field writes `description` only when the
46
46
  * submitted value differs from it, so an untouched body is never re-written. */
47
47
  originalDescription: string;
48
- /** A Ricos description with formatting or media the plain field would flatten: it loads locked,
49
- * the descriptor shows a preview + `descriptionNote`, and an edit is refused. */
48
+ /** How THIS record's description is edited:
49
+ * "editor" — the RichContentEditor, seeded with `descriptionJson` (every live event; sample data);
50
+ * "plain" — the plain text field (an offline row cached as plain before the editor existed);
51
+ * "locked" — neither (an offline row cached as rich, with no document to seed the editor). */
52
+ descriptionMode: "editor" | "plain" | "locked";
53
+ /** The Ricos description as a JSON string: the editor's seed ("" when the event has none, and until
54
+ * this run's load lands — see the boot reset below). Re-based to the saved document after a save. */
55
+ descriptionJson: string;
56
+ descriptionFieldHidden: boolean; // the plain text field (bag key `descriptionText`)
57
+ descriptionEditorHidden: boolean; // the RichContentEditor (bag key `description`)
58
+ /** The editor gate (`EditorGate` in _shared/owner-editor-value.ts): edited since load/save, and
59
+ * hidden until this run's load lands. */
60
+ descriptionDirty: boolean;
61
+ descriptionPending: boolean;
62
+ /** Derived from the mode: "locked" shows the preview + `descriptionNote`, and refuses an edit. */
50
63
  descriptionProtected: boolean;
51
64
  descriptionEditable: boolean;
52
65
  descriptionNote: string;
@@ -99,9 +112,14 @@ function navInputId(): string {
99
112
  return typeof input?.id === "string" ? input.id : "";
100
113
  }
101
114
 
115
+ /** What every load that publishes a description resets: the editor may show it, nothing is edited yet. */
116
+ const DESCRIPTION_LOADED = { descriptionPending: false, descriptionDirty: false } as const;
117
+ const docJson = (doc: unknown): string => (doc ? JSON.stringify(doc) : "");
118
+
102
119
  function shape(s: Partial<EventFormState>): EventFormState {
103
120
  const id = typeof s.id === "string" ? s.id : "";
104
121
  const savedId = typeof s.savedId === "string" ? s.savedId : id;
122
+ const mode = s.descriptionMode ?? "editor";
105
123
  return {
106
124
  mode: "edit",
107
125
  id,
@@ -109,9 +127,15 @@ function shape(s: Partial<EventFormState>): EventFormState {
109
127
  title: s.title ?? "",
110
128
  description: s.description ?? "",
111
129
  originalDescription: s.originalDescription ?? s.description ?? "",
112
- descriptionProtected: s.descriptionProtected ?? false,
113
- descriptionEditable: !(s.descriptionProtected ?? false),
114
- descriptionNote: s.descriptionProtected ? copyParam("ownerEventForm.richDescriptionNote", "This description has formatting or media. Edit it in your site dashboard to keep that content intact; the title can be changed here.") : "",
130
+ descriptionMode: mode,
131
+ descriptionJson: s.descriptionJson ?? "",
132
+ descriptionFieldHidden: mode !== "plain",
133
+ descriptionEditorHidden: mode !== "editor",
134
+ descriptionDirty: s.descriptionDirty ?? false,
135
+ descriptionPending: s.descriptionPending ?? false,
136
+ descriptionProtected: mode === "locked",
137
+ descriptionEditable: mode !== "locked",
138
+ descriptionNote: mode === "locked" ? copyParam("ownerEventForm.richDescriptionNote", "This description has formatting or media. Edit it in your site dashboard to keep that content intact; the title can be changed here.") : "",
115
139
  when: s.when ?? "—",
116
140
  location: s.location ?? "—",
117
141
  status: s.status ?? "idle",
@@ -137,11 +161,18 @@ function demoRefusal(s: Parameters<typeof shape>[0]): ReturnType<typeof shape> {
137
161
  return shape({ ...s, ...demoRefusalPatch(copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE)) });
138
162
  }
139
163
 
164
+ // The RichContentEditor's image tool (the editor's upload contract). The component picks and PUTs
165
+ // the bytes; this service mints the signed Wix Media URL with the worker's own wixApi, so no token
166
+ // reaches the page. Registered BEFORE the form's service on purpose: the worker test harnesses treat a
167
+ // bundle's LAST defineService as its service.
168
+ ricosUploadService("himiOwnerLiteEventFormUploads");
169
+
140
170
  const svc = defineService<EventFormState, {
141
171
  load: (s: EventFormState, arg?: unknown) => Promise<EventFormState>;
172
+ descriptionChanged: (s: EventFormState, arg?: unknown) => EventFormState;
142
173
  submit: (s: EventFormState, values?: Record<string, unknown>) => Promise<EventFormState>;
143
174
  }>("himiOwnerLiteEventForm", {
144
- state: shape({ mode: startMode, id: initialInput.id ?? "" }),
175
+ state: shape({ mode: startMode, id: initialInput.id ?? "", descriptionPending: true }),
145
176
  actions: {
146
177
  load: async (s, arg) => {
147
178
  stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
@@ -156,6 +187,7 @@ const svc = defineService<EventFormState, {
156
187
  return shape({
157
188
  ...s, mode, id, heading: copyParam("ownerEventForm.headingEdit", "Edit event"),
158
189
  title: d.title, description: d.description, when: d.dateLine, location: d.location,
190
+ ...DESCRIPTION_LOADED, descriptionMode: "editor", descriptionJson: docJson(plainPostBody(d.description)),
159
191
  status: "idle", error: "", savedMessage: "", demoNote: copyParam("ownerCommon.demoNote", DEMO_NOTE),
160
192
  });
161
193
  }
@@ -182,7 +214,10 @@ const svc = defineService<EventFormState, {
182
214
  const raw = (ev as unknown as { description?: unknown }).description;
183
215
  const doc = raw && typeof raw === "object" && Array.isArray((raw as { nodes?: unknown }).nodes) ? raw as Parameters<typeof postBodyText>[0] : null;
184
216
  const text = doc ? postBodyText(doc) : typeof raw === "string" ? raw.trim() : "";
185
- return { description: text, originalDescription: text, descriptionProtected: doc ? !ricosIsPlain(doc as { nodes?: unknown; documentStyle?: unknown }) : false };
217
+ // Edited in the RichContentEditor, seeded with the event's own document byte for byte (never
218
+ // re-encoded); a legacy string seeds as paragraphs, and none seeds an empty editor.
219
+ return { ...DESCRIPTION_LOADED, description: text, originalDescription: text, descriptionMode: "editor" as const,
220
+ descriptionJson: doc ? JSON.stringify(raw) : text ? docJson(plainPostBody(text)) : "" };
186
221
  })(),
187
222
  when: row.dateLine || "—",
188
223
  location: row.location || "—",
@@ -198,8 +233,11 @@ const svc = defineService<EventFormState, {
198
233
  ...s, mode, id, heading: "Edit event",
199
234
  title: String(cached.title ?? cached.name ?? ""),
200
235
  description: String(cached.description ?? ""),
201
- // An older cached row carries no verdict, so it stays locked: fail closed.
202
- originalDescription: String(cached.description ?? ""), descriptionProtected: cached.descriptionProtected !== "false",
236
+ originalDescription: String(cached.description ?? ""), ...DESCRIPTION_LOADED,
237
+ // A row cached with its document reopens in the editor. An older row has none, so its verdict
238
+ // decides: plain when it was plain, else locked (it carries no verdict either: fail closed).
239
+ ...(cached.descriptionJson ? { descriptionMode: "editor" as const, descriptionJson: String(cached.descriptionJson) }
240
+ : { descriptionMode: cached.descriptionProtected === "false" ? "plain" as const : "locked" as const, descriptionJson: "" }),
203
241
  when: String(cached.when ?? cached.dateLine ?? "—"),
204
242
  location: String(cached.location ?? "—"),
205
243
  status: "idle", error: "", savedMessage: "",
@@ -211,6 +249,7 @@ const svc = defineService<EventFormState, {
211
249
  return shape({
212
250
  ...s, mode, id, heading: copyParam("ownerEventForm.headingEdit", "Edit event"),
213
251
  title: d.title, description: d.description, when: d.dateLine, location: d.location,
252
+ ...DESCRIPTION_LOADED, descriptionMode: "editor", descriptionJson: docJson(plainPostBody(d.description)),
214
253
  status: "idle", error: "", savedMessage: "", demoNote: copyParam("ownerCommon.demoNote", DEMO_NOTE),
215
254
  });
216
255
  }
@@ -218,59 +257,83 @@ const svc = defineService<EventFormState, {
218
257
  return shape({ ...s, mode, id, status: "error", error: ownerLoadError(e) });
219
258
  }
220
259
  },
260
+ // The editor's on_change carries `args.dirty` only; the document stays in the form bag.
261
+ descriptionChanged: (s) => shape({ ...s, descriptionDirty: true, ...editorChanged(s) }),
221
262
  submit: async (s, values) => {
222
263
  if (isOwnerPreviewDemo() || s.demoNote) return demoRefusal(s);
223
264
  const v = values ?? {};
224
265
  const title = String(v.title ?? s.title).trim();
225
- const description = String(v.description ?? s.description).trim();
226
- // A queue replay boots a FRESH worker (empty baseline), so it carries the original verdict.
227
- const descriptionEdited = typeof v.descriptionEdited === "string" ? v.descriptionEdited === "true" : description !== s.originalDescription.trim();
228
- if (descriptionEdited && s.descriptionProtected) return shape({ ...s, status: "error", savedMessage: "", error: copyParam("ownerEventForm.richDescriptionError", "This description has formatting or media. Edit it in your site dashboard to keep that content intact.") });
266
+ // The description, per record mode. A queue replay boots a FRESH worker and says so with
267
+ // `descriptionEdited`: this bundle's editor items add the document as `descriptionJson`; the
268
+ // plain items (and the pre-editor bundle's) carry plain text only.
269
+ const replay = typeof v.descriptionEdited === "string";
270
+ let doc: { nodes: unknown[] } | null = null; // the Ricos description to WRITE; null leaves it as it is
271
+ let docRaw = ""; // the editor's own string (queue item, re-seed, cache)
272
+ let tooLarge = false;
273
+ if (replay) {
274
+ const queued = queuedDocument(v.descriptionJson);
275
+ // A queued document that no longer reads (or is past the limits) writes NOTHING, and says so.
276
+ if (queued.kind === "invalid" || queued.kind === "too_large") return shape({ ...s, status: "error", savedMessage: "", error: copyParam("ownerEventForm.queuedDescriptionError", "The description saved offline could not be read, so nothing was written. Edit it again.") });
277
+ if (queued.kind === "doc") { doc = queued.doc as { nodes: unknown[] }; docRaw = queued.json; }
278
+ else if (v.descriptionEdited === "true") { const t = String(v.description ?? "").trim(); doc = t ? plainPostBody(t) as { nodes: unknown[] } : { nodes: [] }; }
279
+ } else if (s.descriptionMode === "editor") {
280
+ // Only an edit the editor reported counts (EditorGate). Absent (the editor never became ready):
281
+ // save the rest and leave the description as it is.
282
+ const ev = editorSubmitValue(v.description, s.descriptionJson, { pending: s.descriptionPending, dirty: s.descriptionDirty });
283
+ if (ev.kind === "invalid") return shape({ ...s, status: "error", savedMessage: "", error: copyParam("ownerEventForm.editorReadError", "Couldn’t read the description from the editor. Nothing was saved; try again.") });
284
+ if (ev.kind === "doc") { doc = ev.doc as { nodes: unknown[] }; docRaw = ev.json; }
285
+ tooLarge = ev.kind === "too_large";
286
+ } else {
287
+ // Plain or locked: the plain text field, under its own bag key.
288
+ const t = String(v.descriptionText ?? s.description).trim();
289
+ if (t !== s.originalDescription.trim()) {
290
+ if (s.descriptionMode === "locked") return shape({ ...s, status: "error", savedMessage: "", error: copyParam("ownerEventForm.richDescriptionError", "This description has formatting or media. Edit it in your site dashboard to keep that content intact.") });
291
+ doc = t ? plainPostBody(t) as { nodes: unknown[] } : { nodes: [] };
292
+ }
293
+ }
294
+ const description = doc ? postBodyText(doc as never) : s.description; // the plain-text mirror
229
295
  if (!title) return shape({ ...s, status: "error", error: copyParam("ownerEventForm.titleRequired", "Title is required"), savedMessage: "" });
230
296
  if (!s.id) return shape({ ...s, status: "error", error: copyParam("ownerEventForm.errorNoEvent", "No event to edit."), savedMessage: "" });
231
297
 
232
- // updateEvent(id, event) wraps the body as { event } internally, so pass the
233
- // event fields directly. Only the text the form can safely edit is written: the
234
- // description only when edited, and always as a Ricos document (never a string).
235
- // A local structural type, not WixEventWrite: templates compile against the
298
+ // updateEvent(id, event) wraps the body as { event } internally, so pass the event fields
299
+ // directly. Only what the owner edited is written, the description always as a Ricos document
300
+ // (never a string). A local structural type, not WixEventWrite: templates compile against the
236
301
  // PUBLISHED SDK until its next release.
237
- const event: { title: string; description?: { nodes: unknown[] } } = {
238
- title, ...(descriptionEdited ? { description: description ? plainPostBody(description) as { nodes: unknown[] } : { nodes: [] } } : {}),
239
- };
302
+ const event: { title: string; description?: { nodes: unknown[] } } = { title, ...(doc ? { description: doc } : {}) };
240
303
 
241
- // Optimistic local write — the detail/list repaint from cache immediately.
242
- const localRow: DbEntity = { id: s.id, name: title, title, description, descriptionProtected: String(s.descriptionProtected), when: s.when, location: s.location };
243
- await cachePut(localRow);
304
+ // Optimistic local write — the detail/list repaint from cache immediately. The document the record
305
+ // now holds, when known, rides along so an offline reopen seeds the editor with it.
306
+ const loadedJson = s.descriptionMode === "editor" && !s.descriptionPending ? s.descriptionJson : "";
307
+ const heldJson = docRaw || (doc ? JSON.stringify(doc) : loadedJson);
308
+ const heldDoc = doc ?? (loadedJson ? parseRicosJson(loadedJson) : null);
309
+ // Plain only when KNOWN plain: a plain row, or a loaded event with no description. Before load
310
+ // lands nothing is known, so the row stays protected (fail closed).
311
+ const knownPlain = s.descriptionMode === "plain" || (s.descriptionMode === "editor" && !s.descriptionPending && s.descriptionJson === "");
312
+ await cachePut({ id: s.id, name: title, title, description, descriptionProtected: cachedRichVerdict(heldDoc as never, knownPlain), ...(heldJson ? { descriptionJson: heldJson } : {}), when: s.when, location: s.location });
313
+ const savedState = (savedMessage: string) => shape({
314
+ ...s, mode: "edit", id: s.id, heading: copyParam("ownerEventForm.headingEdit", "Edit event"),
315
+ title, description, originalDescription: doc ? description : s.originalDescription,
316
+ ...(docRaw ? { descriptionJson: docRaw } : {}),
317
+ descriptionDirty: doc ? false : s.descriptionDirty,
318
+ status: "saved", savedMessage, savedId: s.id,
319
+ error: tooLarge ? copyParam("ownerEventForm.descriptionTooLarge", "The description is too long to save, so it was left as it was. Your other changes were saved.") : "",
320
+ });
244
321
  try {
245
322
  await updateEvent(s.id, event);
246
- return shape({
247
- ...s,
248
- mode: "edit",
249
- id: s.id,
250
- heading: copyParam("ownerEventForm.headingEdit", "Edit event"),
251
- title,
252
- description,
253
- originalDescription: descriptionEdited ? description : s.originalDescription,
254
- status: "saved",
255
- error: "",
256
- savedMessage: copyParam("ownerEventForm.savedMessage", "Event saved."),
257
- savedId: s.id,
258
- });
323
+ return savedState(copyParam("ownerEventForm.savedMessage", "Event saved."));
259
324
  } catch (e) {
260
325
  if (shouldQueueOwnerWrite(e)) {
261
326
  // Demo / web-preview (no owner bridge; the record came from demo sample
262
327
  // data, marked by demoNote): a queued write could never replay, so answer
263
328
  // with the demo save note instead of the real-offline write-behind.
264
329
  if (s.demoNote) return demoRefusal(s);
265
- // Real device offline: the optimistic cache row already shows; queue the
266
- // write to replay (WriteQueue, idempotent) when connectivity returns.
267
- await queueWrite("tier5.himiOwnerLiteEventForm.submit", { id: s.id, title, description, descriptionEdited: String(descriptionEdited) });
268
- return shape({
269
- ...s, mode: "edit", id: s.id, heading: copyParam("ownerEventForm.headingEdit", "Edit event"),
270
- title, description, originalDescription: descriptionEdited ? description : s.originalDescription, status: "saved", error: "",
271
- savedMessage: copyParam("ownerEventForm.savedOffline", "Saved offline — will sync when you reconnect."),
272
- savedId: s.id,
273
- });
330
+ // Real device offline: the optimistic cache row already shows; queue the write to replay
331
+ // (WriteQueue, idempotent) when connectivity returns. A document rides as `descriptionJson`
332
+ // with descriptionEdited "false", so a bundle rolled back past this change leaves the
333
+ // description rather than flattening it to text.
334
+ await queueWrite("tier5.himiOwnerLiteEventForm.submit", { id: s.id, title, description,
335
+ ...(docRaw ? { descriptionJson: docRaw, descriptionEdited: "false" } : { descriptionEdited: String(doc !== null) }) });
336
+ return savedState(copyParam("ownerEventForm.savedOffline", "Saved offline — will sync when you reconnect."));
274
337
  }
275
338
  return shape({ ...s, status: "error", error: ownerLoadError(e), savedMessage: "" });
276
339
  }
@@ -278,4 +341,7 @@ const svc = defineService<EventFormState, {
278
341
  },
279
342
  });
280
343
 
344
+ // Adopter contract: the editor seeds once, so a cold boot must not seed it with the last run's document.
345
+ resetHydratedEditor(svc, { json: "descriptionJson", pending: "descriptionPending", dirty: "descriptionDirty" }, true);
346
+
281
347
  export { svc };
@@ -2,17 +2,28 @@
2
2
  // otherwise spin forever and leave a create/submit permanently pending.
3
3
  import { defineService } from "@himalaya/state";
4
4
  import { createQuestionEntry, getQuestionEntry, queryCategories, queryQuestionEntries, updateQuestionEntry, type WixCategory, type WixQuestionEntry } from "@himalaya/wix-faq";
5
- import { postBodyText } from "../_shared/owner-post-content.js";
5
+ import { postBodyText, plainPostBody } from "../_shared/owner-post-content.js";
6
6
  import { draftjsText } from "../_shared/owner-rich-guard.js";
7
+ import { editorSubmitValue, isEmptyDocument, resetHydratedEditor } from "../_shared/owner-editor-value.js";
8
+ import { parseRicosJson, type RicosDocument } from "@himalaya/wix-rich-content";
9
+ import { ricosUploadService } from "@himalaya/wix-rich-content-editor";
7
10
 
8
- interface State { id: string; revision: string; mode: "create" | "edit"; heading: string; question: string; answer: string; categoryId: string; statusValue: "VISIBLE" | "HIDDEN"; categoryOptions: Array<{id:string;label:string}>; error: string; conflict: boolean; noConflict: boolean; savedMessage: string; savedId: string; answerFormat: "plain" | "rich" | "draftjs"; answerProtected: boolean; answerEditable: boolean; answerNote: string; }
11
+ interface State { id: string; revision: string; mode: "create" | "edit"; heading: string; question: string; answer: string; categoryId: string; statusValue: "VISIBLE" | "HIDDEN"; categoryOptions: Array<{id:string;label:string}>; error: string; conflict: boolean; noConflict: boolean; savedMessage: string; savedId: string; answerFormat: "plain" | "rich" | "draftjs"; answerProtected: boolean; answerEditable: boolean; answerNote: string;
12
+ /** [rico] #3373 — how the answer is edited: "editor" (an existing Ricos or plain answer, in the RichContentEditor),
13
+ * "plain" (a new question's text field, bag key `answerText`), "locked" (a legacy DraftJS answer, #2134). */
14
+ answerMode: "editor" | "plain" | "locked";
15
+ /** The editor's seed: the answer as a Ricos JSON string ("" until this run's load lands; see the boot reset). */
16
+ answerJson: string; answerFieldHidden: boolean; answerEditorHidden: boolean;
17
+ /** The editor gate (`EditorGate` in _shared/owner-editor-value.ts): edited since load/save; hidden until load lands. */
18
+ answerDirty: boolean; answerPending: boolean; }
9
19
  const input = ((globalThis as { himalaya?: { input?: { id?: string } } }).himalaya?.input) ?? {};
10
20
  const msg = (e: unknown) => String((e as { message?: unknown })?.message ?? e);
11
21
  const isConflict = (e: unknown) => /\b409\b|conflict|revision/i.test(msg(e));
12
22
  const offline = (e: unknown) => /offline|network|connect|bridge|fetch/i.test(msg(e));
13
- // [rico] #3363 — the answer is a one-of (draftjs | richContent | plainText) and this form edits plainText
14
- // only. A rich or DraftJS answer loads LOCKED: `answer` holds its read-only preview, the required check
15
- // is skipped, and plainText stays out of the PATCH and its fieldMask. #3373 replaces the lock with the editor.
23
+ // [rico] #3363 — the answer is a one-of (draftjs | richContent | plainText). [rico] #3373 — an existing Ricos
24
+ // or plain answer is edited in the RichContentEditor and written as `richContent` once edited (a plain answer
25
+ // becomes Ricos, #2134). A legacy DraftJS answer loads LOCKED: `answer` holds its read-only preview, the
26
+ // required check is skipped, and the answer stays out of the PATCH and its fieldMask.
16
27
  const RICH_ANSWER_NOTE = "This answer has formatting or media. Edit it in your site dashboard to keep that content intact; the question, category and visibility can be changed here.";
17
28
  const RICH_ANSWER_ERROR = "This answer has formatting or media. Edit it in your site dashboard to keep that content intact.";
18
29
  // A locked answer whose only content is media/embeds has no preview text. `answer` is also the
@@ -23,8 +34,64 @@ const answerOf = (q: WixQuestionEntry): { answerFormat: State["answerFormat"]; a
23
34
  : q.draftjs ? { answerFormat: "draftjs", answer: draftjsText(q.draftjs) || MEDIA_ONLY_ANSWER } : { answerFormat: "plain", answer: q.plainText ?? "" };
24
35
  async function categories(): Promise<WixCategory[]> { const out: WixCategory[]=[]; let cursor=""; const seen=new Set<string>(); do { const r=await queryCategories({query:{cursorPaging:{limit:100,...(cursor?{cursor}:{})},sort:[{fieldName:"sortOrder",order:"ASC"}]}}); out.push(...(r.categories??[])); const next=r.pagingMetadata?.cursors?.next??"";if(!next||seen.has(next))break;seen.add(next);cursor=next; } while(cursor); return out; }
25
36
  async function appendOrder(): Promise<number> { let max=-1,cursor=""; const seen=new Set<string>(); do { const r=await queryQuestionEntries({contentFormat:"PLAIN_TEXT",query:{cursorPaging:{limit:100,...(cursor?{cursor}:{})}}}); for(const q of r.questionEntries??[]) max=Math.max(max,q.sortOrder??-1); const next=r.pagingMetadata?.cursors?.next??"";if(!next||seen.has(next))break;seen.add(next);cursor=next; } while(cursor); return max+1; }
26
- const shape=(s:Partial<State>):State=>({ id:s.id??input.id??"", revision:s.revision??"", mode:s.mode??((s.id??input.id)?"edit":"create"), heading:s.heading??((s.id??input.id)?"Edit question":"New question"), question:s.question??"", answer:s.answer??"", categoryId:s.categoryId??"", statusValue:s.statusValue??"HIDDEN", categoryOptions:s.categoryOptions??[], error:s.error??"", conflict:s.conflict??false, noConflict:!(s.conflict??false), savedMessage:s.savedMessage??"", savedId:s.savedId??"", ...(() => { const f = s.answerFormat ?? "plain"; return { answerFormat: f, answerProtected: f !== "plain", answerEditable: f === "plain", answerNote: f !== "plain" ? RICH_ANSWER_NOTE : "" }; })() });
27
- const svc=defineService<State,{load:(s:State,a?:unknown)=>Promise<State>;submit:(s:State,v?:Record<string,unknown>)=>Promise<State>}>("himiOwnerLiteFaqQuestionForm",{state:shape({}),actions:{
28
- load:async(s,a)=>{const id=input.id||String((a as {id?:unknown})?.id??s.id);try{const cats=await categories();const options=cats.map(c=>({id:c.id??"",label:c.title?.trim()||"Untitled category"}));if(!id)return shape({...s,mode:"create",categoryOptions:options,statusValue:"HIDDEN"});const q=(await getQuestionEntry(id)).questionEntry;if(!q) return shape({...s,id,error:"Question not found",categoryOptions:options});return shape({...s,id,revision:q.revision??"",mode:"edit",heading:"Edit question",question:q.question??"",...answerOf(q),categoryId:q.categoryId??"",statusValue:q.status==="VISIBLE"?"VISIBLE":"HIDDEN",categoryOptions:options,error:"",conflict:false});}catch(e){return shape({...s,id,error:offline(e)?"Connect to the internet to load this question.":msg(e)});}},
29
- submit:async(s,v)=>{const question=String(v?.question??s.question).trim(),answer=String(v?.answer??s.answer).trim(),categoryId=String(v?.categoryId??s.categoryId),statusValue=String(v?.status??s.statusValue)==="VISIBLE"?"VISIBLE":"HIDDEN";const locked=s.mode==="edit"&&s.answerProtected;if(locked&&answer!==s.answer.trim())return shape({...s,error:RICH_ANSWER_ERROR,savedMessage:""});if(!question||(!locked&&!answer))return shape({...s,error:"Question and answer are required.",savedMessage:""});try{if(s.mode==="edit"){if(!s.revision)return shape({...s,error:"Refresh before saving; this question has no current revision.",conflict:true});const r=await updateQuestionEntry(s.id,{questionEntry:locked?{id:s.id,revision:s.revision,question,categoryId,status:statusValue}:{id:s.id,revision:s.revision,question,plainText:answer,categoryId,status:statusValue},fieldMask:locked?["question","categoryId","status"]:["question","plainText","categoryId","status"]});return shape({...s,revision:r.questionEntry?.revision??s.revision,question,answer:locked?s.answer:answer,categoryId,statusValue,error:"",conflict:false,savedMessage:"Question saved.",savedId:s.id});}const r=await createQuestionEntry({questionEntry:{question,plainText:answer,categoryId,status:statusValue,sortOrder:await appendOrder()}});return shape({...s,id:r.questionEntry?.id??"",revision:r.questionEntry?.revision??"",mode:"edit",heading:"Edit question",question,answer,categoryId,statusValue,error:"",savedMessage:"Question created.",savedId:r.questionEntry?.id??""});}catch(e){if(isConflict(e))return shape({...s,error:"This question changed elsewhere. Refresh before saving again.",conflict:true,savedMessage:""});return shape({...s,error:offline(e)?"Connect to the internet and try again. This change was not queued.":msg(e),savedMessage:""});}}
30
- }}); export {svc,appendOrder};
37
+ const ANSWER_LOADED={answerPending:false,answerDirty:false} as const;
38
+ const shape=(s:Partial<State>):State=>({ id:s.id??input.id??"", revision:s.revision??"", mode:s.mode??((s.id??input.id)?"edit":"create"), heading:s.heading??((s.id??input.id)?"Edit question":"New question"), question:s.question??"", answer:s.answer??"", categoryId:s.categoryId??"", statusValue:s.statusValue??"HIDDEN", categoryOptions:s.categoryOptions??[], error:s.error??"", conflict:s.conflict??false, noConflict:!(s.conflict??false), savedMessage:s.savedMessage??"", savedId:s.savedId??"", ...(() => { const f = s.answerFormat ?? "plain";
39
+ const editing = (s.mode ?? ((s.id ?? input.id) ? "edit" : "create")) === "edit";
40
+ const mode = s.answerMode ?? (f === "draftjs" ? "locked" : editing ? "editor" : "plain");
41
+ return { answerFormat: f, answerMode: mode, answerJson: s.answerJson ?? "", answerFieldHidden: mode !== "plain", answerEditorHidden: mode !== "editor",
42
+ answerDirty: s.answerDirty ?? false, answerPending: s.answerPending ?? false,
43
+ answerProtected: mode === "locked", answerEditable: mode !== "locked", answerNote: mode === "locked" ? RICH_ANSWER_NOTE : "" }; })() });
44
+ // The RichContentEditor's image tool: this service mints the signed Wix Media URL with the worker's own wixApi.
45
+ // Registered BEFORE the form's service: the worker test harnesses treat a bundle's LAST defineService as its service.
46
+ ricosUploadService("himiOwnerLiteFaqQuestionFormUploads");
47
+ const svc=defineService<State,{load:(s:State,a?:unknown)=>Promise<State>;answerChanged:(s:State,a?:unknown)=>State;submit:(s:State,v?:Record<string,unknown>)=>Promise<State>}>("himiOwnerLiteFaqQuestionForm",{state:shape({answerPending:!!input.id}),actions:{
48
+ load:async(s,a)=>{const id=input.id||String((a as {id?:unknown})?.id??s.id);try{const cats=await categories();const options=cats.map(c=>({id:c.id??"",label:c.title?.trim()||"Untitled category"}));if(!id)return shape({...s,...ANSWER_LOADED,mode:"create",answerMode:"plain",answerJson:"",categoryOptions:options,statusValue:"HIDDEN"});const q=(await getQuestionEntry(id)).questionEntry;if(!q) return shape({...s,id,error:"Question not found",categoryOptions:options});return shape({...s,id,revision:q.revision??"",mode:"edit",heading:"Edit question",question:q.question??"",...((a)=>({...a,...ANSWER_LOADED,answerMode:a.answerFormat==="draftjs"?"locked" as const:"editor" as const,
49
+ // The seed is the stored document byte for byte (never re-encoded); a plain answer seeds as paragraphs.
50
+ answerJson:a.answerFormat==="draftjs"?"":q.richContent?JSON.stringify(q.richContent):q.plainText?JSON.stringify(plainPostBody(q.plainText)):""}))(answerOf(q)),categoryId:q.categoryId??"",statusValue:q.status==="VISIBLE"?"VISIBLE":"HIDDEN",categoryOptions:options,error:"",conflict:false});}catch(e){return shape({...s,id,error:offline(e)?"Connect to the internet to load this question.":msg(e)});}},
51
+ // The editor's on_change carries `args.dirty` only; the document stays in the form bag.
52
+ // Any on_change after a save is an edit that save did not write (every arm fires the pending on_change
53
+ // before releasing a submit), so the confirmation goes.
54
+ answerChanged:(s)=>shape({...s,answerDirty:true,savedMessage:""}),
55
+ submit:async(s,v)=>{
56
+ const question=String(v?.question??s.question).trim(),categoryId=String(v?.categoryId??s.categoryId),statusValue=String(v?.status??s.statusValue)==="VISIBLE"?"VISIBLE":"HIDDEN";
57
+ const editing=s.mode==="edit";
58
+ // The answer, per mode. Editor: `values.answer` is the RichContentEditor's document, or absent (the editor
59
+ // never became ready: the answer is left untouched). Only an edit the editor reported counts. Plain / locked:
60
+ // the plain field, under its own bag key `answerText`.
61
+ let doc:RicosDocument|null=null,docRaw="",answer=s.answer,tooLarge=false;
62
+ if(editing&&s.answerMode==="editor"){
63
+ const ev=editorSubmitValue(v?.answer,s.answerJson,{pending:s.answerPending,dirty:s.answerDirty});
64
+ if(ev.kind==="invalid")return shape({...s,error:"Couldn’t read the answer from the editor. Nothing was saved; try again.",savedMessage:""});
65
+ if(ev.kind==="doc"){doc=ev.doc;docRaw=ev.json;answer=postBodyText(ev.doc as never);}
66
+ tooLarge=ev.kind==="too_large";
67
+ // Required, enforced HERE: native Save does not gate on the RichContentEditor. An untouched editor keeps
68
+ // the stored answer, so it must have one: an empty plain answer, or a Ricos one with no text and no
69
+ // media, does not.
70
+ const stored=s.answerJson?parseRicosJson(s.answerJson):null;
71
+ const keptEmpty=!doc&&(s.answerFormat==="plain"?!s.answer.trim():!stored||isEmptyDocument(stored));
72
+ if(!question||(doc&&isEmptyDocument(doc))||keptEmpty)return shape({...s,error:"Question and answer are required.",savedMessage:""});
73
+ }else{
74
+ answer=String(v?.answerText??s.answer).trim();
75
+ const locked=editing&&s.answerMode==="locked";
76
+ if(locked&&answer!==s.answer.trim())return shape({...s,error:RICH_ANSWER_ERROR,savedMessage:""});
77
+ if(!question||(!locked&&!answer))return shape({...s,error:"Question and answer are required.",savedMessage:""});
78
+ }
79
+ try{
80
+ if(editing){
81
+ if(!s.revision)return shape({...s,error:"Refresh before saving; this question has no current revision.",conflict:true});
82
+ const plainEdit=s.answerMode==="plain";
83
+ const questionEntry={id:s.id,revision:s.revision,question,categoryId,status:statusValue,...(doc?{richContent:doc as never}:plainEdit?{plainText:answer}:{})};
84
+ const fieldMask=["question",...(doc?["richContent"]:plainEdit?["plainText"]:[]),"categoryId","status"];
85
+ const r=await updateQuestionEntry(s.id,{questionEntry,fieldMask});
86
+ return shape({...s,revision:r.questionEntry?.revision??s.revision,question,answer,...(doc?{answerFormat:"rich" as const,answerJson:docRaw}:{}),
87
+ answerDirty:doc?false:s.answerDirty,categoryId,statusValue,conflict:false,savedMessage:"Question saved.",savedId:s.id,
88
+ error:tooLarge?"The answer is too long to save, so it was left as it was. Your other changes were saved.":""});
89
+ }
90
+ const r=await createQuestionEntry({questionEntry:{question,plainText:answer,categoryId,status:statusValue,sortOrder:await appendOrder()}});
91
+ return shape({...s,id:r.questionEntry?.id??"",revision:r.questionEntry?.revision??"",mode:"edit",heading:"Edit question",question,answer,categoryId,statusValue,error:"",savedMessage:"Question created.",savedId:r.questionEntry?.id??""});
92
+ }catch(e){if(isConflict(e))return shape({...s,error:"This question changed elsewhere. Refresh before saving again.",conflict:true,savedMessage:""});return shape({...s,error:offline(e)?"Connect to the internet and try again. This change was not queued.":msg(e),savedMessage:""});}
93
+ }
94
+ }});
95
+ // Adopter contract: the editor seeds once, so a cold boot must not seed it with the last run's document.
96
+ resetHydratedEditor(svc,{json:"answerJson",pending:"answerPending",dirty:"answerDirty"},!!input.id);
97
+ export {svc,appendOrder};