@salesforce/afv-skills 1.62.0 → 1.64.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 (138) hide show
  1. package/package.json +1 -1
  2. package/skills/agentforce-offline-experimentation-run/SKILL.md +253 -0
  3. package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +796 -0
  4. package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +111 -0
  5. package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +1053 -0
  6. package/skills/automation-sandbox-post-copy-config-generate/SKILL.md +21 -11
  7. package/skills/automation-sandbox-post-copy-config-generate/assets/config_template.json +10 -0
  8. package/skills/automation-sandbox-post-copy-config-generate/assets/json_schema.json +33 -1
  9. package/skills/automation-sandbox-post-copy-config-generate/references/configuration_catalog.md +44 -1
  10. package/skills/automation-sandbox-post-copy-configure/SKILL.md +132 -124
  11. package/skills/automation-sandbox-post-copy-configure/references/flow_activation_path.md +151 -0
  12. package/skills/automation-sandbox-post-copy-configure/references/scheduled_apex_path.md +55 -9
  13. package/skills/consumer-goods-promotion-bo-api-deploy/SKILL.md +54 -107
  14. package/skills/consumer-goods-promotion-bo-api-deploy/assets/set-comment-value/payloads/copy.json +1 -1
  15. package/skills/consumer-goods-promotion-bo-api-deploy/references/conventions-and-payload-rules.md +82 -5
  16. package/skills/consumer-goods-promotion-bo-api-deploy/references/generate-and-wire.md +35 -2
  17. package/skills/consumer-goods-promotion-bo-api-deploy/references/preflight-and-seed-queries.md +149 -0
  18. package/skills/consumer-goods-promotion-bo-api-deploy/references/smoke-and-verify.md +17 -1
  19. package/skills/dx-devops-conflict-resolve/SKILL.md +1 -5
  20. package/skills/dx-devops-pipeline-manage/SKILL.md +1 -5
  21. package/skills/dx-devops-project-manage/SKILL.md +1 -5
  22. package/skills/dx-devops-promote/SKILL.md +1 -5
  23. package/skills/dx-devops-request-status/SKILL.md +1 -5
  24. package/skills/dx-lifecycle-center/SKILL.md +120 -0
  25. package/skills/dx-lifecycle-center/examples/workflows.md +175 -0
  26. package/skills/dx-lifecycle-center/references/connect-api.md +123 -0
  27. package/skills/dx-lifecycle-center/references/connect-org.md +184 -0
  28. package/skills/dx-lifecycle-center/references/deploy.md +153 -0
  29. package/skills/dx-lifecycle-center/references/error-handling.md +21 -0
  30. package/skills/dx-lifecycle-center/references/hub-org.md +37 -0
  31. package/skills/dx-lifecycle-center/references/list-orgs.md +99 -0
  32. package/skills/dx-lifecycle-center/references/request-body.md +75 -0
  33. package/skills/dx-lifecycle-center/references/troubleshooting.md +31 -0
  34. package/skills/dx-lifecycle-center/scripts/poll-deploy-status.sh +200 -0
  35. package/skills/dx-lifecycle-center/scripts/resolve-environment.sh +120 -0
  36. package/skills/dx-org-devhub-configure/SKILL.md +1 -3
  37. package/skills/dx-org-shape-manage/SKILL.md +1 -5
  38. package/skills/dx-org-snapshot-manage/SKILL.md +1 -3
  39. package/skills/education-cloud-academic-calendar-generate/SKILL.md +1 -3
  40. package/skills/education-cloud-course-catalog-migrate/SKILL.md +1 -3
  41. package/skills/education-cloud-domain-configure/SKILL.md +1 -3
  42. package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -3
  43. package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -5
  44. package/skills/experience-ui-bundle-2gp-deploy/SKILL.md +1 -3
  45. package/skills/experience-ui-bundle-features-generate/SKILL.md +1 -5
  46. package/skills/experience-ui-bundle-mfa-configure/SKILL.md +1 -3
  47. package/skills/field-service-data-capture-migrate/SKILL.md +1 -3
  48. package/skills/insurance-brokerage-agency-billing-configure/SKILL.md +1 -3
  49. package/skills/marketing-message-test/SKILL.md +1 -3
  50. package/skills/nonprofit-fundraising-configure/SKILL.md +240 -0
  51. package/skills/nonprofit-fundraising-configure/references/gotchas.md +26 -0
  52. package/skills/nonprofit-fundraising-configure/references/mcp-invocation.md +678 -0
  53. package/skills/nonprofit-fundraising-configure/references/permission-targeting.md +35 -0
  54. package/skills/nonprofit-volunteer-management-model-get/SKILL.md +166 -0
  55. package/skills/nonprofit-volunteer-management-model-get/references/entities.md +315 -0
  56. package/skills/nonprofit-volunteer-management-model-get/references/relationships.md +175 -0
  57. package/skills/nonprofit-volunteer-management-model-get/references/themes.md +143 -0
  58. package/skills/nonprofit-volunteer-management-records-create/SKILL.md +121 -0
  59. package/skills/nonprofit-volunteer-management-records-create/references/field-guide.md +284 -0
  60. package/skills/nonprofit-volunteer-management-records-create/references/mcp-invocation.md +248 -0
  61. package/skills/nonprofit-volunteer-management-records-create/references/review-format.md +140 -0
  62. package/skills/nonprofit-volunteer-management-records-create/scripts/expand-series.py +632 -0
  63. package/skills/nonprofit-volunteer-management-records-create/scripts/render-review-table.py +419 -0
  64. package/skills/nonprofit-volunteer-management-records-create/scripts/tests/test_expand_series.py +690 -0
  65. package/skills/nonprofit-volunteer-management-records-create/scripts/tests/test_render_review_table.py +421 -0
  66. package/skills/nonprofit-volunteer-management-structure-generate/SKILL.md +119 -0
  67. package/skills/nonprofit-volunteer-management-structure-generate/references/configuration-decisions.md +159 -0
  68. package/skills/nonprofit-volunteer-management-structure-generate/references/discovery-interview.md +137 -0
  69. package/skills/nonprofit-volunteer-management-structure-generate/references/edge-cases.md +48 -0
  70. package/skills/nonprofit-volunteer-management-structure-generate/references/mcp-invocation.md +75 -0
  71. package/skills/nonprofit-volunteer-management-structure-generate/references/plan-output.md +141 -0
  72. package/skills/platform-custom-field-generate/SKILL.md +1 -0
  73. package/skills/platform-custom-field-generate/references/advanced-picklists.md +5 -5
  74. package/skills/platform-custom-metadata-type-generate/SKILL.md +3 -0
  75. package/skills/platform-custom-object-generate/SKILL.md +4 -0
  76. package/skills/platform-custom-setting-generate/SKILL.md +3 -0
  77. package/skills/platform-datamask-run/SKILL.md +1 -5
  78. package/skills/platform-permission-set-generate/SKILL.md +3 -0
  79. package/skills/platform-salesforce-connect-adapter-generate/SKILL.md +1 -3
  80. package/skills/platform-sandbox-configure/SKILL.md +1 -3
  81. package/skills/platform-value-set-generate/SKILL.md +3 -0
  82. package/skills/sales-call-scoring-configure/SKILL.md +1 -5
  83. package/skills/service-agentforce-channel-configure/SKILL.md +1 -3
  84. package/skills/service-agentforce-human-escalation-configure/SKILL.md +1 -3
  85. package/skills/service-catalog-template-coordinate/SKILL.md +1 -3
  86. package/skills/service-concierge-portal-generate/SKILL.md +1 -3
  87. package/skills/service-email-to-case-configure/SKILL.md +1 -5
  88. package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/SKILL.md +1 -3
  89. package/skills/service-itsm-agentic-setup-agentforce-coordinate/SKILL.md +1 -3
  90. package/skills/service-itsm-agentic-setup-agentforce-studio-configure/SKILL.md +1 -3
  91. package/skills/service-itsm-agentic-setup-agentforce-studio-validate/SKILL.md +1 -3
  92. package/skills/service-itsm-agentic-setup-cmdb-access-assign/SKILL.md +1 -3
  93. package/skills/service-itsm-agentic-setup-cmdb-bundle-deploy/SKILL.md +1 -5
  94. package/skills/service-itsm-agentic-setup-cmdb-configure/SKILL.md +1 -3
  95. package/skills/service-itsm-agentic-setup-cmdb-coordinate/SKILL.md +1 -3
  96. package/skills/service-itsm-agentic-setup-cmdb-discovery-configure/SKILL.md +1 -3
  97. package/skills/service-itsm-agentic-setup-employee-agent-configure/SKILL.md +1 -3
  98. package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/SKILL.md +1 -3
  99. package/skills/service-itsm-agentic-setup-incident-management/SKILL.md +1 -0
  100. package/skills/service-itsm-agentic-setup-incident-sla-configure/SKILL.md +1 -0
  101. package/skills/service-itsm-agentic-setup-itsm-agentforce-permset-assign/SKILL.md +1 -3
  102. package/skills/service-itsm-agentic-setup-uel-user-create/SKILL.md +1 -9
  103. package/skills/service-itsm-incident-mgmt-configure/SKILL.md +1 -5
  104. package/skills/service-itsm-incident-priority-configure/SKILL.md +1 -3
  105. package/skills/service-itsm-slack-itservice-configure/SKILL.md +1 -3
  106. package/skills/service-itsm-swarming-configure/SKILL.md +1 -3
  107. package/skills/service-itsm-teams-configure/SKILL.md +1 -3
  108. package/skills/service-itsm-teams-debug/SKILL.md +1 -3
  109. package/skills/service-itsm-teams-employee-agent-configure/SKILL.md +1 -3
  110. package/skills/service-itsm-teams-itdesk-configure/SKILL.md +1 -3
  111. package/skills/service-itsm-teams-itservice-configure/SKILL.md +1 -3
  112. package/skills/service-native-voice-recording-transcription-configure/SKILL.md +1 -3
  113. package/skills/service-omni-agent-users-create/SKILL.md +1 -3
  114. package/skills/service-omni-agent-work-sharing-configure/SKILL.md +1 -3
  115. package/skills/service-omni-attribute-routing-configure/SKILL.md +1 -3
  116. package/skills/service-omni-base-settings-configure/SKILL.md +1 -3
  117. package/skills/service-omni-channel-inventory-analyze/SKILL.md +1 -3
  118. package/skills/service-omni-channel-limits-analyze/SKILL.md +1 -3
  119. package/skills/service-omni-channel-setup-coordinate/SKILL.md +1 -3
  120. package/skills/service-omni-command-center-analyze/SKILL.md +1 -3
  121. package/skills/service-omni-command-center-configure/SKILL.md +1 -3
  122. package/skills/service-omni-permission-set-assign/SKILL.md +1 -3
  123. package/skills/service-omni-presence-status-deploy/SKILL.md +1 -3
  124. package/skills/service-omni-presence-user-config-deploy/SKILL.md +1 -3
  125. package/skills/service-omni-queue-deploy/SKILL.md +1 -3
  126. package/skills/service-omni-queue-members-assign/SKILL.md +1 -3
  127. package/skills/service-omni-queue-routing-config-deploy/SKILL.md +1 -3
  128. package/skills/service-omni-routing-flow-deploy/SKILL.md +1 -3
  129. package/skills/service-omni-service-channel-configure/SKILL.md +1 -3
  130. package/skills/service-omni-sidebar-configure/SKILL.md +1 -3
  131. package/skills/service-omni-skills-based-routing-configure/SKILL.md +1 -3
  132. package/skills/service-omni-supervisor-config-deploy/SKILL.md +1 -3
  133. package/skills/service-omni-supervisor-permset-assign/SKILL.md +1 -3
  134. package/skills/service-omni-supervisor-surface-deploy/SKILL.md +1 -3
  135. package/skills/service-omni-supervisor-users-create/SKILL.md +1 -3
  136. package/skills/service-omni-work-skill-routing-configure/SKILL.md +1 -3
  137. package/skills/field-service-setup-orchestrator-get/SKILL.md +0 -47
  138. package/skills/tableau-next-asset-create/README.md +0 -65
