@conduction/nextcloud-vue 2.96.0 → 2.98.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 (163) hide show
  1. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +17 -0
  2. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  4. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue.js +1 -1
  5. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +33 -1
  6. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  7. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +383 -359
  8. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  9. package/dist/esm/components/CnChartWidget/CnChartWidget.vue.js +1 -1
  10. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js +106 -2
  11. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js.map +1 -1
  12. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js.map +1 -1
  13. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue.js +9 -0
  14. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue.js.map +1 -0
  15. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue2.js +964 -0
  16. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue2.js.map +1 -0
  17. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue3.js +282 -0
  18. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue3.js.map +1 -0
  19. package/dist/esm/components/CnChecklistRunWidget/index.js +28 -0
  20. package/dist/esm/components/CnChecklistRunWidget/index.js.map +1 -0
  21. package/dist/esm/components/CnConversationThread/CnConversationThread.vue.js +1 -1
  22. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js +17 -3
  23. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js.map +1 -1
  24. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js +61 -43
  25. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js.map +1 -1
  26. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue.js +1 -1
  27. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js +159 -18
  28. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js.map +1 -1
  29. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js +9 -7
  30. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js.map +1 -1
  31. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  32. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +1 -0
  33. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  34. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  35. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +26 -8
  36. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  37. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  38. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +188 -5
  39. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  40. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  41. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  42. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +48 -3
  43. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  44. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +46 -12
  45. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  46. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue.js +1 -1
  47. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js +3 -3
  48. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js.map +1 -1
  49. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js.map +1 -1
  50. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue.js +1 -1
  51. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js +27 -0
  52. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js.map +1 -1
  53. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js +16 -2
  54. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js.map +1 -1
  55. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue.js +1 -1
  56. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +144 -0
  57. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  58. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +488 -399
  59. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  60. package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js +8 -2
  61. package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js.map +1 -1
  62. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +344 -21
  63. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  64. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +29 -7
  65. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  66. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue.js +1 -1
  67. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js +2 -2
  68. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js.map +1 -1
  69. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js +2 -2
  70. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js.map +1 -1
  71. package/dist/esm/components/CnIndexPage/useNamedSource.js +1 -1
  72. package/dist/esm/components/CnIndexPage/useNamedSource.js.map +1 -1
  73. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue.js +1 -1
  74. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +13 -0
  75. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  76. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +5 -1
  77. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  78. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue.js +9 -0
  79. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue.js.map +1 -0
  80. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js +220 -0
  81. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue2.js.map +1 -0
  82. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js +121 -0
  83. package/dist/esm/components/CnOrderedListField/CnOrderedListField.vue3.js.map +1 -0
  84. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue.js +1 -1
  85. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js +83 -9
  86. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js.map +1 -1
  87. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js +80 -11
  88. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js.map +1 -1
  89. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  90. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  91. package/dist/esm/composables/indexSources.js +420 -5
  92. package/dist/esm/composables/indexSources.js.map +1 -1
  93. package/dist/esm/composables/useOrganisationStore.js +115 -0
  94. package/dist/esm/composables/useOrganisationStore.js.map +1 -0
  95. package/dist/esm/index.js +2 -0
  96. package/dist/esm/index.js.map +1 -1
  97. package/dist/esm/l10n/en.json.js +21 -1
  98. package/dist/esm/l10n/en.json.js.map +1 -1
  99. package/dist/esm/l10n/nl.json.js +36 -1
  100. package/dist/esm/l10n/nl.json.js.map +1 -1
  101. package/dist/esm/library-style.js +1 -1
  102. package/dist/esm/nextcloud-vue.css +725 -383
  103. package/dist/esm/utils/builtInFormatters.js +34 -1
  104. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  105. package/dist/esm/utils/libraryWidgetKeys.js +1 -0
  106. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  107. package/dist/esm/utils/matrixCells.js +158 -0
  108. package/dist/esm/utils/matrixCells.js.map +1 -0
  109. package/dist/esm/utils/nextcloudDirectory.js +121 -0
  110. package/dist/esm/utils/nextcloudDirectory.js.map +1 -0
  111. package/dist/esm/utils/queryPresets.js +13 -2
  112. package/dist/esm/utils/queryPresets.js.map +1 -1
  113. package/dist/esm/utils/scopeListLayout.js +37 -1
  114. package/dist/esm/utils/scopeListLayout.js.map +1 -1
  115. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  116. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  117. package/dist/nextcloud-vue.cjs.js +14443 -10519
  118. package/dist/nextcloud-vue.cjs.js.map +2 -2
  119. package/dist/nextcloud-vue.css +725 -383
  120. package/l10n/en.json +21 -1
  121. package/l10n/nl.json +36 -1
  122. package/package.json +2 -2
  123. package/scripts/merge-l10n.js +196 -0
  124. package/src/components/CnAppRoot/CnAppRoot.md +1 -0
  125. package/src/components/CnAppRoot/CnAppRoot.vue +17 -0
  126. package/src/components/CnCellRenderer/CnCellRenderer.vue +57 -1
  127. package/src/components/CnChartWidget/CnChartWidget.md +2 -1
  128. package/src/components/CnChartWidget/CnChartWidget.vue +106 -2
  129. package/src/components/CnChecklistRunWidget/CnChecklistRunWidget.vue +1269 -0
  130. package/src/components/CnChecklistRunWidget/index.js +27 -0
  131. package/src/components/CnConversationThread/CnConversationThread.md +12 -0
  132. package/src/components/CnConversationThread/CnConversationThread.vue +30 -4
  133. package/src/components/CnConversationThread/CnConversationWidget.vue +167 -24
  134. package/src/components/CnDashboardPage/CnDashboardPage.vue +1 -0
  135. package/src/components/CnDataTable/CnDataTable.vue +26 -8
  136. package/src/components/CnDetailPage/CnDetailPage.md +48 -0
  137. package/src/components/CnDetailPage/CnDetailPage.vue +188 -5
  138. package/src/components/CnFactsWidget/CnFactsWidget.vue +114 -3
  139. package/src/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue +3 -3
  140. package/src/components/CnFeaturesBoard/CnFeaturesBoard.vue +38 -2
  141. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +508 -236
  142. package/src/components/CnFilesBrowser/filesBrowserColumns.js +8 -2
  143. package/src/components/CnIndexPage/CnIndexPage.md +28 -0
  144. package/src/components/CnIndexPage/CnIndexPage.vue +367 -27
  145. package/src/components/CnIndexPage/CnIndexPageWidgets.vue +13 -4
  146. package/src/components/CnIndexPage/useNamedSource.js +1 -1
  147. package/src/components/CnObjectSidebar/CnFilesTab.vue +17 -0
  148. package/src/components/CnOrderedListField/CnOrderedListField.vue +386 -0
  149. package/src/components/CnOrderedListField/index.js +7 -0
  150. package/src/components/CnUserSettingsDialog/CnUserSettingsDialog.vue +139 -10
  151. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  152. package/src/components/index.js +2 -0
  153. package/src/composables/indexSources.js +419 -4
  154. package/src/composables/useOrganisationStore.js +111 -0
  155. package/src/index.js +2 -0
  156. package/src/schemas/app-manifest-v2.schema.json +42 -5
  157. package/src/utils/builtInFormatters.js +33 -0
  158. package/src/utils/libraryWidgetKeys.js +1 -0
  159. package/src/utils/matrixCells.js +154 -0
  160. package/src/utils/nextcloudDirectory.js +117 -0
  161. package/src/utils/queryPresets.js +13 -2
  162. package/src/utils/scopeListLayout.js +37 -1
  163. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -0,0 +1,964 @@
