@anthusai/papyrus 1.0.0-next.8 → 1.0.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.
- package/amplify/auth/redirect-urls.js +54 -0
- package/amplify/auth/redirect-urls.ts +66 -0
- package/amplify/auth/resource.js +20 -5
- package/amplify/auth/resource.ts +27 -5
- package/amplify/data/resource.js +42 -80
- package/amplify/data/resource.ts +42 -80
- package/amplify/data/schema.js +45 -60
- package/amplify/data/schema.ts +47 -60
- package/amplify/functions/category-action/handler.ts +5 -3
- package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
- package/amplify/functions/console-chat-responder/resource.js +0 -7
- package/amplify/functions/console-chat-responder/resource.ts +0 -8
- package/amplify/functions/console-chat-responder/src/main.rs +0 -9
- package/amplify/functions/content-actions/handler.py +143 -1
- package/amplify/functions/content-actions/requirements.txt +2 -1
- package/amplify/functions/content-actions/resource.js +6 -1
- package/amplify/functions/content-actions/resource.ts +9 -2
- package/amplify/functions/renditions/handler.py +120 -0
- package/amplify/functions/renditions/requirements.txt +4 -0
- package/amplify/functions/shared/python-bundle.js +42 -2
- package/amplify/functions/shared/python-bundle.ts +47 -2
- package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
- package/amplify/inbound-email/ses-intake.js +86 -0
- package/amplify/inbound-email/ses-intake.ts +110 -0
- package/amplify/plugins/registry.js +19 -0
- package/amplify/plugins/registry.ts +23 -0
- package/amplify/plugins/signup/function/handler.py +200 -0
- package/amplify/plugins/signup/function/resource.js +27 -0
- package/amplify/plugins/signup/function/resource.ts +35 -0
- package/amplify/plugins/signup/papyrus.plugin.js +59 -0
- package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
- package/amplify/plugins/signup/schema.js +40 -0
- package/amplify/plugins/signup/schema.ts +41 -0
- package/amplify/plugins/signup/waf.js +48 -0
- package/amplify/plugins/signup/waf.ts +51 -0
- package/amplify/plugins/types.js +1 -0
- package/amplify/plugins/types.ts +39 -0
- package/amplify/plugins/wire.js +24 -0
- package/amplify/plugins/wire.ts +26 -0
- package/amplify/publication/default-publication.js +18 -0
- package/amplify/publication/default-publication.ts +20 -0
- package/amplify/publication/invalidation-alarm.js +26 -0
- package/amplify/publication/invalidation-alarm.ts +30 -0
- package/amplify/publication/redirect-and-index.js +37 -0
- package/amplify/publication/renditions-function.js +74 -0
- package/amplify/publication/renditions-function.ts +112 -0
- package/amplify/publication/renditions-settings.js +51 -0
- package/amplify/publication/renditions-settings.ts +58 -0
- package/amplify/publication/reproject-on-deploy.js +73 -0
- package/amplify/publication/reproject-on-deploy.ts +78 -0
- package/amplify/publication/s3-projection-settings.js +67 -0
- package/amplify/publication/s3-projection-settings.ts +101 -0
- package/amplify/publication/s3-projection.js +151 -0
- package/amplify/publication/s3-projection.ts +201 -0
- package/amplify/resolver-waves.js +26 -0
- package/amplify/resolver-waves.ts +34 -0
- package/amplify/revalidate-invalidation-settings.js +30 -0
- package/amplify/revalidate-invalidation-settings.ts +35 -0
- package/amplify/revalidate-secret-policy.js +13 -0
- package/amplify/revalidate-secret-policy.ts +19 -0
- package/amplify/site-backend-config.js +65 -1
- package/amplify/site-backend-config.ts +153 -2
- package/amplify/site-backend-names.js +103 -0
- package/amplify/site-backend-names.ts +166 -0
- package/amplify/site-backend.js +143 -176
- package/amplify/site-backend.ts +181 -201
- package/amplify/storage-backups/construct.js +31 -0
- package/amplify/storage-backups/construct.ts +47 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
- package/app/[year]/[month]/[day]/page.tsx +1 -1
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
- package/app/api/archive/editions/route.ts +1 -1
- package/app/api/cyclotron/priming-proof/route.ts +11 -0
- package/app/api/media/[...path]/route.ts +5 -16
- package/app/api/revalidate/route.ts +15 -7
- package/app/archive/page.tsx +1 -1
- package/app/articles/[slug]/page.tsx +7 -5
- package/app/blog-rhythm.css +278 -0
- package/app/cyclotron/page.tsx +13 -0
- package/app/globals.css +2 -0
- package/app/layout.tsx +16 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
- package/app/newsroom/[section]/page.tsx +6 -3
- package/app/newsroom/layout.tsx +4 -9
- package/app/newsroom/settings/page.tsx +6 -0
- package/app/newsroom/work/page.tsx +10 -0
- package/app/page.tsx +22 -8
- package/app/robots.ts +2 -5
- package/app/tailwind.css +1 -0
- package/bin/papyrus-infra.mjs +3 -3
- package/components/amplify-client-provider.tsx +5 -22
- package/components/archive-shell.tsx +2 -22
- package/components/article-video.tsx +152 -5
- package/components/blog-page-background.tsx +23 -6
- package/components/news-desk-page.tsx +4 -15
- package/components/news-desk-taxonomy-client.ts +90 -3
- package/components/newspaper.tsx +6 -2
- package/components/newsroom-access-gate.tsx +2 -1
- package/components/newsroom-board-view.tsx +99 -0
- package/components/newsroom-client-shell.tsx +12 -0
- package/components/newsroom-curation-provider.tsx +17 -0
- package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-OFL.txt +93 -0
- package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +93 -0
- package/components/newsroom-newsprint-board.css +48 -0
- package/components/newsroom-ops-overview.tsx +28 -11
- package/components/newsroom-ops-shell.tsx +7 -1
- package/components/newsroom-organizer-shell.tsx +130 -0
- package/components/newsroom-organizer.css +63 -0
- package/components/newsroom-references-view.tsx +92 -8
- package/components/papyrus-console-shell.tsx +4 -2
- package/components/pictogram-figure.tsx +9 -2
- package/components/presentation-header.tsx +135 -0
- package/components/presentation-rhythm-hrule.tsx +17 -0
- package/components/priming-proof-page.tsx +92 -0
- package/components/publication-cyclotron-panel.tsx +57 -0
- package/components/reader-auth-control.tsx +8 -5
- package/components/reference-curation-controls.tsx +77 -0
- package/components/reference-cyclotron-status.tsx +109 -0
- package/components/reference-priming.tsx +18 -0
- package/components/reference-relevance-review.tsx +75 -0
- package/components/settings-page.tsx +2 -1
- package/components/signup-entries-panel.tsx +94 -0
- package/components/topic-steering-workspace.tsx +78 -6
- package/components/use-blog-header-obstacles.ts +81 -0
- package/components/use-brand-fonts-ready.ts +28 -0
- package/components/use-near-viewport.ts +30 -0
- package/components/use-rhythm-overlay.ts +29 -0
- package/infra/amplify-app-shell.js +219 -71
- package/infra/amplify-app-shell.ts +231 -77
- package/infra/build-specs.js +23 -8
- package/infra/build-specs.ts +27 -11
- package/infra/site-config.js +277 -34
- package/infra/site-config.ts +340 -43
- package/lib/amplify-outputs-path.ts +5 -1
- package/lib/amplify-server-runtime.ts +36 -1
- package/lib/approved-priming-proof-reports.ts +6 -0
- package/lib/blog-feature-solver.ts +459 -0
- package/lib/blog-rhythm.ts +134 -0
- package/lib/brand-fonts-watch.ts +43 -0
- package/lib/brand-fonts.ts +112 -0
- package/lib/cached-content-repository.ts +12 -0
- package/lib/category-repository.ts +43 -93
- package/lib/content-item-lifecycle.ts +55 -0
- package/lib/content-repository.ts +3 -0
- package/lib/content-types.ts +4 -0
- package/lib/curation-client.ts +61 -0
- package/lib/edition-routes.ts +31 -29
- package/lib/graphql-content-repository.ts +56 -22
- package/lib/media-route-response.ts +45 -0
- package/lib/newspaper-layout.ts +3 -2
- package/lib/newsroom-base-path.ts +12 -3
- package/lib/newsroom-board.ts +99 -0
- package/lib/newsroom-category-drilldown.ts +3 -2
- package/lib/newsroom-forum-routes.ts +6 -4
- package/lib/newsroom-index-filters.ts +14 -10
- package/lib/newsroom-nav.ts +3 -1
- package/lib/newsroom-organizer-nav.ts +37 -0
- package/lib/newsroom-section-routing.ts +20 -0
- package/lib/oauth-redirect-priority.ts +40 -0
- package/lib/papyrus-web-locations.ts +7 -6
- package/lib/plugin-settings.ts +11 -0
- package/lib/presentation-footer.ts +3 -2
- package/lib/pretext-layout.ts +12 -3
- package/lib/priming-proof-report.ts +89 -0
- package/lib/prospectus-site-identity.ts +303 -0
- package/lib/publication-cyclotron.ts +74 -0
- package/lib/rate-limit-retry.ts +55 -0
- package/lib/reader-base-path.ts +24 -0
- package/lib/reader-cache-lifetime.ts +5 -0
- package/lib/reader-static-params.ts +85 -4
- package/lib/reader-storage-url.ts +9 -12
- package/lib/reference-policy.ts +5 -0
- package/lib/relevance-decisions.ts +103 -0
- package/lib/relevance-status.ts +63 -0
- package/lib/renderer.ts +7 -0
- package/lib/revalidate-prewarm.ts +53 -0
- package/lib/revalidate-secret.ts +59 -0
- package/lib/robots-policy.ts +14 -0
- package/lib/root-route-redirect.ts +16 -0
- package/lib/semantic-graph.ts +7 -0
- package/lib/shared-credentials-provider.ts +61 -0
- package/lib/signup-csv.ts +24 -0
- package/lib/site-brand.ts +59 -0
- package/lib/staging-gated-path.ts +23 -0
- package/lib/staging-preview-object.ts +21 -5
- package/lib/test-editor-override.ts +4 -0
- package/lib/video-loading.ts +22 -0
- package/lib/video-script.ts +14 -0
- package/middleware.ts +11 -19
- package/package.json +64 -1
- package/renderers/pretext/article-page.tsx +39 -3
- package/renderers/pretext/presentation-shared.tsx +113 -0
- package/renderers/pretext/presentation-shell.tsx +15 -102
- package/renderers/pretext/rhythm-article-page.tsx +165 -0
- package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
- package/renderers/pretext/rhythm-shell-style.ts +35 -0
- package/routes.manifest.json +68 -12
- package/src/sync.mjs +30 -6
- package/src/with-papyrus.d.mts +9 -0
- package/src/with-papyrus.mjs +48 -3
- package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
- package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
- package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
|
@@ -4,8 +4,17 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
+
from papyrus_content.edge_invalidation import distribution_ids_from_environment, invalidate_revalidated_paths
|
|
7
8
|
from papyrus_content.graphql_authoring import create_authoring_client
|
|
8
9
|
from papyrus_content.markus_renderer.derive import derive_body
|
|
10
|
+
from papyrus_content.projection.deploy_render import deploy_render, drift_metric_line
|
|
11
|
+
from papyrus_content.projection.engine import project, project_all, project_item_pages
|
|
12
|
+
from papyrus_content.projection.runtime import (
|
|
13
|
+
DEAD_LETTER_QUEUE_ENV,
|
|
14
|
+
context_from_environment,
|
|
15
|
+
projection_configured,
|
|
16
|
+
public_item_url,
|
|
17
|
+
)
|
|
9
18
|
from papyrus_content.rebuild_trigger import (
|
|
10
19
|
DEFAULT_STAGING_BRANCH,
|
|
11
20
|
reader_target_from_environment,
|
|
@@ -56,6 +65,127 @@ def derive_markus_action(arguments: dict) -> dict:
|
|
|
56
65
|
return response
|
|
57
66
|
|
|
58
67
|
|
|
68
|
+
PROJECTION_TASK_KEY = "projectionTask"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ProjectionTaskFailed(Exception):
|
|
72
|
+
"""Raised by an asynchronous projection task so Lambda retries it and, finally, sends it to the dead letter queue."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def send_to_projection_dead_letter_queue(task: dict, error: str) -> None:
|
|
76
|
+
queue_url = (os.environ.get(DEAD_LETTER_QUEUE_ENV) or "").strip()
|
|
77
|
+
if not queue_url:
|
|
78
|
+
return
|
|
79
|
+
try:
|
|
80
|
+
import boto3
|
|
81
|
+
|
|
82
|
+
boto3.client("sqs").send_message(
|
|
83
|
+
QueueUrl=queue_url, MessageBody=json.dumps({PROJECTION_TASK_KEY: task, "error": error})
|
|
84
|
+
)
|
|
85
|
+
except Exception: # noqa: BLE001 - the dead letter is best effort; the response already reports the failure
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def schedule_projection_task(task: dict, error: str) -> bool:
|
|
90
|
+
"""Run a projection task later, in this same function, as an asynchronous invocation.
|
|
91
|
+
|
|
92
|
+
The function has an on-failure destination (the dead letter queue), so a task
|
|
93
|
+
that keeps failing ends up there for ``papyrus ops content project --reconcile``.
|
|
94
|
+
"""
|
|
95
|
+
function_name = (os.environ.get("AWS_LAMBDA_FUNCTION_NAME") or "").strip()
|
|
96
|
+
if not function_name:
|
|
97
|
+
send_to_projection_dead_letter_queue(task, error)
|
|
98
|
+
return False
|
|
99
|
+
try:
|
|
100
|
+
import boto3
|
|
101
|
+
|
|
102
|
+
boto3.client("lambda").invoke(
|
|
103
|
+
FunctionName=function_name, InvocationType="Event", Payload=json.dumps({PROJECTION_TASK_KEY: task}).encode("utf-8")
|
|
104
|
+
)
|
|
105
|
+
return True
|
|
106
|
+
except Exception as scheduling_error: # noqa: BLE001 - fall back to the dead letter queue
|
|
107
|
+
send_to_projection_dead_letter_queue(task, f"{error}; scheduling failed: {scheduling_error}")
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def projection_context(client):
|
|
112
|
+
context = context_from_environment(client)
|
|
113
|
+
context.defer_follow_up = True
|
|
114
|
+
return context
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def project_item_action(client, item_id: str, *, unpublished: bool) -> dict:
|
|
118
|
+
if not projection_configured():
|
|
119
|
+
return {}
|
|
120
|
+
liveness = {} if unpublished else {"live": False}
|
|
121
|
+
task = {"task": "item", "itemId": item_id, "unpublished": unpublished}
|
|
122
|
+
try:
|
|
123
|
+
report = project(projection_context(client), item_id, unpublished=unpublished)
|
|
124
|
+
except Exception as error: # noqa: BLE001 - a projection failure never fails the save
|
|
125
|
+
message = f"{type(error).__name__}: {error}"
|
|
126
|
+
schedule_projection_task(task, message)
|
|
127
|
+
return {**liveness, "projected": False, "paths": [], "error": message}
|
|
128
|
+
response = {"projected": report.ok, "paths": [*report.written, *report.deleted]}
|
|
129
|
+
if not unpublished:
|
|
130
|
+
response["live"] = report.ok
|
|
131
|
+
url = public_item_url(os.environ, report.item_paths)
|
|
132
|
+
if url:
|
|
133
|
+
response["url"] = url
|
|
134
|
+
if not report.ok:
|
|
135
|
+
message = "; ".join(report.errors)
|
|
136
|
+
schedule_projection_task(task, message)
|
|
137
|
+
response["error"] = message
|
|
138
|
+
if report.unpurged:
|
|
139
|
+
response["edgePurged"] = False
|
|
140
|
+
schedule_unpurged_invalidation(report.unpurged)
|
|
141
|
+
if report.follow_up_item_pages:
|
|
142
|
+
schedule_projection_task({"task": "item-pages", "exceptItemId": item_id}, "follow-up")
|
|
143
|
+
return response
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def schedule_unpurged_invalidation(keys: list[str]) -> None:
|
|
147
|
+
schedule_projection_task({"task": "invalidate", "keys": keys}, "edge invalidation failed")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def run_deploy_render_task(client) -> dict:
|
|
151
|
+
"""Render every published page after a backend deploy, then report drift (a dry run: nothing is deleted)."""
|
|
152
|
+
context = projection_context(client)
|
|
153
|
+
report = deploy_render(context)
|
|
154
|
+
print(drift_metric_line(report))
|
|
155
|
+
if report.render.unpurged:
|
|
156
|
+
schedule_unpurged_invalidation(report.render.unpurged)
|
|
157
|
+
if not report.ok:
|
|
158
|
+
raise ProjectionTaskFailed("; ".join([*report.render.errors, *report.drift.errors]))
|
|
159
|
+
return report.to_dict()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def run_projection_task(task: dict) -> dict:
|
|
163
|
+
"""An asynchronous projection task. Raising makes Lambda retry it, then send the event to the dead letter queue."""
|
|
164
|
+
client, _claims = create_authoring_client()
|
|
165
|
+
if task.get("task") == "item-pages":
|
|
166
|
+
report = project_item_pages(projection_context(client), except_item_id=task.get("exceptItemId"))
|
|
167
|
+
elif task.get("task") == "item":
|
|
168
|
+
report = project(projection_context(client), task["itemId"], unpublished=bool(task.get("unpublished")))
|
|
169
|
+
if report.follow_up_item_pages:
|
|
170
|
+
schedule_projection_task({"task": "item-pages", "exceptItemId": task["itemId"]}, "follow-up")
|
|
171
|
+
elif task.get("task") == "all":
|
|
172
|
+
report = project_all(projection_context(client))
|
|
173
|
+
elif task.get("task") == "deploy-render":
|
|
174
|
+
return run_deploy_render_task(client)
|
|
175
|
+
elif task.get("task") == "invalidate":
|
|
176
|
+
invalidate = projection_context(client).invalidate
|
|
177
|
+
if invalidate is None:
|
|
178
|
+
return {"ok": True, "paths": []}
|
|
179
|
+
return {"ok": True, "paths": invalidate(list(task["keys"]))}
|
|
180
|
+
else:
|
|
181
|
+
raise ProjectionTaskFailed(f"Unknown projection task {task.get('task')!r}")
|
|
182
|
+
if report.unpurged:
|
|
183
|
+
schedule_unpurged_invalidation(report.unpurged)
|
|
184
|
+
if not report.ok:
|
|
185
|
+
raise ProjectionTaskFailed("; ".join(report.errors))
|
|
186
|
+
return {"ok": True, "paths": [*report.written, *report.deleted]}
|
|
187
|
+
|
|
188
|
+
|
|
59
189
|
def save_item_draft_action(arguments: dict, actor: str) -> dict:
|
|
60
190
|
client, _claims = create_authoring_client()
|
|
61
191
|
fields = ItemFields(
|
|
@@ -68,7 +198,7 @@ def save_item_draft_action(arguments: dict, actor: str) -> dict:
|
|
|
68
198
|
id=arguments.get("id"),
|
|
69
199
|
)
|
|
70
200
|
record = save_item(client, fields, actor=actor, expected_content_hash=arguments.get("expectedContentHash"))
|
|
71
|
-
|
|
201
|
+
response = {
|
|
72
202
|
"ok": True,
|
|
73
203
|
"item": {
|
|
74
204
|
"id": record["id"],
|
|
@@ -78,6 +208,11 @@ def save_item_draft_action(arguments: dict, actor: str) -> dict:
|
|
|
78
208
|
"versionNumber": record.get("versionNumber"),
|
|
79
209
|
},
|
|
80
210
|
}
|
|
211
|
+
if record["status"] == "published" and projection_configured():
|
|
212
|
+
published = publish_item(client, record["id"], actor=actor)
|
|
213
|
+
response["item"]["versionNumber"] = published.version_number
|
|
214
|
+
response.update(project_item_action(client, record["id"], unpublished=False))
|
|
215
|
+
return response
|
|
81
216
|
|
|
82
217
|
|
|
83
218
|
def site_triggers_for(client, item_id: str) -> dict:
|
|
@@ -90,6 +225,9 @@ def site_triggers_for(client, item_id: str) -> dict:
|
|
|
90
225
|
slugs = [item["slug"]] if item.get("slug") else []
|
|
91
226
|
revalidated = trigger_pretext_revalidation(slugs, None, base_url=revalidate_base_url)
|
|
92
227
|
triggers["revalidated"] = revalidated if revalidated is not None else {"ok": False, "reason": "not-configured"}
|
|
228
|
+
distribution_ids = distribution_ids_from_environment(os.environ)
|
|
229
|
+
if distribution_ids and revalidated is not None:
|
|
230
|
+
triggers["edgeInvalidated"] = invalidate_revalidated_paths(distribution_ids, revalidated)
|
|
93
231
|
except Exception as error: # noqa: BLE001 - a trigger failure never fails the publish
|
|
94
232
|
triggers["revalidated"] = {"ok": False, "error": f"{type(error).__name__}: {error}"}
|
|
95
233
|
if reader_app_id:
|
|
@@ -108,6 +246,7 @@ def publish_item_action(arguments: dict, actor: str) -> dict:
|
|
|
108
246
|
}
|
|
109
247
|
if result.changed:
|
|
110
248
|
response.update(site_triggers_for(client, arguments["id"]))
|
|
249
|
+
response.update(project_item_action(client, arguments["id"], unpublished=False))
|
|
111
250
|
return response
|
|
112
251
|
|
|
113
252
|
|
|
@@ -117,6 +256,7 @@ def unpublish_item_action(arguments: dict, actor: str) -> dict:
|
|
|
117
256
|
response = {"ok": True, "changed": result.changed}
|
|
118
257
|
if result.changed:
|
|
119
258
|
response.update(site_triggers_for(client, arguments["id"]))
|
|
259
|
+
response.update(project_item_action(client, arguments["id"], unpublished=True))
|
|
120
260
|
return response
|
|
121
261
|
|
|
122
262
|
|
|
@@ -130,6 +270,8 @@ def request_staging_build_action() -> dict:
|
|
|
130
270
|
|
|
131
271
|
|
|
132
272
|
def handler(event, context):
|
|
273
|
+
if PROJECTION_TASK_KEY in event:
|
|
274
|
+
return run_projection_task(event[PROJECTION_TASK_KEY])
|
|
133
275
|
field = event["fieldName"]
|
|
134
276
|
arguments = parse_input(event["arguments"]["input"])
|
|
135
277
|
actor = actor_from_event(event)
|
|
@@ -7,11 +7,15 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { pythonLambdaCode } from "../shared/python-bundle.js";
|
|
8
8
|
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
const projectRoot = path.resolve(dirname, "../../..");
|
|
10
|
-
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=
|
|
10
|
+
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
|
|
11
11
|
function installCheckoutRequirements(outputDir) {
|
|
12
12
|
const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
|
|
13
13
|
execSync(`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`, { cwd: projectRoot, stdio: "inherit" });
|
|
14
14
|
}
|
|
15
|
+
let siteFilesForNextContentActionsBundle;
|
|
16
|
+
export function setSiteFilesForContentActionsBundle(siteFiles) {
|
|
17
|
+
siteFilesForNextContentActionsBundle = siteFiles;
|
|
18
|
+
}
|
|
15
19
|
export const contentActions = defineFunction((scope) => {
|
|
16
20
|
return new Function(scope, "papyrus-content-actions", {
|
|
17
21
|
runtime: Runtime.PYTHON_3_12,
|
|
@@ -25,6 +29,7 @@ export const contentActions = defineFunction((scope) => {
|
|
|
25
29
|
requirements: contentActionsRequirements,
|
|
26
30
|
noDeps: true,
|
|
27
31
|
checkoutExtras: installCheckoutRequirements,
|
|
32
|
+
...(siteFilesForNextContentActionsBundle !== undefined ? { siteFiles: siteFilesForNextContentActionsBundle } : {}),
|
|
28
33
|
}, [
|
|
29
34
|
"bash",
|
|
30
35
|
"-c",
|
|
@@ -5,12 +5,12 @@ import { Construct } from "constructs";
|
|
|
5
5
|
import { execSync } from "node:child_process";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { pythonLambdaCode } from "../shared/python-bundle";
|
|
8
|
+
import { pythonLambdaCode, type SiteBundleFile } from "../shared/python-bundle";
|
|
9
9
|
|
|
10
10
|
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
const projectRoot = path.resolve(dirname, "../../..");
|
|
12
12
|
|
|
13
|
-
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=
|
|
13
|
+
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
|
|
14
14
|
|
|
15
15
|
function installCheckoutRequirements(outputDir: string): void {
|
|
16
16
|
const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
|
|
@@ -20,6 +20,12 @@ function installCheckoutRequirements(outputDir: string): void {
|
|
|
20
20
|
);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
let siteFilesForNextContentActionsBundle: SiteBundleFile[] | undefined;
|
|
24
|
+
|
|
25
|
+
export function setSiteFilesForContentActionsBundle(siteFiles: SiteBundleFile[] | undefined): void {
|
|
26
|
+
siteFilesForNextContentActionsBundle = siteFiles;
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
export const contentActions = defineFunction(
|
|
24
30
|
(scope: Construct) => {
|
|
25
31
|
return new Function(scope, "papyrus-content-actions", {
|
|
@@ -35,6 +41,7 @@ export const contentActions = defineFunction(
|
|
|
35
41
|
requirements: contentActionsRequirements,
|
|
36
42
|
noDeps: true,
|
|
37
43
|
checkoutExtras: installCheckoutRequirements,
|
|
44
|
+
...(siteFilesForNextContentActionsBundle !== undefined ? { siteFiles: siteFilesForNextContentActionsBundle } : {}),
|
|
38
45
|
},
|
|
39
46
|
[
|
|
40
47
|
"bash",
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from papyrus_content.projection.runtime import SITE_BUCKET_ENV, SITE_STATIC_DIRECTORY_ENV
|
|
10
|
+
from papyrus_content.renditions import (
|
|
11
|
+
RenditionSettings,
|
|
12
|
+
S3RenditionStore,
|
|
13
|
+
make_renditions,
|
|
14
|
+
media_originals_enabled,
|
|
15
|
+
meta_key,
|
|
16
|
+
original_site_key,
|
|
17
|
+
ORIGINAL_CACHE_CONTROL,
|
|
18
|
+
settings_from_static_directory,
|
|
19
|
+
source_from_media_key,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_loaded: dict[str, Any] = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def configured_settings() -> RenditionSettings | None:
|
|
26
|
+
if "settings" not in _loaded:
|
|
27
|
+
static_directory = (os.environ.get(SITE_STATIC_DIRECTORY_ENV) or "").strip()
|
|
28
|
+
_loaded["settings"] = settings_from_static_directory(Path(static_directory) if static_directory else None)
|
|
29
|
+
return _loaded["settings"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def originals_enabled() -> bool:
|
|
33
|
+
if "originals" not in _loaded:
|
|
34
|
+
static_directory = (os.environ.get(SITE_STATIC_DIRECTORY_ENV) or "").strip()
|
|
35
|
+
_loaded["originals"] = media_originals_enabled(Path(static_directory) if static_directory else None)
|
|
36
|
+
return _loaded["originals"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def site_store() -> S3RenditionStore:
|
|
40
|
+
if "store" not in _loaded:
|
|
41
|
+
_loaded["store"] = S3RenditionStore(os.environ[SITE_BUCKET_ENV])
|
|
42
|
+
return _loaded["store"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def s3_client():
|
|
46
|
+
if "s3" not in _loaded:
|
|
47
|
+
import boto3
|
|
48
|
+
|
|
49
|
+
_loaded["s3"] = boto3.client("s3")
|
|
50
|
+
return _loaded["s3"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def media_objects(event: dict) -> list[tuple[str, str]]:
|
|
54
|
+
if event.get("source") == "aws.s3" and "detail" in event:
|
|
55
|
+
detail = event["detail"]
|
|
56
|
+
return [(detail["bucket"]["name"], detail["object"]["key"])]
|
|
57
|
+
if "bucket" in event and "key" in event:
|
|
58
|
+
return [(event["bucket"], event["key"])]
|
|
59
|
+
raise ValueError("The event is neither an S3 object event nor a {bucket, key} request.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def render_media_object(
|
|
63
|
+
settings: RenditionSettings | None,
|
|
64
|
+
bucket: str,
|
|
65
|
+
key: str,
|
|
66
|
+
*,
|
|
67
|
+
originals: bool = False,
|
|
68
|
+
skip_existing: bool = False,
|
|
69
|
+
dry_run: bool = False,
|
|
70
|
+
) -> dict:
|
|
71
|
+
source = source_from_media_key(key) if settings is not None else None
|
|
72
|
+
original_key = original_site_key(key) if originals else None
|
|
73
|
+
if source is None and original_key is None:
|
|
74
|
+
return {"key": key, "action": "ignored", "reason": "not an image under media/"}
|
|
75
|
+
if skip_existing:
|
|
76
|
+
renditions_exist = source is None or site_store().get(meta_key(settings.pipeline, source)) is not None
|
|
77
|
+
original_exists = original_key is None or site_store().get(original_key) is not None
|
|
78
|
+
if renditions_exist and original_exists:
|
|
79
|
+
return {"key": key, "source": source or original_key, "action": "skipped-existing"}
|
|
80
|
+
if dry_run:
|
|
81
|
+
return {"key": key, "source": source or original_key, "action": "would-render" if source else "would-copy"}
|
|
82
|
+
started = time.perf_counter()
|
|
83
|
+
data = s3_client().get_object(Bucket=bucket, Key=key)["Body"].read()
|
|
84
|
+
downloaded = time.perf_counter()
|
|
85
|
+
if original_key is not None:
|
|
86
|
+
site_store().put(original_key, data, cache_control=ORIGINAL_CACHE_CONTROL)
|
|
87
|
+
if source is None:
|
|
88
|
+
return {"key": key, "source": original_key, "action": "copied", "written": 1, "sourceBytes": len(data)}
|
|
89
|
+
result = make_renditions(settings, site_store(), source, data)
|
|
90
|
+
finished = time.perf_counter()
|
|
91
|
+
return {
|
|
92
|
+
"key": key,
|
|
93
|
+
"source": source,
|
|
94
|
+
"action": "rendered",
|
|
95
|
+
"kind": result["kind"],
|
|
96
|
+
"written": result["written"] + (1 if original_key is not None else 0),
|
|
97
|
+
"sourceBytes": len(data),
|
|
98
|
+
"downloadMilliseconds": round((downloaded - started) * 1000),
|
|
99
|
+
"renditionMilliseconds": round((finished - downloaded) * 1000),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def handler(event, context):
|
|
104
|
+
settings = configured_settings()
|
|
105
|
+
originals = originals_enabled()
|
|
106
|
+
if settings is None and not originals:
|
|
107
|
+
return {"ok": True, "results": [], "skipped": "this site has no images or media settings in projection.json"}
|
|
108
|
+
results = [
|
|
109
|
+
render_media_object(
|
|
110
|
+
settings,
|
|
111
|
+
bucket,
|
|
112
|
+
key,
|
|
113
|
+
originals=originals,
|
|
114
|
+
skip_existing=event.get("skipExisting") is True,
|
|
115
|
+
dry_run=event.get("dryRun") is True,
|
|
116
|
+
)
|
|
117
|
+
for bucket, key in media_objects(event)
|
|
118
|
+
]
|
|
119
|
+
print(json.dumps({"renditions": results}))
|
|
120
|
+
return {"ok": True, "results": results}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AssetHashType, DockerImage } from "aws-cdk-lib";
|
|
2
2
|
import { Code, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import { execFileSync } from "node:child_process";
|
|
4
5
|
import fs from "node:fs";
|
|
5
6
|
import path from "node:path";
|
|
@@ -41,6 +42,39 @@ export function toPep440(version) {
|
|
|
41
42
|
throw new Error(`Cannot map ${version} to a PEP 440 version`);
|
|
42
43
|
return m[2] === undefined ? m[1] : `${m[1]}.dev${m[2]}`;
|
|
43
44
|
}
|
|
45
|
+
function isBytecodeArtifact(sourcePath) {
|
|
46
|
+
return sourcePath.split(path.sep).includes("__pycache__") || sourcePath.endsWith(".pyc");
|
|
47
|
+
}
|
|
48
|
+
export function copySiteFiles(outputDir, siteFiles) {
|
|
49
|
+
for (const siteFile of siteFiles ?? []) {
|
|
50
|
+
const sourcePath = path.resolve(process.cwd(), siteFile.from);
|
|
51
|
+
if (!fs.existsSync(sourcePath))
|
|
52
|
+
throw new Error(`Site file to bundle does not exist: ${siteFile.from}`);
|
|
53
|
+
const destinationPath = path.join(outputDir, siteFile.to);
|
|
54
|
+
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
|
55
|
+
fs.cpSync(sourcePath, destinationPath, { recursive: true, filter: (candidate) => !isBytecodeArtifact(candidate) });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function listFilesRecursively(root) {
|
|
59
|
+
if (fs.statSync(root).isFile())
|
|
60
|
+
return [root];
|
|
61
|
+
return fs.readdirSync(root).sort().flatMap((entry) => listFilesRecursively(path.join(root, entry)))
|
|
62
|
+
.filter((candidate) => !isBytecodeArtifact(candidate));
|
|
63
|
+
}
|
|
64
|
+
export function siteFilesContentHash(siteFiles) {
|
|
65
|
+
const hash = createHash("sha256");
|
|
66
|
+
for (const siteFile of siteFiles ?? []) {
|
|
67
|
+
const sourcePath = path.resolve(process.cwd(), siteFile.from);
|
|
68
|
+
hash.update(`${siteFile.to}\0`);
|
|
69
|
+
if (!fs.existsSync(sourcePath))
|
|
70
|
+
continue;
|
|
71
|
+
for (const filePath of listFilesRecursively(sourcePath)) {
|
|
72
|
+
hash.update(`${path.relative(sourcePath, filePath)}\0`);
|
|
73
|
+
hash.update(fs.readFileSync(filePath));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return hash.digest("hex").slice(0, 16);
|
|
77
|
+
}
|
|
44
78
|
function pipSpec() {
|
|
45
79
|
return (process.env.PAPYRUS_PIP_SPEC ?? "").trim() || `papyrus-newsroom==${toPep440(papyrusVersion())}`;
|
|
46
80
|
}
|
|
@@ -77,6 +111,7 @@ function bundleFromPip(outputDir, options) {
|
|
|
77
111
|
], { stdio: "inherit" });
|
|
78
112
|
}
|
|
79
113
|
fs.copyFileSync(path.join(packageRoot, "amplify/functions", options.functionDir, "handler.py"), path.join(outputDir, "handler.py"));
|
|
114
|
+
copySiteFiles(outputDir, options.siteFiles);
|
|
80
115
|
if (options.corpora) {
|
|
81
116
|
const corporaDir = path.join(outputDir, "corpora");
|
|
82
117
|
fs.mkdirSync(corporaDir, { recursive: true });
|
|
@@ -96,6 +131,7 @@ export function bundleFromCheckout(outputDir, options) {
|
|
|
96
131
|
fs.cpSync(path.join(packageRoot, "src", module), path.join(outputDir, module), { recursive: true });
|
|
97
132
|
}
|
|
98
133
|
options.checkoutExtras?.(outputDir);
|
|
134
|
+
copySiteFiles(outputDir, options.siteFiles);
|
|
99
135
|
}
|
|
100
136
|
/**
|
|
101
137
|
* CDK Code for a Python Lambda. In package mode the asset hash is derived
|
|
@@ -103,14 +139,18 @@ export function bundleFromCheckout(outputDir, options) {
|
|
|
103
139
|
*/
|
|
104
140
|
export function pythonLambdaCode(options, dockerCommand) {
|
|
105
141
|
const packageMode = isPackageMode();
|
|
142
|
+
const hasSiteFiles = (options.siteFiles ?? []).length > 0;
|
|
143
|
+
const siteFilesSuffix = hasSiteFiles ? `:site-${siteFilesContentHash(options.siteFiles)}` : "";
|
|
106
144
|
return Code.fromAsset(packageRoot, {
|
|
107
145
|
...(packageMode
|
|
108
146
|
? {
|
|
109
147
|
assetHashType: AssetHashType.CUSTOM,
|
|
110
|
-
assetHash: `${options.functionDir}:${pipSpec()}:${(options.requirements ?? []).join(",")}`
|
|
148
|
+
assetHash: `${options.functionDir}:${pipSpec()}:${(options.requirements ?? []).join(",")}${siteFilesSuffix}`
|
|
111
149
|
.replace(/[^a-zA-Z0-9._:=,-]/g, "_"),
|
|
112
150
|
}
|
|
113
|
-
:
|
|
151
|
+
: hasSiteFiles
|
|
152
|
+
? { assetHashType: AssetHashType.OUTPUT }
|
|
153
|
+
: {}),
|
|
114
154
|
bundling: {
|
|
115
155
|
image: Runtime.PYTHON_3_12.bundlingImage ?? DockerImage.fromRegistry("public.ecr.aws/sam/build-python3.12"),
|
|
116
156
|
local: {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AssetHashType, DockerImage } from "aws-cdk-lib";
|
|
2
2
|
import { Code, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import { execFileSync } from "node:child_process";
|
|
4
5
|
import fs from "node:fs";
|
|
5
6
|
import path from "node:path";
|
|
@@ -57,8 +58,46 @@ export type PythonBundleOptions = {
|
|
|
57
58
|
noDeps?: boolean;
|
|
58
59
|
/** Copy `<cwd>/corpora/*.yml` into the asset (publication-owned steering config). */
|
|
59
60
|
corpora?: boolean;
|
|
61
|
+
/** Site repo files copied into the asset; `from` is relative to the current working directory, `to` to the asset root. A directory is copied recursively. */
|
|
62
|
+
siteFiles?: SiteBundleFile[];
|
|
60
63
|
};
|
|
61
64
|
|
|
65
|
+
export type SiteBundleFile = { from: string; to: string };
|
|
66
|
+
|
|
67
|
+
function isBytecodeArtifact(sourcePath: string): boolean {
|
|
68
|
+
return sourcePath.split(path.sep).includes("__pycache__") || sourcePath.endsWith(".pyc");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function copySiteFiles(outputDir: string, siteFiles: SiteBundleFile[] | undefined): void {
|
|
72
|
+
for (const siteFile of siteFiles ?? []) {
|
|
73
|
+
const sourcePath = path.resolve(process.cwd(), siteFile.from);
|
|
74
|
+
if (!fs.existsSync(sourcePath)) throw new Error(`Site file to bundle does not exist: ${siteFile.from}`);
|
|
75
|
+
const destinationPath = path.join(outputDir, siteFile.to);
|
|
76
|
+
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
|
77
|
+
fs.cpSync(sourcePath, destinationPath, { recursive: true, filter: (candidate) => !isBytecodeArtifact(candidate) });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function listFilesRecursively(root: string): string[] {
|
|
82
|
+
if (fs.statSync(root).isFile()) return [root];
|
|
83
|
+
return fs.readdirSync(root).sort().flatMap((entry) => listFilesRecursively(path.join(root, entry)))
|
|
84
|
+
.filter((candidate) => !isBytecodeArtifact(candidate));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function siteFilesContentHash(siteFiles: SiteBundleFile[] | undefined): string {
|
|
88
|
+
const hash = createHash("sha256");
|
|
89
|
+
for (const siteFile of siteFiles ?? []) {
|
|
90
|
+
const sourcePath = path.resolve(process.cwd(), siteFile.from);
|
|
91
|
+
hash.update(`${siteFile.to}\0`);
|
|
92
|
+
if (!fs.existsSync(sourcePath)) continue;
|
|
93
|
+
for (const filePath of listFilesRecursively(sourcePath)) {
|
|
94
|
+
hash.update(`${path.relative(sourcePath, filePath)}\0`);
|
|
95
|
+
hash.update(fs.readFileSync(filePath));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return hash.digest("hex").slice(0, 16);
|
|
99
|
+
}
|
|
100
|
+
|
|
62
101
|
function pipSpec(): string {
|
|
63
102
|
return (process.env.PAPYRUS_PIP_SPEC ?? "").trim() || `papyrus-newsroom==${toPep440(papyrusVersion())}`;
|
|
64
103
|
}
|
|
@@ -107,6 +146,7 @@ function bundleFromPip(outputDir: string, options: PythonBundleOptions): void {
|
|
|
107
146
|
path.join(packageRoot, "amplify/functions", options.functionDir, "handler.py"),
|
|
108
147
|
path.join(outputDir, "handler.py"),
|
|
109
148
|
);
|
|
149
|
+
copySiteFiles(outputDir, options.siteFiles);
|
|
110
150
|
if (options.corpora) {
|
|
111
151
|
const corporaDir = path.join(outputDir, "corpora");
|
|
112
152
|
fs.mkdirSync(corporaDir, { recursive: true });
|
|
@@ -129,6 +169,7 @@ export function bundleFromCheckout(outputDir: string, options: PythonBundleOptio
|
|
|
129
169
|
fs.cpSync(path.join(packageRoot, "src", module), path.join(outputDir, module), { recursive: true });
|
|
130
170
|
}
|
|
131
171
|
options.checkoutExtras?.(outputDir);
|
|
172
|
+
copySiteFiles(outputDir, options.siteFiles);
|
|
132
173
|
}
|
|
133
174
|
|
|
134
175
|
/**
|
|
@@ -137,14 +178,18 @@ export function bundleFromCheckout(outputDir: string, options: PythonBundleOptio
|
|
|
137
178
|
*/
|
|
138
179
|
export function pythonLambdaCode(options: PythonBundleOptions, dockerCommand: string[]): Code {
|
|
139
180
|
const packageMode = isPackageMode();
|
|
181
|
+
const hasSiteFiles = (options.siteFiles ?? []).length > 0;
|
|
182
|
+
const siteFilesSuffix = hasSiteFiles ? `:site-${siteFilesContentHash(options.siteFiles)}` : "";
|
|
140
183
|
return Code.fromAsset(packageRoot, {
|
|
141
184
|
...(packageMode
|
|
142
185
|
? {
|
|
143
186
|
assetHashType: AssetHashType.CUSTOM,
|
|
144
|
-
assetHash: `${options.functionDir}:${pipSpec()}:${(options.requirements ?? []).join(",")}`
|
|
187
|
+
assetHash: `${options.functionDir}:${pipSpec()}:${(options.requirements ?? []).join(",")}${siteFilesSuffix}`
|
|
145
188
|
.replace(/[^a-zA-Z0-9._:=,-]/g, "_"),
|
|
146
189
|
}
|
|
147
|
-
:
|
|
190
|
+
: hasSiteFiles
|
|
191
|
+
? { assetHashType: AssetHashType.OUTPUT }
|
|
192
|
+
: {}),
|
|
148
193
|
bundling: {
|
|
149
194
|
image: Runtime.PYTHON_3_12.bundlingImage ?? DockerImage.fromRegistry("public.ecr.aws/sam/build-python3.12"),
|
|
150
195
|
local: {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata stored with an editor's reference curation Message. When the
|
|
3
|
+
* relevance cyclotron decided the reference, the review records which
|
|
4
|
+
* decision it answered and whether its explanation may be shared (for the
|
|
5
|
+
* Cyclotron marketing recording). Both are optional.
|
|
6
|
+
*/
|
|
7
|
+
export type ReferenceCurationMetadataInput = {
|
|
8
|
+
action: string
|
|
9
|
+
curationStatus: string
|
|
10
|
+
reasonCode: string | null
|
|
11
|
+
referenceId: string
|
|
12
|
+
referenceLineageId: string
|
|
13
|
+
decisionRelationId?: string | null
|
|
14
|
+
shareable?: boolean | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function referenceCurationMetadata(input: ReferenceCurationMetadataInput): Record<string, unknown> {
|
|
18
|
+
const decisionRelationId = input.decisionRelationId?.trim() || null
|
|
19
|
+
return {
|
|
20
|
+
action: input.action,
|
|
21
|
+
curationStatus: input.curationStatus,
|
|
22
|
+
reasonCode: input.reasonCode,
|
|
23
|
+
curationReasonCode: input.reasonCode,
|
|
24
|
+
referenceId: input.referenceId,
|
|
25
|
+
referenceLineageId: input.referenceLineageId,
|
|
26
|
+
...(decisionRelationId ? {decisionRelationId} : {}),
|
|
27
|
+
...(decisionRelationId ? {shareable: input.shareable === true} : {}),
|
|
28
|
+
}
|
|
29
|
+
}
|