@@ -0,0 +1,632 @@
1
+ #!/usr/bin/env python3
2
+ """Emit the discrete VM shift / time-slot records that should be imported for a series.
3
+
4
+ This is the *deterministic materialization step* for the two VM entities whose count can
5
+ exceed hand-authoring size — ``JobPositionShift`` (a dated series) and ``TimeSlot`` (a
6
+ weekly window set). It does NOT write to the org: it emits a ``{"records": [...]}`` tree
7
+ object on stdout — the request body of a ``composite/tree`` call, and the file format of a
8
+ Salesforce CLI tree import — which the skill sends with the write call documented in
9
+ ``references/mcp-invocation.md``. A tree call is all-or-nothing, so each batch either
10
+ lands whole or not at all.
11
+
12
+ Its contract is "given a series spec, emit the records that should be *created*" — which
13
+ includes NOT re-emitting shifts that already exist (``excludeExisting``). Expansion and
14
+ that set-difference both live here, in reviewed/tested code, because the alternative —
15
+ day/date arithmetic or dedup done by hand in the skill — is exactly the error-prone,
16
+ unreviewed work this script exists to replace (see SKILL.md: "Never build a series or a
17
+ records table by hand"). The agent passes a **spec** (args as data)
18
+ and never authors code per run.
19
+
20
+ Field API names come from ``references/field-guide.md`` — the standard-field authority.
21
+ No field is emitted that the field guide does not document, and fields that default
22
+ server-side are deliberately left unset: ``OperatingHours`` has no ``Type`` field at all,
23
+ while ``TimeSlot.Type`` DOES exist but is required-but-defaulted (unset yields ``Normal``,
24
+ which VM doesn't use) and ``TimeSlot.Name`` is auto-number — none are ever set here.
25
+
26
+ USAGE
27
+ -----
28
+ python3 scripts/expand-series.py < spec.json # one tree object to stdout (<=200 records)
29
+ python3 scripts/expand-series.py --out-dir ./import < spec.json # one importable file per <=200 batch
30
+
31
+ stdout carries a SINGLE importable tree object, so a series over 200 records
32
+ (which must split into multiple files) requires --out-dir; the script fails with
33
+ a message rather than printing a JSON array a tree call cannot read.
34
+
35
+ WINDOWS: uniform vs. per-day
36
+ ----------------------------
37
+ Supply EXACTLY ONE of:
38
+ * ``windows`` — a flat list applied UNIFORMLY to every day in the series. Use only
39
+ when the time windows are the same on every ``daysOfWeek`` (or every explicit date).
40
+ * ``windowsByDay`` — a map of day-name -> window list, when the windows VARY by weekday
41
+ (e.g. Sat 08:00–12:00 + 12:00–17:00 but Sun 09:00–14:00). The map keys define which
42
+ weekdays the series runs on, so do NOT also pass ``daysOfWeek`` with ``windowsByDay``.
43
+
44
+ Overlapping windows on the same day (e.g. 08:00–13:00 and 12:00–17:00) are ALLOWED and
45
+ emitted as-is — VM permits overlapping shift records; the script does not second-guess
46
+ intent. (For a same-day window — ``endDayOffset`` 0 — it still rejects an end that is not
47
+ after the start.) An EXACT duplicate window (same start/end/offset listed twice) collapses
48
+ to one shift — never emitted twice.
49
+
50
+ MULTI-DAY / OVERNIGHT shifts (JobPositionShift only)
51
+ ----------------------------------------------------
52
+ A ``JobPositionShift`` has separate StartDate and EndDate, so a shift may span dates. Each
53
+ window may carry ``endDayOffset`` (int >= 0, default 0) — how many days AFTER its StartDate
54
+ the window ENDS:
55
+ * 0 (default) — single-day shift; EndDate == StartDate; end time must be after start.
56
+ * 1 — crosses midnight into the next day (e.g. 23:00 -> 03:00 the next morning).
57
+ * N — an N-day span (e.g. a Mon 09:00 -> Fri 17:00 shift is endDayOffset 4).
58
+ The recurring/one-off date is the StartDate and EndDate = StartDate + offset; with an offset
59
+ the end time may be earlier than the start time (EndDate > StartDate already orders them).
60
+ EndDate can fall past ``dateRange.end`` — the range bounds START dates, not end dates.
61
+ ``TimeSlot`` is a single-weekday window (no EndDate) and rejects ``endDayOffset``.
62
+
63
+ DATES: ranges + one-offs + removals (JobPositionShift)
64
+ ------------------------------------------------------
65
+ The date set is the UNION of two optional sources (supply at least one):
66
+ * ``dateRanges`` — a LIST of {start, end}; each range recurs on the series' weekdays
67
+ (``daysOfWeek`` in flat-window mode, or the ``windowsByDay`` keys in per-day mode).
68
+ ``dateRange`` (singular) is accepted as sugar for a one-element list.
69
+ * ``oneOffDates`` — individual 'YYYY-MM-DD' dates added AS-IS (no weekday filter), for
70
+ one-off extras/exceptions outside the recurring pattern.
71
+ So "every Tue/Thu across June and September, plus Aug 15" is ONE spec (multiple ranges +
72
+ a one-off) — no need to run the script per range. Overlapping ranges / a one-off already
73
+ inside a range collapse: each date appears once.
74
+
75
+ ``excludeDates`` — an optional list of 'YYYY-MM-DD' dates to DROP from the union
76
+ (regardless of window). This is the in-memory removal ("skip the July 4th shift I was
77
+ about to create"); it is independent of ``excludeExisting`` below (which is org dedup by
78
+ full shift key, not date removal).
79
+
80
+ ``count`` — an optional integer >= 1 that bounds the series by NUMBER OF DATES instead of
81
+ (or as well as) an end date: the series keeps the first ``count`` dates of the union, after
82
+ ``excludeDates`` is applied, and every window on a kept date becomes a shift. With ``count``
83
+ a range may omit its ``end`` ("start 2027-06-05, 10 Saturdays"); with both an end and a
84
+ count the series stops at whichever comes first. A series always needs an end date or a
85
+ count, so an open-ended series can't be expressed. ``count`` is applied before
86
+ ``excludeExisting``, so it counts the dates the user asked for, not only the missing ones.
87
+
88
+ SPEC (stdin JSON)
89
+ -----------------
90
+ JobPositionShift — ranges + one-offs, uniform windows:
91
+ {
92
+ "entity": "JobPositionShift",
93
+ "parentId": "0Jp...", # literal JobPosition Id (or "parentRef")
94
+ "parentRef": "JobPosition_1", # @refId — only when the JobPosition is in the SAME tree file;
95
+ # a parent from an earlier import is always a literal parentId
96
+ "daysOfWeek": ["Tuesday", "Thursday"], # weekdays the ranges recur on
97
+ "windows": [{"start": "10:00", "end": "12:00"}, {"start": "14:00", "end": "16:00"}],
98
+ "dateRanges": [ # LIST of ranges (or "dateRange" for a single one)
99
+ {"start": "2027-06-01", "end": "2027-06-30"},
100
+ {"start": "2027-09-01", "end": "2027-09-30"}
101
+ ],
102
+ "oneOffDates": ["2027-08-15"], # optional individual dates outside the ranges
103
+ "excludeDates": ["2027-06-08"], # optional whole dates to drop (in-memory removal)
104
+ "timeZone": "America/New_York",
105
+ "maximumAttendeesCount": 8, # optional
106
+ "status": "Upcoming", # optional — omitted means "Upcoming", so pass the
107
+ # value the user confirmed in the review
108
+ "count": 10, # optional — keep only the first N dates
109
+ "referenceIdPrefix": "JPS", # optional
110
+ "excludeExisting": [ # optional — shifts already in the org; skip re-creating these
111
+ {"StartDate": "2027-06-01", "EndDate": "2027-06-01",
112
+ "StartTime": "10:00:00.000Z", "EndTime": "12:00:00.000Z"}
113
+ ]
114
+ }
115
+
116
+ JobPositionShift — per-day windows (windows vary by weekday):
117
+ {
118
+ "entity": "JobPositionShift",
119
+ "parentId": "0Jp...",
120
+ "windowsByDay": {
121
+ "Saturday": [{"start": "08:00", "end": "12:00"}, {"start": "12:00", "end": "17:00"}],
122
+ "Sunday": [{"start": "09:00", "end": "14:00"}]
123
+ },
124
+ "dateRange": {"start": "2027-06-05", "end": "2027-08-29"},
125
+ "timeZone": "America/New_York"
126
+ }
127
+
128
+ JobPositionShift — bounded by a count instead of an end date (10 Saturdays):
129
+ {
130
+ "entity": "JobPositionShift",
131
+ "parentId": "0Jp...",
132
+ "daysOfWeek": ["Saturday"],
133
+ "windows": [{"start": "09:00", "end": "12:00"}],
134
+ "dateRange": {"start": "2027-06-05"},
135
+ "count": 10,
136
+ "timeZone": "America/New_York"
137
+ }
138
+
139
+ ``oneOffDates`` may be used with either window form; with ``windowsByDay`` every date
140
+ (range-derived OR one-off) must fall on a weekday present in the map. A spec of ONLY
141
+ ``oneOffDates`` (no ranges) needs no ``daysOfWeek``.
142
+ {
143
+ "entity": "JobPositionShift",
144
+ "parentId": "0Jp...",
145
+ "oneOffDates": ["2026-09-06", "2026-09-20", "2026-10-11"],
146
+ "windows": [{"start": "12:00", "end": "16:00"}],
147
+ "timeZone": "America/New_York",
148
+ "maximumAttendeesCount": 30
149
+ }
150
+
151
+ excludeExisting (JobPositionShift only): a list of already-existing shifts to skip, matched
152
+ on the shift key ``JobPositionId + StartDate + EndDate + StartTime + EndTime``. A spec has one
153
+ JobPosition, so pass only that JobPosition's shifts and the four date/time fields are compared. Times accept "HH:MM",
154
+ "HH:MM:SS", or the Salesforce literal "HH:MM:SS.000Z" that a SOQL query returns, so a
155
+ query result can be passed straight through. ``EndDate`` defaults to ``StartDate`` if
156
+ omitted. If every generated shift is excluded, the script exits non-zero with a clear
157
+ "nothing to create" message rather than emitting an empty import.
158
+
159
+ TimeSlot (weekly windows — no dateRange; parent OperatingHours must be a LITERAL Id
160
+ because OperatingHoursId is master-detail). Supports uniform ``windows`` (+ ``daysOfWeek``)
161
+ or per-day ``windowsByDay``:
162
+ {
163
+ "entity": "TimeSlot",
164
+ "parentId": "0OH...", # literal OperatingHours Id (master-detail: no @ref)
165
+ "daysOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
166
+ "windows": [{"start": "10:00", "end": "12:00"}, {"start": "14:00", "end": "16:00"}],
167
+ "referenceIdPrefix": "TS" # optional
168
+ }
169
+
170
+ Exit non-zero with a message on any spec error (unknown entity, bad day name, bad
171
+ time/date, both/neither windows forms, empty result, TimeSlot given a @ref parent, etc.).
172
+ """
173
+
174
+ import argparse
175
+ import datetime as dt
176
+ import json
177
+ import os
178
+ import sys
179
+
180
+ TREE_IMPORT_CAP = 200 # Composite Tree API caps at 200 records per call.
181
+
182
+ DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
183
+ DAY_INDEX = {name: i for i, name in enumerate(DAY_NAMES)}
184
+
185
+
186
+ def fail(msg):
187
+ sys.stderr.write("expand-series: %s\n" % msg)
188
+ sys.exit(1)
189
+
190
+
191
+ def parse_time(value, label):
192
+ """Accept 'HH:MM', 'HH:MM:SS', or the Salesforce literal 'HH:MM:SS.mmmZ'
193
+ (as a SOQL query returns it) -> canonical 'HH:MM:SS.000Z'."""
194
+ if not isinstance(value, str):
195
+ fail("%s must be a string like '10:00' or '10:00:00'" % label)
196
+ parts = value.strip().split(":")
197
+ if len(parts) == 2:
198
+ parts.append("00")
199
+ if len(parts) != 3:
200
+ fail("%s '%s' is not HH:MM or HH:MM:SS" % (label, value))
201
+ # Tolerate a fractional-seconds / trailing-Z suffix on the seconds component so a
202
+ # Salesforce time literal ('12:00:00.000Z') round-trips into the same canonical form.
203
+ sec = parts[2].split(".")[0].rstrip("Zz")
204
+ try:
205
+ h, m, s = int(parts[0]), int(parts[1]), int(sec)
206
+ dt.time(h, m, s) # validate ranges
207
+ except ValueError:
208
+ fail("%s '%s' is not a valid time" % (label, value))
209
+ return "%02d:%02d:%02d.000Z" % (h, m, s)
210
+
211
+
212
+ def parse_date(value, label):
213
+ if not isinstance(value, str):
214
+ fail("%s must be a 'YYYY-MM-DD' string" % label)
215
+ try:
216
+ return dt.date.fromisoformat(value.strip())
217
+ except ValueError:
218
+ fail("%s '%s' is not a valid YYYY-MM-DD date" % (label, value))
219
+
220
+
221
+ def normalize_window_list(value, label, allow_span=True):
222
+ """A list of {start, end[, endDayOffset]} -> list of (start, end, offset).
223
+
224
+ ``endDayOffset`` (int >= 0, default 0) is how many days after StartDate the window
225
+ ENDS: 0 = same day, 1 = crosses midnight into the next day, N = an N-day span. Only
226
+ ``JobPositionShift`` supports it (it has a separate EndDate); ``TimeSlot`` windows are
227
+ single-weekday (``allow_span=False``) and reject a nonzero offset. When the offset is 0
228
+ the end must be after the start (a valid same-day window); once the window spans a later
229
+ day any end time is valid, because EndDate > StartDate already makes the end datetime
230
+ later than the start.
231
+ """
232
+ if not isinstance(value, list) or not value:
233
+ fail("%s must be a non-empty list of {start, end}" % label)
234
+ out = []
235
+ for w in value:
236
+ if not isinstance(w, dict) or "start" not in w or "end" not in w:
237
+ fail("each %s entry needs a 'start' and 'end' (HH:MM)" % label)
238
+ start = parse_time(w["start"], "%s.start" % label)
239
+ end = parse_time(w["end"], "%s.end" % label)
240
+ offset = w.get("endDayOffset", 0)
241
+ # bool is an int subclass — reject it so a stray `true` isn't read as offset 1.
242
+ if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
243
+ fail("%s endDayOffset must be an integer >= 0 "
244
+ "(0 = same day, 1 = next day, N = N-day span)" % label)
245
+ if offset and not allow_span:
246
+ fail("%s cannot span days — endDayOffset is only for JobPositionShift, "
247
+ "which has an EndDate (a TimeSlot is one weekday's window)" % label)
248
+ if offset == 0 and end <= start:
249
+ fail("%s end %s must be after start %s — for a shift that crosses midnight or "
250
+ "spans days, set endDayOffset (1 = next day, N = N-day span)"
251
+ % (label, w["end"], w["start"]))
252
+ out.append((start, end, offset))
253
+ return out
254
+
255
+
256
+ def normalize_windows_by_day(raw, allow_span=True):
257
+ """A map dayName -> [{start,end}] -> {dayName: [(start,end,offset)]}."""
258
+ if not isinstance(raw, dict) or not raw:
259
+ fail("windowsByDay must be a non-empty map of dayName -> [{start, end}]")
260
+ out = {}
261
+ for day, wins in raw.items():
262
+ if day not in DAY_INDEX:
263
+ fail("unknown day '%s' in windowsByDay — use %s" % (day, ", ".join(DAY_NAMES)))
264
+ out[day] = normalize_window_list(wins, "windowsByDay['%s']" % day, allow_span)
265
+ return out
266
+
267
+
268
+ def resolve_windows(spec, allow_span=True):
269
+ """Return ('flat', [(start,end,offset)]) or ('by_day', {day: [(start,end,offset)]}).
270
+
271
+ Exactly one of ``windows`` / ``windowsByDay`` must be supplied. ``allow_span`` gates the
272
+ per-window ``endDayOffset`` (JobPositionShift only); TimeSlot passes ``allow_span=False``.
273
+ """
274
+ flat = spec.get("windows")
275
+ by_day = spec.get("windowsByDay")
276
+ if (flat is None) == (by_day is None):
277
+ fail("supply exactly one of 'windows' (uniform) or 'windowsByDay' (per-day)")
278
+ if flat is not None:
279
+ return "flat", normalize_window_list(flat, "windows", allow_span)
280
+ return "by_day", normalize_windows_by_day(by_day, allow_span)
281
+
282
+
283
+ def normalize_days(spec):
284
+ days = spec.get("daysOfWeek")
285
+ if not isinstance(days, list) or not days:
286
+ fail("daysOfWeek must be a non-empty list of day names")
287
+ out = []
288
+ for d in days:
289
+ if d not in DAY_INDEX:
290
+ fail("unknown day '%s' — use %s" % (d, ", ".join(DAY_NAMES)))
291
+ out.append(d)
292
+ return out
293
+
294
+
295
+ def dates_in_range(date_range, wanted_days, open_limit=None):
296
+ """Dates in ``date_range`` that fall on ``wanted_days``.
297
+
298
+ A range without an ``end`` is only valid when the spec sets ``count``; it then yields
299
+ ``open_limit`` matching dates from its start, which is enough to fill ``count`` after
300
+ ``excludeDates`` removes any of them.
301
+ """
302
+ start = parse_date(date_range.get("start"), "dateRange.start")
303
+ end = None
304
+ if date_range.get("end") is None:
305
+ if open_limit is None:
306
+ fail("dateRange.end is required unless the spec sets 'count' — a series needs "
307
+ "an end date or a count")
308
+ else:
309
+ end = parse_date(date_range.get("end"), "dateRange.end")
310
+ if end < start:
311
+ fail("dateRange.end must be on/after dateRange.start")
312
+ wanted = {DAY_INDEX[d] for d in wanted_days}
313
+ out = []
314
+ cur = start
315
+ one = dt.timedelta(days=1)
316
+ while end is None or cur <= end:
317
+ if end is None and len(out) >= open_limit:
318
+ break
319
+ if cur.weekday() in wanted:
320
+ out.append(cur)
321
+ cur += one
322
+ return out
323
+
324
+
325
+ def parse_count(spec):
326
+ """``count`` (optional int >= 1) — the number of series dates to keep."""
327
+ count = spec.get("count")
328
+ if count is None:
329
+ return None
330
+ # bool is an int subclass — reject it so a stray `true` isn't read as 1.
331
+ if isinstance(count, bool) or not isinstance(count, int) or count < 1:
332
+ fail("count must be an integer >= 1 (the number of dates in the series)")
333
+ return count
334
+
335
+
336
+ def parent_value(spec, entity):
337
+ literal = spec.get("parentId")
338
+ ref = spec.get("parentRef")
339
+ if literal and ref:
340
+ fail("supply only one of parentId / parentRef")
341
+ if literal:
342
+ return literal
343
+ if ref:
344
+ if entity == "TimeSlot":
345
+ fail("TimeSlot.OperatingHoursId is master-detail — the parent must be a "
346
+ "literal Id (parentId), not a @ref. Create OperatingHours first.")
347
+ return "@" + str(ref).lstrip("@")
348
+ fail("supply a parentId (literal) or parentRef (@refId) for the parent")
349
+
350
+
351
+ def resolve_shift_dates(spec, mode, windows):
352
+ """Return the ordered, de-duplicated list of dt.date for a shift series.
353
+
354
+ The date set is the UNION of two optional sources (supply at least one):
355
+ * ``dateRanges`` — a list of {start, end} ranges, each expanded on the series'
356
+ recurring weekdays (``daysOfWeek`` in flat mode, or the ``windowsByDay`` keys in
357
+ per-day mode). ``dateRange`` (singular) is accepted as sugar for a one-element list.
358
+ * ``oneOffDates`` — individual 'YYYY-MM-DD' dates added AS-IS (no weekday filter);
359
+ use for one-off extras/exceptions that fall outside the recurring pattern.
360
+ ``excludeDates`` (optional) then removes whole dates from the union — an in-memory
361
+ removal (e.g. a holiday), independent of ``excludeExisting`` (which is org-dedup by
362
+ the full shift key). Overlapping ranges and a one-off that also lands in a range both
363
+ collapse — a date appears once. ``count`` (optional) finally keeps the first N dates.
364
+ """
365
+ if spec.get("dates") is not None:
366
+ fail("'dates' was renamed for clarity — use 'oneOffDates' for individual dates "
367
+ "and 'dateRanges' (or 'dateRange') for recurring ranges")
368
+
369
+ # Collect ranges: singular sugar + list form.
370
+ ranges = []
371
+ single = spec.get("dateRange")
372
+ if single is not None:
373
+ if not isinstance(single, dict):
374
+ fail("dateRange must be a single {start, end} object; for multiple ranges use 'dateRanges'")
375
+ ranges.append(single)
376
+ many = spec.get("dateRanges")
377
+ if many is not None:
378
+ if not isinstance(many, list) or not many:
379
+ fail("dateRanges must be a non-empty list of {start, end} objects")
380
+ for r in many:
381
+ if not isinstance(r, dict):
382
+ fail("each dateRanges entry must be a {start, end} object")
383
+ ranges.extend(many)
384
+
385
+ one_offs = spec.get("oneOffDates")
386
+ if one_offs is not None and (not isinstance(one_offs, list) or not one_offs):
387
+ fail("oneOffDates must be a non-empty list of 'YYYY-MM-DD' strings")
388
+
389
+ if not ranges and one_offs is None:
390
+ fail("JobPositionShift needs 'dateRanges' (or 'dateRange') and/or 'oneOffDates'")
391
+
392
+ # Weekdays the ranges recur on.
393
+ if mode == "by_day":
394
+ if spec.get("daysOfWeek") is not None:
395
+ fail("windowsByDay already defines the weekdays — do not also pass daysOfWeek")
396
+ wanted_days = list(windows.keys())
397
+ else:
398
+ wanted_days = normalize_days(spec) if ranges else None
399
+
400
+ count = parse_count(spec)
401
+ excl = spec.get("excludeDates")
402
+ if excl is not None and not isinstance(excl, list):
403
+ fail("excludeDates must be a list of 'YYYY-MM-DD' strings")
404
+ open_limit = count + len(excl or []) if count is not None else None
405
+
406
+ collected = set()
407
+ for r in ranges:
408
+ for day in dates_in_range(r, wanted_days, open_limit):
409
+ collected.add(day)
410
+ if one_offs is not None:
411
+ seen = set()
412
+ for d in one_offs:
413
+ day = parse_date(d, "oneOffDates[]")
414
+ if day in seen:
415
+ fail("duplicate date '%s' in oneOffDates" % d)
416
+ seen.add(day)
417
+ collected.add(day)
418
+
419
+ # Per-day mode: every date (incl. one-offs) must map to a windowsByDay entry.
420
+ if mode == "by_day":
421
+ for day in collected:
422
+ name = DAY_NAMES[day.weekday()]
423
+ if name not in windows:
424
+ fail("date %s falls on %s, which has no entry in windowsByDay"
425
+ % (day.isoformat(), name))
426
+
427
+ if excl is not None:
428
+ for d in excl:
429
+ collected.discard(parse_date(d, "excludeDates[]"))
430
+
431
+ ordered = sorted(collected)
432
+ return ordered[:count] if count is not None else ordered
433
+
434
+
435
+ def normalize_exclusions(spec):
436
+ """Build the set of already-existing shift keys to skip.
437
+
438
+ Key = (StartDate, EndDate, StartTime, EndTime), all canonicalized to the same form
439
+ the generator emits so a SOQL query result matches byte-for-byte. EndDate
440
+ defaults to StartDate when omitted (single-day shift).
441
+ """
442
+ raw = spec.get("excludeExisting")
443
+ if raw is None:
444
+ return set()
445
+ if not isinstance(raw, list):
446
+ fail("excludeExisting must be a list of {StartDate, EndDate, StartTime, EndTime}")
447
+ out = set()
448
+ for i, e in enumerate(raw):
449
+ if not isinstance(e, dict):
450
+ fail("excludeExisting[%d] must be an object" % i)
451
+ sd = parse_date(e.get("StartDate"), "excludeExisting[%d].StartDate" % i).isoformat()
452
+ ed_raw = e.get("EndDate")
453
+ ed = parse_date(ed_raw, "excludeExisting[%d].EndDate" % i).isoformat() if ed_raw else sd
454
+ st = parse_time(e.get("StartTime"), "excludeExisting[%d].StartTime" % i)
455
+ et = parse_time(e.get("EndTime"), "excludeExisting[%d].EndTime" % i)
456
+ out.add((sd, ed, st, et))
457
+ return out
458
+
459
+
460
+ def build_shifts(spec):
461
+ """Return (records, generated_count, skipped_count).
462
+
463
+ ``generated_count`` is every (date x window) the series describes; ``skipped_count`` is
464
+ how many of those were dropped as already-existing (excludeExisting). ``records`` is
465
+ what remains to import.
466
+ """
467
+ mode, windows = resolve_windows(spec)
468
+ shift_dates = resolve_shift_dates(spec, mode, windows)
469
+ tz = spec.get("timeZone")
470
+ if not tz:
471
+ fail("JobPositionShift needs a timeZone (IANA, e.g. America/New_York)")
472
+ parent = parent_value(spec, "JobPositionShift")
473
+ status = spec.get("status", "Upcoming")
474
+ cap = spec.get("maximumAttendeesCount")
475
+ prefix = spec.get("referenceIdPrefix", "JPS")
476
+ exclude = normalize_exclusions(spec)
477
+
478
+ records = []
479
+ generated = 0
480
+ skipped = 0
481
+ n = 0
482
+ seen = set()
483
+ for day in shift_dates:
484
+ iso = day.isoformat()
485
+ day_windows = windows if mode == "flat" else windows[DAY_NAMES[day.weekday()]]
486
+ for (start, end, offset) in day_windows:
487
+ # EndDate = StartDate + offset days: 0 = single-day, 1 = crosses midnight, N =
488
+ # an N-day span. The key mirrors the org natural key (JobPositionId is fixed per
489
+ # spec) so a queried existing shift matches exactly.
490
+ end_iso = (day + dt.timedelta(days=offset)).isoformat()
491
+ key = (iso, end_iso, start, end)
492
+ if key in seen:
493
+ continue # exact-duplicate (dates, window) — never emit the same shift twice
494
+ seen.add(key)
495
+ generated += 1
496
+ if key in exclude:
497
+ skipped += 1
498
+ continue
499
+ n += 1
500
+ fields = {
501
+ "attributes": {"type": "JobPositionShift",
502
+ "referenceId": "%s_%d" % (prefix, n)},
503
+ "JobPositionId": parent,
504
+ "Status": status,
505
+ "StartDate": iso,
506
+ "EndDate": end_iso,
507
+ "StartTime": start,
508
+ "EndTime": end,
509
+ "TimeZone": tz,
510
+ }
511
+ if cap is not None:
512
+ fields["MaximumAttendeesCount"] = cap
513
+ records.append(fields)
514
+ return records, generated, skipped
515
+
516
+
517
+ def build_timeslots(spec):
518
+ mode, windows = resolve_windows(spec, allow_span=False)
519
+ if spec.get("dateRange"):
520
+ fail("TimeSlot is a weekly window set — do not pass a dateRange")
521
+ if spec.get("dates"):
522
+ fail("TimeSlot is a weekly window set — do not pass a 'dates' list "
523
+ "(that belongs to a JobPositionShift spec)")
524
+ if spec.get("count") is not None:
525
+ fail("TimeSlot is a weekly window set — 'count' belongs to a JobPositionShift spec")
526
+ parent = parent_value(spec, "TimeSlot") # enforces literal Id
527
+ prefix = spec.get("referenceIdPrefix", "TS")
528
+
529
+ if mode == "flat":
530
+ seen_day, days = set(), []
531
+ for d in normalize_days(spec):
532
+ if d not in seen_day: # collapse a day listed twice → no duplicate slots
533
+ seen_day.add(d)
534
+ days.append(d)
535
+ day_windows = [(d, windows) for d in days]
536
+ else:
537
+ if spec.get("daysOfWeek") is not None:
538
+ fail("windowsByDay already defines the days — do not also pass daysOfWeek")
539
+ day_windows = [(d, windows[d]) for d in windows.keys()]
540
+
541
+ records = []
542
+ n = 0
543
+ seen = set()
544
+ for day, wins in day_windows:
545
+ for (start, end, _offset) in wins: # offset is always 0 here (allow_span=False)
546
+ if (day, start, end) in seen:
547
+ continue # exact-duplicate (day, window) — never emit the same slot twice
548
+ seen.add((day, start, end))
549
+ n += 1
550
+ records.append({
551
+ "attributes": {"type": "TimeSlot",
552
+ "referenceId": "%s_%d" % (prefix, n)},
553
+ "OperatingHoursId": parent,
554
+ "DayOfWeek": day,
555
+ "StartTime": start,
556
+ "EndTime": end,
557
+ })
558
+ if not records:
559
+ fail("no time slots produced — check daysOfWeek / windows")
560
+ return records
561
+
562
+
563
+ def chunk(records, size):
564
+ return [records[i:i + size] for i in range(0, len(records), size)]
565
+
566
+
567
+ def main():
568
+ ap = argparse.ArgumentParser(description="Expand a VM shift/time-slot series to tree-import JSON.")
569
+ ap.add_argument("--out-dir", help="write one JSON file per <=200-record batch here; "
570
+ "print the paths instead of the JSON")
571
+ args = ap.parse_args()
572
+
573
+ raw = sys.stdin.read()
574
+ if not raw.strip():
575
+ fail("no spec on stdin — pipe a JSON spec (see the module docstring)")
576
+ try:
577
+ spec = json.loads(raw)
578
+ except json.JSONDecodeError as e:
579
+ fail("spec is not valid JSON: %s" % e)
580
+
581
+ if not isinstance(spec, dict):
582
+ fail("spec must be a JSON object (got %s) — see the module docstring" % type(spec).__name__)
583
+
584
+ entity = spec.get("entity")
585
+ if entity == "JobPositionShift":
586
+ records, generated, skipped = build_shifts(spec)
587
+ if generated == 0:
588
+ fail("no shifts produced — check dateRanges/oneOffDates against the weekdays "
589
+ "(daysOfWeek or windowsByDay), or whether excludeDates removed them all")
590
+ if not records:
591
+ fail("all %d requested shift(s) already exist (excludeExisting); nothing to create"
592
+ % generated)
593
+ if skipped:
594
+ sys.stderr.write("expand-series: excluded %d already-existing shift(s); "
595
+ "%d to create\n" % (skipped, len(records)))
596
+ elif entity == "TimeSlot":
597
+ records = build_timeslots(spec)
598
+ else:
599
+ fail("entity must be 'JobPositionShift' or 'TimeSlot' (got %r)" % entity)
600
+
601
+ batches = chunk(records, TREE_IMPORT_CAP)
602
+
603
+ if args.out_dir:
604
+ os.makedirs(args.out_dir, exist_ok=True)
605
+ paths = []
606
+ for i, batch in enumerate(batches, 1):
607
+ path = os.path.join(args.out_dir, "%s-%02d.json" % (entity, i))
608
+ with open(path, "w") as fh:
609
+ json.dump({"records": batch}, fh, indent=2)
610
+ fh.write("\n")
611
+ paths.append(path)
612
+ sys.stderr.write("expand-series: %d record(s) -> %d file(s)\n"
613
+ % (len(records), len(paths)))
614
+ print("\n".join(paths))
615
+ else:
616
+ # stdout carries a SINGLE importable tree object ({records:[...]}). Once the
617
+ # series splits past the 200-record cap, no single stdout payload is valid
618
+ # tree-import input, so require --out-dir and write one importable file per
619
+ # batch rather than emitting a JSON array a tree call cannot read.
620
+ if len(batches) > 1:
621
+ fail("%d record(s) exceed the %d-record tree-import cap (%d batches). "
622
+ "Re-run with --out-dir <dir> to write one importable file per batch, "
623
+ "then import each file separately."
624
+ % (len(records), TREE_IMPORT_CAP, len(batches)))
625
+ json.dump({"records": batches[0]}, sys.stdout, indent=2)
626
+ sys.stdout.write("\n")
627
+ sys.stderr.write("expand-series: %d record(s) in %d batch(es)\n"
628
+ % (len(records), len(batches)))
629
+
630
+
631
+ if __name__ == "__main__":
632
+ main()