1
+ import axios__default from '@nextcloud/axios';
2
+ import { translate } from '@nextcloud/l10n';
3
+ import { generateUrl } from '@nextcloud/router';
4
+ import { NcButton, NcLoadingIcon } from '@nextcloud/vue';
5
+ import { uploadHeldFiles } from '../../composables/useHeldFileUpload.js';
6
+ import { dispatchAction } from '../../utils/actionsDispatcher.js';
7
+ import { fetchReferenceObjects } from '../../utils/boardReferenceColumns.js';
8
+ import { readPath } from '../../utils/readPath.js';
9
+ import { resolveFilterTokens, hasUnresolvedTokens, resolveDeepTokens } from '../../utils/resolveFilterTokens.js';
10
+
11
+ const OBJECT_API = '/apps/openregister/api/objects';
12
+
13
+ let instanceCounter = 0;
14
+
15
+ /**
16
+ * The choices a question type offers, before translation.
17
+ *
18
+ * @type {Record<string, Array<{value: string, label: string}>>}
19
+ */
20
+ const TYPE_CHOICES = Object.freeze({
21
+ yes_no: [{ key: 'yes', label: 'Yes' }, { key: 'no', label: 'No' }],
22
+ yes_no_na: [{ key: 'yes', label: 'Yes' }, { key: 'no', label: 'No' }, { key: 'na', label: 'Not applicable' }],
23
+ });
24
+
25
+ /** The kinds of question the widget draws. */
26
+ const KINDS = Object.freeze(['yes_no', 'yes_no_na', 'choice', 'text', 'number', 'photo']);
27
+
28
+ /**
29
+ * Sort by a numeric field, lowest first, keeping the given order for ties and
30
+ * for entries without the field.
31
+ *
32
+ * @param {Array<object>} list The entries.
33
+ * @param {string} field The field.
34
+ * @return {Array<object>} A sorted copy.
35
+ */
36
+ function byOrder(list, field) {
37
+ if (!field) {
38
+ return [...list]
39
+ }
40
+ const rank = (entry) => {
41
+ const n = Number(readPath(entry, field));
42
+ return Number.isFinite(n) ? n : Infinity
43
+ };
44
+ return list.map((entry, index) => ({ entry, index })).sort((a, b) => (rank(a.entry) - rank(b.entry)) || (a.index - b.index)).map(({ entry }) => entry)
45
+ }
46
+
47
+ /**
48
+ * A register or schema as the object API path takes it.
49
+ *
50
+ * @param {string|object} value A slug, or a register/schema object.
51
+ * @return {string} The slug or id.
52
+ */
53
+ function slugOf(value) {
54
+ if (value && typeof value === 'object') {
55
+ return String(value.slug ?? value.id ?? '')
56
+ }
57
+ return value ? String(value) : ''
58
+ }
59
+
60
+ /**
61
+ * The id of an object, wherever OpenRegister carries it.
62
+ *
63
+ * @param {object|null} object The object.
64
+ * @return {string} The id, or ''.
65
+ */
66
+ function idOf(object) {
67
+ return String(object?.id ?? object?.['@self']?.id ?? object?.uuid ?? '')
68
+ }
69
+
70
+ /**
71
+ * CnChecklistRunWidget — the `checklist-run` widget: a checklist answered a
72
+ * page of questions at a time (board DqInspectie, the middle phone frame).
73
+ *
74
+ * The questions are records of a register, listed with a token-resolved
75
+ * filter. Each page shows `pageSize` questions (one by default) as radio
76
+ * choices or a text field, an optional note, and photos for the first
77
+ * question of the page, then Previous and Next. The answers of a page are
78
+ * saved when the user moves on.
79
+ *
80
+ * Answers are stored one record per question through the OpenRegister object
81
+ * API, under the reader's own rights: an existing answer is updated, a new
82
+ * one created with the `answers.create` fields. Photos go up through the
83
+ * object's own file upload (`uploadHeldFiles`) and are attached to the
84
+ * answer's `photosField`. An app that stores answers its own way names a
85
+ * `saveAction` instead; it is dispatched per answer with the answer as its
86
+ * last argument, and photos are not uploaded then.
87
+ *
88
+ * ```json
89
+ * {
90
+ * "type": "checklist-run",
91
+ * "content": {
92
+ * "questions": { "register": "app", "schema": "question", "filter": { "checklist": "@object.checklist" }, "orderField": "position" },
93
+ * "fields": { "text": "text", "type": "type", "options": "options", "required": "required", "help": "help" },
94
+ * "answers": {
95
+ * "register": "app", "schema": "answer", "filter": { "run": "@objectId" },
96
+ * "questionField": "question", "valueField": "answer", "noteField": "note", "photosField": "photos",
97
+ * "create": { "run": "@objectId" }
98
+ * },
99
+ * "pageSize": 1,
100
+ * "caption": "{title} version {version}"
101
+ * }
102
+ * }
103
+ * ```
104
+ *
105
+ * The questions and answers can also live inside the open record:
106
+ * `questionsPath` (with `itemsKey` for groups, both sorted by `orderField`)
107
+ * reads the questions off it, and `answersPath` writes the answers into an
108
+ * array on it, one entry per question keyed by `answerKey`, by updating the
109
+ * record through the object API. Photos then go to the record's own files
110
+ * (`recordPhotosField`) and their references into the answer.
111
+ *
112
+ * A question can require a photo: `fields.photoRequired` names its rule,
113
+ * `photoRequiredValues` maps the stored values onto `never`, `always` or
114
+ * `{ whenAnswer }`, and the page holds until the photo is there. On a page
115
+ * with no bound record, `record: { register, schema, filter, sort }` picks
116
+ * the open record: the first match, under the reader's rights.
117
+ *
118
+ * Question kinds: `yes_no`, `yes_no_na`, `choice` (the question's `options`,
119
+ * a list of strings or `{ value, label }`), `number`, `photo` and `text`.
120
+ * `typeMap` maps the app's type values onto them, `choiceValues` the stored
121
+ * values of yes / no / not applicable.
122
+ * Keyboard: the choices are native radio buttons in a fieldset whose legend is
123
+ * the question; on a new page focus moves to the position line and a polite
124
+ * live region reads it.
125
+ */
126
+ var script = {
127
+ name: 'CnChecklistRunWidget',
128
+
129
+ components: { NcButton, NcLoadingIcon },
130
+
131
+ inject: {
132
+ /** The detail page's object context (`{ objectId, object }`), for tokens. */
133
+ cnObjectContext: { default: null },
134
+ /** The page's workspace values, for `@workspace.<key>` tokens. */
135
+ cnWorkspaceContext: { default: null },
136
+ /** The host's translate, for manifest texts and question texts. */
137
+ cnTranslate: { default: () => (key) => key },
138
+ /** The page's pre-bound action dispatcher, for `saveAction` / `finishAction`. */
139
+ cnDispatchAction: { default: null },
140
+ },
141
+
142
+ props: {
143
+ /**
144
+ * The widget config: `{ questions | questionsPath, itemsKey?,
145
+ * groupLabelField?, orderField?, fields?, typeMap?, choiceValues?,
146
+ * answers | answersPath, answerKey?, answerFields?, recordPhotosField?,
147
+ * record?, saveAction?, finishAction?, pageSize?, allowNote?,
148
+ * allowPhotos?, caption?, emptyText? }`, as in the component
149
+ * description and docs/components/cn-checklist-run-widget.md.
150
+ *
151
+ * @type {object}
152
+ */
153
+ content: {
154
+ type: Object,
155
+ default: () => ({}),
156
+ },
157
+
158
+ /** The bound record's id, for `@objectId`. */
159
+ objectId: {
160
+ type: [String, Number],
161
+ default: '',
162
+ },
163
+
164
+ /** The bound record, for `@object.<field>`, the caption and `questionsPath` / `answersPath`. */
165
+ objectData: {
166
+ type: Object,
167
+ default: null,
168
+ },
169
+
170
+ /** The bound record's register, for writing `answersPath` (`content.record` wins). */
171
+ register: {
172
+ type: [String, Object],
173
+ default: '',
174
+ },
175
+
176
+ /** The bound record's schema, for writing `answersPath` (`content.record` wins). */
177
+ schema: {
178
+ type: [String, Object],
179
+ default: '',
180
+ },
181
+ },
182
+
183
+ emits: ['finished'],
184
+
185
+ data() {
186
+ instanceCounter += 1;
187
+ return {
188
+ uid: `cn-checklist-run-${instanceCounter}`,
189
+ loading: false,
190
+ error: '',
191
+ saving: false,
192
+ saveError: '',
193
+ questions: [],
194
+ answerIds: {},
195
+ // Photos an answer already holds, kept when new ones are attached.
196
+ storedPhotos: {},
197
+ // `answersPath` mode: the answer entries as last read or written.
198
+ recordAnswers: [],
199
+ draft: {},
200
+ dirty: {},
201
+ photos: {},
202
+ page: 0,
203
+ missing: [],
204
+ // Questions on this page that still need their photo.
205
+ missingPhoto: [],
206
+ // `content.record` with a filter: the record it picked, or null.
207
+ pickedRecord: null,
208
+ liveText: '',
209
+ }
210
+ },
211
+
212
+ computed: {
213
+ /** @return {object} What the tokens resolve against. */
214
+ tokenCtx() {
215
+ const unwrap = (c) => (c && typeof c === 'object' && 'value' in c ? c.value : c);
216
+ const injected = unwrap(this.cnObjectContext) || {};
217
+ return {
218
+ ...injected,
219
+ objectId: (this.pickedRecord && idOf(this.pickedRecord)) || this.objectId || injected.objectId || this.objectData?.id || null,
220
+ object: this.pickedRecord || this.objectData || injected.object || null,
221
+ workspace: unwrap(this.cnWorkspaceContext) || {},
222
+ }
223
+ },
224
+
225
+ /** @return {object} The question field paths, with their defaults. */
226
+ fields() {
227
+ return { id: 'id', text: 'text', type: 'type', options: 'options', required: 'required', help: 'help', ...(this.content?.fields || {}) }
228
+ },
229
+
230
+ /** @return {number} Questions per page, at least 1. */
231
+ pageSize() {
232
+ const n = Math.floor(Number(this.content?.pageSize));
233
+ return Number.isFinite(n) && n >= 1 ? n : 1
234
+ },
235
+
236
+ /** @return {number} The number of pages. */
237
+ pageCount() {
238
+ return Math.max(1, Math.ceil(this.questions.length / this.pageSize))
239
+ },
240
+
241
+ /** @return {boolean} Whether this is the last page. */
242
+ isLastPage() {
243
+ return this.page >= this.pageCount - 1
244
+ },
245
+
246
+ /** @return {object[]} The questions on this page. */
247
+ pageQuestions() {
248
+ return this.questions.slice(this.page * this.pageSize, (this.page + 1) * this.pageSize)
249
+ },
250
+
251
+ /** @return {object|null} The first question on this page, the one photos belong to. */
252
+ currentQuestion() {
253
+ return this.pageQuestions[0] || null
254
+ },
255
+
256
+ /** @return {number} Questions with an answer. */
257
+ answeredCount() {
258
+ return this.questions.filter((q) => this.isAnswered(q)).length
259
+ },
260
+
261
+ /** @return {number} The share answered, 0 to 100. */
262
+ progressShare() {
263
+ return this.questions.length ? Math.round((this.answeredCount / this.questions.length) * 100) : 0
264
+ },
265
+
266
+ /** @return {string} "Question 7 of 24", with the caption after it. */
267
+ positionText() {
268
+ const first = this.page * this.pageSize + 1;
269
+ const position = translate('nextcloud-vue', 'Question {n} of {total}', { n: first, total: this.questions.length });
270
+ const caption = this.caption;
271
+ return caption ? `${position} · ${caption}` : position
272
+ },
273
+
274
+ /** @return {string} `content.caption` with `{field}` filled from the record. */
275
+ caption() {
276
+ const template = this.content?.caption;
277
+ if (typeof template !== 'string' || template === '') {
278
+ return ''
279
+ }
280
+ const object = this.tokenCtx.object || {};
281
+ return this.translate(template).replace(/\{([A-Za-z0-9_.]+)\}/g, (_m, path) => {
282
+ const value = readPath(object, path);
283
+ return value === null || value === undefined ? '' : String(value)
284
+ }).trim()
285
+ },
286
+
287
+ /** @return {boolean} Whether a note field shows (default true). */
288
+ allowNote() {
289
+ return this.content?.allowNote !== false
290
+ },
291
+
292
+ /** @return {boolean} Whether the answers are written into an array on the open record. */
293
+ inRecord() {
294
+ return typeof this.content?.answersPath === 'string' && this.content.answersPath !== ''
295
+ },
296
+
297
+ /** @return {object} `answersPath` field names, with their defaults. */
298
+ answerFields() {
299
+ return { value: 'value', number: 'value', choice: 'value', note: 'note', photos: 'photos', ...(this.content?.answerFields || {}) }
300
+ },
301
+
302
+ /** @return {{register: string, schema: string}} Where the open record lives. */
303
+ recordRef() {
304
+ const own = this.content?.record || {};
305
+ return { register: slugOf(own.register || this.register), schema: slugOf(own.schema || this.schema) }
306
+ },
307
+
308
+ /**
309
+ * Whether photos can be added: allowed, and somewhere to put them.
310
+ *
311
+ * @return {boolean}
312
+ * @spec openspec/changes/checklist-run-widget/specs/checklist-run-widget/spec.md#requirement-questions-and-answers-can-live-inside-the-open-record
313
+ */
314
+ photosEnabled() {
315
+ if (this.content?.allowPhotos === false || this.content?.saveAction) {
316
+ return false
317
+ }
318
+ if (this.inRecord) {
319
+ return Boolean(this.content?.recordPhotosField && this.answerFields.photos)
320
+ }
321
+ return Boolean(this.content?.answers?.photosField)
322
+ },
323
+
324
+ /** @return {string} The group (section) of this page's first question, or ''. */
325
+ pageGroup() {
326
+ return this.currentQuestion?.group || ''
327
+ },
328
+
329
+ /**
330
+ * The questions of this page that get a photo control: with a
331
+ * `photoRequired` field, those whose rule is not never (and photo
332
+ * questions); without one, the first question of the page.
333
+ *
334
+ * @return {object[]} The questions.
335
+ * @spec openspec/changes/checklist-run-photo-rule/specs/checklist-run-photo-rule/spec.md#requirement-a-question-can-require-a-photo
336
+ */
337
+ photoQuestions() {
338
+ if (!this.photosEnabled) {
339
+ return []
340
+ }
341
+ if (this.fields.photoRequired) {
342
+ return this.pageQuestions.filter((q) => q.kind === 'photo' || q.photoRule !== 'never')
343
+ }
344
+ const first = this.currentQuestion;
345
+ const extra = this.pageQuestions.filter((q) => q.kind === 'photo' && q !== first);
346
+ return first ? [first, ...extra] : extra
347
+ },
348
+
349
+ /**
350
+ * `content.record` with a `filter`: the list to pick the open record
351
+ * from (first match of `sort`), `'blocked'` while a token is
352
+ * unresolved, or null without a filter.
353
+ *
354
+ * @return {object|string|null}
355
+ * @spec openspec/changes/checklist-run-photo-rule/specs/checklist-run-photo-rule/spec.md#requirement-a-checklist-run-can-pick-its-record-by-filter
356
+ */
357
+ recordPicker() {
358
+ const own = this.content?.record;
359
+ if (!own || typeof own !== 'object' || !own.filter || typeof own.filter !== 'object') {
360
+ return null
361
+ }
362
+ const unwrap = (c) => (c && typeof c === 'object' && 'value' in c ? c.value : c);
363
+ const filter = resolveFilterTokens(own.filter, { objectId: this.objectId || null, object: this.objectData, workspace: unwrap(this.cnWorkspaceContext) || {} });
364
+ if (hasUnresolvedTokens(filter)) {
365
+ return 'blocked'
366
+ }
367
+ const sort = own.sort && typeof own.sort === 'object' && own.sort.field ? { [`_order[${own.sort.field}]`]: own.sort.dir === 'asc' ? 'asc' : 'desc' } : {};
368
+ return { register: slugOf(own.register), schema: slugOf(own.schema), filter: { ...filter, ...sort } }
369
+ },
370
+
371
+ /** @return {object|null} The record the run lives in: the picked one, else the bound one. */
372
+ openRecord() {
373
+ return this.recordPicker ? this.pickedRecord : this.tokenCtx.object
374
+ },
375
+
376
+ /** @return {string} The key of the source lists, to read them again when they change. */
377
+ sourceKey() {
378
+ const picker = this.recordPicker;
379
+ if (picker === 'blocked') {
380
+ return ''
381
+ }
382
+ if (picker && typeof this.content?.questionsPath === 'string' && this.content.questionsPath !== '') {
383
+ return JSON.stringify({ pick: picker, path: this.content.questionsPath })
384
+ }
385
+ if (typeof this.content?.questionsPath === 'string' && this.content.questionsPath !== '') {
386
+ const object = this.tokenCtx.object;
387
+ return object ? JSON.stringify({ path: this.content.questionsPath, id: idOf(object), at: object['@self']?.updated ?? null }) : ''
388
+ }
389
+ const q = this.content?.questions;
390
+ if (!q || !q.register || !q.schema) {
391
+ return ''
392
+ }
393
+ const qFilter = resolveFilterTokens(q.filter || {}, this.tokenCtx);
394
+ const a = this.content?.answers;
395
+ const aFilter = a && a.register && a.schema ? resolveFilterTokens(a.filter || {}, this.tokenCtx) : null;
396
+ if (hasUnresolvedTokens(qFilter) || (aFilter && hasUnresolvedTokens(aFilter))) {
397
+ return ''
398
+ }
399
+ return JSON.stringify({ q: [q.register, q.schema, qFilter], a: aFilter ? [a.register, a.schema, aFilter] : null })
400
+ },
401
+
402
+ loadingLabel: () => translate('nextcloud-vue', 'Loading …'),
403
+ progressLabel: () => translate('nextcloud-vue', 'Questions answered'),
404
+ requiredLabel: () => ` ${translate('nextcloud-vue', '(required)')}`,
405
+ missingLabel: () => translate('nextcloud-vue', 'Answer this question to go on.'),
406
+ previousLabel: () => translate('nextcloud-vue', 'Previous'),
407
+ nextLabel: () => translate('nextcloud-vue', 'Next'),
408
+ finishLabel: () => translate('nextcloud-vue', 'Finish'),
409
+ addPhotoLabel: () => translate('nextcloud-vue', 'Take or add a photo'),
410
+ missingPhotoLabel: () => translate('nextcloud-vue', 'This answer needs a photo. Add one to go on.'),
411
+ photoOnlyLabel: () => translate('nextcloud-vue', 'Answer with a photo.'),
412
+ morePhotoLabel: () => translate('nextcloud-vue', 'Another photo'),
413
+
414
+ /** @return {string} The note field's label. */
415
+ noteLabel() {
416
+ return this.content?.noteLabel ? this.translate(this.content.noteLabel) : translate('nextcloud-vue', 'Note')
417
+ },
418
+
419
+ /** @return {string} The empty-state text. */
420
+ emptyLabel() {
421
+ return this.content?.emptyText ? this.translate(this.content.emptyText) : translate('nextcloud-vue', 'This checklist has no questions.')
422
+ },
423
+ },
424
+
425
+ watch: {
426
+ sourceKey: {
427
+ handler() {
428
+ this.load();
429
+ },
430
+
431
+ immediate: true,
432
+ },
433
+ },
434
+
435
+ methods: {
436
+ /**
437
+ * The host's translate, or the text unchanged.
438
+ *
439
+ * @param {string} text The text.
440
+ * @return {string} The translation.
441
+ */
442
+ translate(text) {
443
+ return typeof this.cnTranslate === 'function' ? this.cnTranslate(text) : text
444
+ },
445
+
446
+ /**
447
+ * Whether a value counts as an answer.
448
+ *
449
+ * @param {unknown} value The value.
450
+ * @return {boolean} True for a non-empty value.
451
+ */
452
+ hasValue(value) {
453
+ return value !== null && value !== undefined && String(value).trim() !== ''
454
+ },
455
+
456
+ /**
457
+ * Whether a question has an answer: a value, or for a photo question
458
+ * a photo stored or held.
459
+ *
460
+ * @param {object} question The question.
461
+ * @return {boolean} True when answered.
462
+ */
463
+ isAnswered(question) {
464
+ if (question.kind === 'photo') {
465
+ return this.hasPhoto(question)
466
+ }
467
+ return this.hasValue(this.draft[question.id]?.value)
468
+ },
469
+
470
+ /**
471
+ * A question record as the widget draws it.
472
+ *
473
+ * @param {object} record The question record.
474
+ * @param {string} [group] The group (section) it belongs to.
475
+ * @return {{id: string, text: string, kind: string, required: boolean, help: string, choices: Array<{value: string, label: string}>, group: string}} The question.
476
+ */
477
+ toQuestion(record, group = '') {
478
+ const f = this.fields;
479
+ const raw = String(readPath(record, f.type) ?? '');
480
+ const mapped = this.content?.typeMap && typeof this.content.typeMap === 'object' ? this.content.typeMap[raw] : undefined;
481
+ const kind = KINDS.includes(mapped) ? mapped : (KINDS.includes(raw) ? raw : 'text');
482
+ const stored = { yes: 'yes', no: 'no', na: 'na', ...(this.content?.choiceValues || {}) };
483
+ let choices = (TYPE_CHOICES[kind] || []).map((c) => ({ value: String(stored[c.key]), label: translate('nextcloud-vue', c.label) }));
484
+ if (kind === 'choice') {
485
+ const options = readPath(record, f.options);
486
+ choices = (Array.isArray(options) ? options : []).map((o) => (o && typeof o === 'object'
487
+ ? { value: String(o.value ?? o.label ?? ''), label: this.translate(String(o.label ?? o.value ?? '')) }
488
+ : { value: String(o), label: this.translate(String(o)) })).filter((c) => c.value !== '');
489
+ }
490
+ const help = readPath(record, f.help);
491
+ return {
492
+ id: String(readPath(record, f.id) ?? idOf(record)),
493
+ text: this.translate(String(readPath(record, f.text) ?? '')),
494
+ kind,
495
+ required: Boolean(readPath(record, f.required)),
496
+ help: typeof help === 'string' && help !== '' ? this.translate(help) : '',
497
+ choices,
498
+ group,
499
+ photoRule: this.photoRuleOf(record),
500
+ }
501
+ },
502
+
503
+ /**
504
+ * A question's photo rule, from its `photoRequired` field mapped
505
+ * through `photoRequiredValues`: `'never'`, `'always'` or
506
+ * `{ whenAnswer }` (a photo is needed when the answer is that value).
507
+ *
508
+ * @param {object} record The question record.
509
+ * @return {('never'|'always'|{whenAnswer: string})} The rule.
510
+ * @spec openspec/changes/checklist-run-photo-rule/specs/checklist-run-photo-rule/spec.md#requirement-a-question-can-require-a-photo
511
+ */
512
+ photoRuleOf(record) {
513
+ const path = this.fields.photoRequired;
514
+ if (!path) {
515
+ return 'never'
516
+ }
517
+ const raw = readPath(record, path);
518
+ const map = { true: 'always', false: 'never', always: 'always', never: 'never', ...(this.content?.photoRequiredValues || {}) };
519
+ const rule = raw === null || raw === undefined ? 'never' : map[String(raw)];
520
+ if (rule && typeof rule === 'object' && rule.whenAnswer !== undefined) {
521
+ return { whenAnswer: String(rule.whenAnswer) }
522
+ }
523
+ return rule === 'always' ? 'always' : 'never'
524
+ },
525
+
526
+ /**
527
+ * Whether a question needs a photo with its current answer.
528
+ *
529
+ * @param {object} question The question.
530
+ * @return {boolean} True when a photo is required now.
531
+ */
532
+ needsPhoto(question) {
533
+ const rule = question.photoRule;
534
+ if (rule === 'always') {
535
+ return true
536
+ }
537
+ return Boolean(rule && typeof rule === 'object' && String(this.draft[question.id]?.value ?? '') === rule.whenAnswer)
538
+ },
539
+
540
+ /**
541
+ * Whether a question has a photo, stored or held.
542
+ *
543
+ * @param {object} question The question.
544
+ * @return {boolean} True with at least one photo.
545
+ */
546
+ hasPhoto(question) {
547
+ return (this.storedPhotos[question.id] || []).length > 0 || (this.photos[question.id] || []).length > 0
548
+ },
549
+
550
+ /**
551
+ * The photo card title for a question: its position in the run.
552
+ *
553
+ * @param {object} question The question.
554
+ * @return {string} The title.
555
+ */
556
+ photoTitle(question) {
557
+ const n = this.questions.indexOf(question) + 1;
558
+ return translate('nextcloud-vue', 'Photo for question {n}', { n })
559
+ },
560
+
561
+ /**
562
+ * The names of the photos held for a question.
563
+ *
564
+ * @param {object} question The question.
565
+ * @return {string[]} The file names.
566
+ */
567
+ photoNamesFor(question) {
568
+ return (this.photos[question.id] || []).map((file) => file.name)
569
+ },
570
+
571
+ /**
572
+ * The questions on the open record (`questionsPath`): a list of
573
+ * questions, or of groups whose `itemsKey` holds them, both levels
574
+ * sorted by `orderField`.
575
+ *
576
+ * @return {object[]} The questions.
577
+ * @spec openspec/changes/checklist-run-widget/specs/checklist-run-widget/spec.md#requirement-questions-and-answers-can-live-inside-the-open-record
578
+ */
579
+ questionsFromRecord() {
580
+ const c = this.content;
581
+ const list = readPath(this.openRecord, c.questionsPath);
582
+ const orderField = c.orderField === undefined ? 'order' : c.orderField;
583
+ const top = byOrder(Array.isArray(list) ? list : [], orderField);
584
+ if (!c.itemsKey) {
585
+ return top.map((record) => this.toQuestion(record))
586
+ }
587
+ const labelField = c.groupLabelField || 'name';
588
+ return top.flatMap((group) => {
589
+ const items = readPath(group, c.itemsKey);
590
+ const label = readPath(group, labelField);
591
+ return byOrder(Array.isArray(items) ? items : [], orderField).map((record) => this.toQuestion(record, typeof label === 'string' ? this.translate(label) : ''))
592
+ })
593
+ },
594
+
595
+ /**
596
+ * The draft of one answer entry from the open record's `answersPath`.
597
+ *
598
+ * @param {object} question The question.
599
+ * @param {object} entry The stored entry.
600
+ * @return {{value: string, note: string}} The draft.
601
+ */
602
+ draftFromEntry(question, entry) {
603
+ const af = this.answerFields;
604
+ const field = question.kind === 'number' ? af.number : (question.kind === 'choice' ? af.choice : af.value);
605
+ const value = readPath(entry, field);
606
+ const note = readPath(entry, af.note);
607
+ return { value: value === null || value === undefined ? '' : String(value), note: note === null || note === undefined ? '' : String(note) }
608
+ },
609
+
610
+ /**
611
+ * Read the questions and the answers given so far.
612
+ *
613
+ * @return {Promise<void>}
614
+ * @spec openspec/changes/checklist-run-widget/specs/checklist-run-widget/spec.md#requirement-a-checklist-run-shows-a-page-of-questions-at-a-time
615
+ */
616
+ async load() {
617
+ const key = this.sourceKey;
618
+ if (key === '') {
619
+ return
620
+ }
621
+ this.loading = true;
622
+ this.error = '';
623
+ try {
624
+ const picker = this.recordPicker;
625
+ if (picker) {
626
+ const found = await fetchReferenceObjects({ register: picker.register, schema: picker.schema, filter: picker.filter, limit: 1 });
627
+ if (key !== this.sourceKey) {
628
+ return
629
+ }
630
+ this.pickedRecord = found[0] || null;
631
+ }
632
+ let questions;
633
+ if (picker && !this.pickedRecord) {
634
+ questions = [];
635
+ } else if (this.content.questionsPath) {
636
+ questions = this.questionsFromRecord();
637
+ } else {
638
+ const q = this.content.questions;
639
+ const records = await fetchReferenceObjects({ register: q.register, schema: q.schema, filter: resolveFilterTokens(q.filter || {}, this.tokenCtx), limit: q.limit || 500 });
640
+ questions = byOrder(records, q.orderField).map((r) => this.toQuestion(r));
641
+ }
642
+ questions = questions.filter((x) => x.id !== '' && x.text !== '');
643
+ const a = this.inRecord ? null : this.content.answers;
644
+ const answers = a && a.register && a.schema
645
+ ? await fetchReferenceObjects({ register: a.register, schema: a.schema, filter: resolveFilterTokens(a.filter || {}, this.tokenCtx), limit: a.limit || 500 })
646
+ : [];
647
+ if (key !== this.sourceKey) {
648
+ return
649
+ }
650
+ const draft = {};
651
+ const answerIds = {};
652
+ const storedPhotos = {};
653
+ for (const question of questions) {
654
+ draft[question.id] = { value: '', note: '' };
655
+ }
656
+ if (this.inRecord) {
657
+ const entries = readPath(this.openRecord, this.content.answersPath);
658
+ this.recordAnswers = Array.isArray(entries) ? entries : [];
659
+ const key = this.content.answerKey || 'questionId';
660
+ for (const entry of this.recordAnswers) {
661
+ const qid = String(readPath(entry, key) ?? '');
662
+ const question = questions.find((x) => x.id === qid);
663
+ if (question && !answerIds[qid]) {
664
+ draft[qid] = this.draftFromEntry(question, entry);
665
+ answerIds[qid] = qid;
666
+ const photos = readPath(entry, this.answerFields.photos);
667
+ storedPhotos[qid] = Array.isArray(photos) ? photos : [];
668
+ }
669
+ }
670
+ }
671
+ for (const answer of answers) {
672
+ const qid = String(readPath(answer, a.questionField || 'question') ?? '');
673
+ if (draft[qid] && !answerIds[qid]) {
674
+ draft[qid] = {
675
+ value: String(readPath(answer, a.valueField || 'answer') ?? ''),
676
+ note: String(readPath(answer, a.noteField || 'note') ?? ''),
677
+ };
678
+ answerIds[qid] = idOf(answer);
679
+ const stored = a.photosField ? readPath(answer, a.photosField) : null;
680
+ storedPhotos[qid] = Array.isArray(stored) ? stored : [];
681
+ }
682
+ }
683
+ this.questions = questions;
684
+ this.draft = draft;
685
+ this.answerIds = answerIds;
686
+ this.storedPhotos = storedPhotos;
687
+ this.dirty = {};
688
+ this.page = Math.min(this.page, Math.max(0, Math.ceil(questions.length / this.pageSize) - 1));
689
+ } catch {
690
+ this.error = translate('nextcloud-vue', 'The checklist could not be loaded.');
691
+ } finally {
692
+ this.loading = false;
693
+ }
694
+ },
695
+
696
+ /**
697
+ * Note that a question's answer changed, so the page saves it.
698
+ *
699
+ * @param {string} id The question id.
700
+ * @return {void}
701
+ */
702
+ markDirty(id) {
703
+ this.dirty = { ...this.dirty, [id]: true };
704
+ this.missing = this.missing.filter((m) => m !== id);
705
+ },
706
+
707
+ /**
708
+ * Hold the photos picked for a question until the page saves.
709
+ *
710
+ * @param {Event} event The file input's change event.
711
+ * @param {string} [questionId] The question, else the first of the page.
712
+ * @return {void}
713
+ */
714
+ addPhotos(event, questionId) {
715
+ const id = questionId || this.currentQuestion?.id;
716
+ const files = Array.from(event?.target?.files || []);
717
+ if (!id || files.length === 0) {
718
+ return
719
+ }
720
+ this.photos = { ...this.photos, [id]: [...(this.photos[id] || []), ...files] };
721
+ this.dirty = { ...this.dirty, [id]: true };
722
+ this.missingPhoto = this.missingPhoto.filter((m) => m !== id);
723
+ if (event.target) {
724
+ event.target.value = '';
725
+ }
726
+ },
727
+
728
+ /**
729
+ * Save the changed answers of this page: through `saveAction` when
730
+ * set, else one answer record per question through the object API,
731
+ * then its held photos.
732
+ *
733
+ * @return {Promise<boolean>} True when everything saved.
734
+ * @spec openspec/changes/checklist-run-widget/specs/checklist-run-widget/spec.md#requirement-answers-are-saved-under-the-readers-own-rights
735
+ */
736
+ async savePage() {
737
+ const changed = this.pageQuestions.filter((q) => this.dirty[q.id]);
738
+ if (changed.length === 0) {
739
+ return true
740
+ }
741
+ this.saving = true;
742
+ this.saveError = '';
743
+ try {
744
+ if (this.inRecord && !this.content?.saveAction) {
745
+ await this.saveIntoRecord(changed);
746
+ this.dirty = Object.fromEntries(Object.entries(this.dirty).filter(([id]) => !changed.some((q) => q.id === id)));
747
+ return true
748
+ }
749
+ for (const question of changed) {
750
+ await this.saveAnswer(question);
751
+ const { [question.id]: _done, ...rest } = this.dirty;
752
+ this.dirty = rest;
753
+ }
754
+ return true
755
+ } catch {
756
+ this.saveError = translate('nextcloud-vue', 'The answer could not be saved. Try again.');
757
+ return false
758
+ } finally {
759
+ this.saving = false;
760
+ }
761
+ },
762
+
763
+ /**
764
+ * Write the changed answers of this page into the open record's
765
+ * `answersPath` array (one entry per question, keyed by `answerKey`)
766
+ * with one update of the record through the object API. Held photos
767
+ * go first to the record's own files (`recordPhotosField`), and their
768
+ * references (`photoRefField`, default `id`) into the answer.
769
+ *
770
+ * @param {object[]} changed The changed questions.
771
+ * @return {Promise<void>}
772
+ * @spec openspec/changes/checklist-run-widget/specs/checklist-run-widget/spec.md#requirement-questions-and-answers-can-live-inside-the-open-record
773
+ */
774
+ async saveIntoRecord(changed) {
775
+ const { register, schema } = this.recordRef;
776
+ const objectId = idOf(this.openRecord) || String(this.openRecordId || '');
777
+ if (!register || !schema || !objectId) {
778
+ throw new Error('no record to write to')
779
+ }
780
+ const c = this.content;
781
+ const af = this.answerFields;
782
+ const key = c.answerKey || 'questionId';
783
+ const refField = c.photoRefField || 'id';
784
+ let recordPhotos = Array.isArray(readPath(this.openRecord, c.recordPhotosField)) ? readPath(this.openRecord, c.recordPhotosField) : [];
785
+ const entries = [...this.recordAnswers];
786
+ for (const question of changed) {
787
+ const draft = this.draft[question.id];
788
+ const index = entries.findIndex((entry) => String(readPath(entry, key) ?? '') === question.id);
789
+ const entry = { ...(index >= 0 ? entries[index] : { [key]: question.id }) };
790
+ if (question.kind === 'number') {
791
+ entry[af.number] = draft.value === '' ? null : Number(draft.value);
792
+ } else if (question.kind === 'choice') {
793
+ entry[af.choice] = draft.value;
794
+ } else if (question.kind !== 'photo') {
795
+ entry[af.value] = draft.value;
796
+ }
797
+ if (this.allowNote) {
798
+ entry[af.note] = draft.note;
799
+ }
800
+ if (af.at) {
801
+ entry[af.at] = new Date().toISOString();
802
+ }
803
+ const held = this.photos[question.id] || [];
804
+ if (this.photosEnabled && held.length) {
805
+ const field = c.recordPhotosField;
806
+ const { failed, attached } = await uploadHeldFiles({ register, schema, objectId, held: { [field]: held }, kept: { [field]: recordPhotos } });
807
+ if (failed.length) {
808
+ throw new Error('photo upload failed')
809
+ }
810
+ const now = Array.isArray(attached[field]) ? attached[field] : recordPhotos;
811
+ const added = now.slice(recordPhotos.length).map((ref) => (ref && typeof ref === 'object' ? ref[refField] ?? ref : ref));
812
+ recordPhotos = now;
813
+ const existing = Array.isArray(entry[af.photos]) ? entry[af.photos] : [];
814
+ entry[af.photos] = [...existing, ...added];
815
+ this.storedPhotos = { ...this.storedPhotos, [question.id]: entry[af.photos] };
816
+ const { [question.id]: _sent, ...rest } = this.photos;
817
+ this.photos = rest;
818
+ }
819
+ if (index >= 0) {
820
+ entries[index] = entry;
821
+ } else {
822
+ entries.push(entry);
823
+ }
824
+ }
825
+ const url = generateUrl(`${OBJECT_API}/${encodeURIComponent(register)}/${encodeURIComponent(schema)}/${encodeURIComponent(objectId)}`);
826
+ await axios__default.patch(url, { [c.answersPath]: entries });
827
+ this.recordAnswers = entries;
828
+ },
829
+
830
+ /**
831
+ * Save one answer.
832
+ *
833
+ * @param {object} question The question.
834
+ * @return {Promise<void>}
835
+ */
836
+ async saveAnswer(question) {
837
+ const entry = this.draft[question.id];
838
+ const answer = { questionId: question.id, value: entry.value, note: entry.note };
839
+ const action = this.content?.saveAction;
840
+ if (action && typeof action === 'object') {
841
+ const resolved = resolveDeepTokens(action, this.tokenCtx);
842
+ const wrapped = { ...resolved, args: [...(Array.isArray(resolved.args) ? resolved.args : []), answer] };
843
+ const run = typeof this.cnDispatchAction === 'function' ? this.cnDispatchAction(wrapped) : dispatchAction(wrapped, { router: this.$router || null, tokenCtx: this.tokenCtx });
844
+ await run;
845
+ return
846
+ }
847
+ const a = this.content?.answers;
848
+ if (!a || !a.register || !a.schema) {
849
+ return
850
+ }
851
+ const body = {
852
+ [a.questionField || 'question']: question.id,
853
+ [a.valueField || 'answer']: entry.value,
854
+ };
855
+ if (this.allowNote) {
856
+ body[a.noteField || 'note'] = entry.note;
857
+ }
858
+ const base = generateUrl(`${OBJECT_API}/${encodeURIComponent(a.register)}/${encodeURIComponent(a.schema)}`);
859
+ let id = this.answerIds[question.id];
860
+ if (id) {
861
+ await axios__default.patch(`${base}/${encodeURIComponent(id)}`, body);
862
+ } else {
863
+ const create = resolveDeepTokens(a.create && typeof a.create === 'object' ? a.create : {}, this.tokenCtx);
864
+ const response = await axios__default.post(base, { ...create, ...body });
865
+ id = idOf(response?.data);
866
+ this.answerIds = { ...this.answerIds, [question.id]: id };
867
+ }
868
+ const held = this.photos[question.id] || [];
869
+ if (this.photosEnabled && held.length && id) {
870
+ const { failed, attached } = await uploadHeldFiles({ register: a.register, schema: a.schema, objectId: id, held: { [a.photosField]: held }, kept: { [a.photosField]: this.storedPhotos[question.id] || [] } });
871
+ if (failed.length) {
872
+ throw new Error('photo upload failed')
873
+ }
874
+ if (Array.isArray(attached[a.photosField])) {
875
+ this.storedPhotos = { ...this.storedPhotos, [question.id]: attached[a.photosField] };
876
+ }
877
+ const { [question.id]: _sent, ...rest } = this.photos;
878
+ this.photos = rest;
879
+ }
880
+ },
881
+
882
+ /**
883
+ * The required questions on this page without an answer.
884
+ *
885
+ * @return {string[]} Their ids.
886
+ */
887
+ unansweredRequired() {
888
+ return this.pageQuestions.filter((q) => q.required && !this.isAnswered(q)).map((q) => q.id)
889
+ },
890
+
891
+ /**
892
+ * Save this page and go to the next one; on the last page, finish.
893
+ *
894
+ * @return {Promise<void>}
895
+ * @spec openspec/changes/checklist-run-widget/specs/checklist-run-widget/spec.md#requirement-a-checklist-run-shows-a-page-of-questions-at-a-time
896
+ */
897
+ async next() {
898
+ this.missing = this.unansweredRequired();
899
+ this.missingPhoto = this.photosEnabled ? this.pageQuestions.filter((q) => this.needsPhoto(q) && !this.hasPhoto(q)).map((q) => q.id) : [];
900
+ if (this.missing.length || this.missingPhoto.length) {
901
+ return
902
+ }
903
+ if (!(await this.savePage())) {
904
+ return
905
+ }
906
+ if (this.isLastPage) {
907
+ await this.finish();
908
+ return
909
+ }
910
+ this.goTo(this.page + 1);
911
+ },
912
+
913
+ /**
914
+ * Save this page and go back one.
915
+ *
916
+ * @return {Promise<void>}
917
+ */
918
+ async previous() {
919
+ if (this.page === 0 || !(await this.savePage())) {
920
+ return
921
+ }
922
+ this.missing = [];
923
+ this.missingPhoto = [];
924
+ this.goTo(this.page - 1);
925
+ },
926
+
927
+ /**
928
+ * Show a page, move focus to its position line and announce it.
929
+ *
930
+ * @param {number} page The page.
931
+ * @return {void}
932
+ */
933
+ goTo(page) {
934
+ this.page = page;
935
+ this.$nextTick(() => {
936
+ this.liveText = this.positionText;
937
+ this.$refs.position?.focus?.();
938
+ });
939
+ },
940
+
941
+ /**
942
+ * The run is done: dispatch `finishAction` when set, and emit `finished`.
943
+ *
944
+ * @return {Promise<void>}
945
+ */
946
+ async finish() {
947
+ const action = this.content?.finishAction;
948
+ if (action && typeof action === 'object') {
949
+ const resolved = resolveDeepTokens(action, this.tokenCtx);
950
+ const run = typeof this.cnDispatchAction === 'function' ? this.cnDispatchAction(resolved) : dispatchAction(resolved, { router: this.$router || null, tokenCtx: this.tokenCtx });
951
+ await run;
952
+ }
953
+ this.liveText = translate('nextcloud-vue', 'Checklist finished.');
954
+ /**
955
+ * @event finished The last page was saved.
956
+ * @type {{answered: number, total: number}}
957
+ */
958
+ this.$emit('finished', { answered: this.answeredCount, total: this.questions.length });
959
+ },
960
+ },
961
+ };
962
+
963
+ export { script as default };
964
+ //# sourceMappingURL=CnChecklistRunWidget.vue2.js.map