Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
12
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: split AutomationForm into per-action editor registry

Hugo bda42715 0f65e0b4

+1132 -1063
+1 -1
app/components/LexiconFlow/index.tsx
··· 1 1 import { ArrowRight, Webhook } from "../../icons.ts"; 2 2 import { nsidToDomain } from "../../../lib/lexicons/resolver.ts"; 3 3 import { type Action } from "../../../lib/db/schema.ts"; 4 - import { isRecordProducingAction } from "../../../lib/actions/registry.ts"; 4 + import { isRecordProducingAction } from "../../islands/action-editors/registry.ts"; 5 5 import { FOLLOW_TARGETS } from "../../../lib/automations/follow-targets.ts"; 6 6 import { Favicon, NsidCode } from "../NsidCode/index.tsx"; 7 7 import * as s from "./styles.css.ts";
+31 -959
app/islands/AutomationForm.tsx
··· 2 2 import type { RecordSchema, SchemaNode } from "../../lib/lexicons/schema-types.js"; 3 3 import { nsidRequiresWantedDids } from "../../lib/lexicons/match.js"; 4 4 import { type Action, type FetchStep, type FollowTarget } from "../../lib/db/schema.js"; 5 - import { isRecordProducingAction } from "../../lib/actions/registry.js"; 6 5 import { 7 6 ACTION_CATALOGUE, 8 7 actionTypeKey, 9 8 type AddableActionId, 10 9 } from "../../lib/automations/action-catalogue.js"; 11 - import { FOLLOW_TARGETS } from "../../lib/automations/follow-targets.js"; 12 10 import { SCOPE_INSUFFICIENT, redirectToScopeUpgrade } from "../../lib/auth/scope-errors.js"; 13 - import RecordFormBuilder from "./RecordFormBuilder.js"; 14 11 import { ActionHeader } from "../components/ActionHeader/index.js"; 15 12 import { actionIcon } from "../styles/action-header.css.ts"; 16 13 import * as s from "./AutomationForm.css.ts"; 14 + import { 15 + ACTION_UI_REGISTRY, 16 + isRecordProducingAction, 17 + type ActionDraft, 18 + } from "./action-editors/registry.ts"; 19 + import type { ForEachDraft } from "./action-editors/types.ts"; 17 20 18 21 type Field = { 19 22 path: string; ··· 38 41 comment: string; 39 42 }; 40 43 41 - type ForEachDraft = { 42 - path: string; 43 - conditions: Condition[]; 44 - }; 45 - 46 44 type FetchDraft = { 47 45 kind: "record" | "search"; 48 46 name: string; ··· 54 52 conditions: Condition[]; 55 53 comment: string; 56 54 }; 57 - 58 - type HeaderDraft = { key: string; value: string }; 59 - type WebhookDraft = { 60 - type: "webhook"; 61 - callbackUrl: string; 62 - headers: HeaderDraft[]; 63 - comment: string; 64 - forEach?: ForEachDraft; 65 - }; 66 - type RecordDraft = { 67 - type: "record"; 68 - targetCollection: string; 69 - recordTemplate: string; 70 - comment: string; 71 - forEach?: ForEachDraft; 72 - }; 73 - type BskyPostDraft = { 74 - type: "bsky-post"; 75 - textTemplate: string; 76 - langsText: string; 77 - labels: string[]; 78 - comment: string; 79 - forEach?: ForEachDraft; 80 - }; 81 - type PatchRecordDraft = { 82 - type: "patch-record"; 83 - targetCollection: string; 84 - baseRecordUri: string; 85 - recordTemplate: string; 86 - comment: string; 87 - forEach?: ForEachDraft; 88 - }; 89 - type MarginBookmarkDraft = { 90 - type: "margin-bookmark"; 91 - targetSource: string; 92 - bodyValue: string; 93 - tagsText: string; 94 - comment: string; 95 - forEach?: ForEachDraft; 96 - }; 97 - type FollowDraft = { 98 - type: "follow"; 99 - target: FollowTarget; 100 - subject: string; 101 - comment: string; 102 - forEach?: ForEachDraft; 103 - }; 104 - type SembleSaveDraft = { 105 - type: "semble-save"; 106 - url: string; 107 - comment: string; 108 - forEach?: ForEachDraft; 109 - }; 110 - type ActionDraft = 111 - | WebhookDraft 112 - | RecordDraft 113 - | BskyPostDraft 114 - | PatchRecordDraft 115 - | MarginBookmarkDraft 116 - | FollowDraft 117 - | SembleSaveDraft; 118 55 119 56 export type AutomationInitial = { 120 57 rkey?: string; ··· 307 244 } 308 245 309 246 // --------------------------------------------------------------------------- 310 - // Webhook action editor 311 - // --------------------------------------------------------------------------- 312 - 313 - function WebhookActionEditor({ 314 - action, 315 - index, 316 - onChange, 317 - }: { 318 - action: WebhookDraft; 319 - index: number; 320 - onChange: (a: WebhookDraft) => void; 321 - }) { 322 - const updateHeader = (i: number, key: "key" | "value", val: string) => { 323 - const headers = action.headers.map((h, j) => (j === i ? { ...h, [key]: val } : h)); 324 - onChange({ ...action, headers }); 325 - }; 326 - const addHeader = () => { 327 - onChange({ ...action, headers: [...action.headers, { key: "", value: "" }] }); 328 - }; 329 - const removeHeader = (i: number) => { 330 - onChange({ ...action, headers: action.headers.filter((_, j) => j !== i) }); 331 - }; 332 - 333 - const callbackId = `action-${index}-callback-url`; 334 - const headersGroupId = `action-${index}-headers-group`; 335 - 336 - return ( 337 - <> 338 - <div class={s.fieldGroup}> 339 - <label class={s.label} for={callbackId}> 340 - Callback URL 341 - </label> 342 - <input 343 - id={callbackId} 344 - class={s.input} 345 - type="url" 346 - placeholder="e.g. https://example.com/hooks/events" 347 - value={action.callbackUrl} 348 - onInput={(e: Event) => 349 - onChange({ ...action, callbackUrl: (e.target as HTMLInputElement).value }) 350 - } 351 - required 352 - autocomplete="off" 353 - /> 354 - </div> 355 - <div class={s.fieldGroup} role="group" aria-labelledby={headersGroupId}> 356 - <span id={headersGroupId} class={s.label}> 357 - Custom headers 358 - </span> 359 - <span class={s.hint}> 360 - Use <code>{"{{secret:name}}"}</code> to reference stored secrets 361 - </span> 362 - {action.headers.map((header, i) => ( 363 - <div key={i} class={s.conditionRow}> 364 - <div class={s.conditionField}> 365 - <input 366 - class={s.input} 367 - type="text" 368 - placeholder="e.g. Authorization" 369 - value={header.key} 370 - onInput={(e: Event) => updateHeader(i, "key", (e.target as HTMLInputElement).value)} 371 - aria-label="Header name" 372 - autocomplete="off" 373 - /> 374 - </div> 375 - <div class={s.conditionValue}> 376 - <input 377 - class={s.input} 378 - type="text" 379 - placeholder="e.g. Bearer {{secret:my-token}}" 380 - value={header.value} 381 - onInput={(e: Event) => 382 - updateHeader(i, "value", (e.target as HTMLInputElement).value) 383 - } 384 - aria-label="Header value" 385 - autocomplete="off" 386 - /> 387 - </div> 388 - <button type="button" class={s.removeBtn} onClick={() => removeHeader(i)}> 389 - Remove 390 - </button> 391 - </div> 392 - ))} 393 - <button type="button" class={s.addBtn} onClick={addHeader}> 394 - + Add Header 395 - </button> 396 - </div> 397 - </> 398 - ); 399 - } 400 - 401 - // --------------------------------------------------------------------------- 402 - // Shared NSID schema hook for record action editors 403 - // --------------------------------------------------------------------------- 404 - 405 - function useNsidSchema(initialCollection?: string) { 406 - const [targetSchema, setTargetSchema] = useState<RecordSchema | null>(null); 407 - const [targetSchemaLoading, setTargetSchemaLoading] = useState(false); 408 - const [targetSchemaError, setTargetSchemaError] = useState(""); 409 - const [nsidSuggestions, setNsidSuggestions] = useState<string[]>([]); 410 - const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 411 - const suggestDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 412 - const abortRef = useRef<AbortController | null>(null); 413 - const lastSuggestPrefix = useRef(""); 414 - const [datalistId] = useState(() => `nsid-${Math.random().toString(36).slice(2, 8)}`); 415 - const initialFetched = useRef(false); 416 - 417 - const fetchTargetSchema = useCallback((nsid: string) => { 418 - if (debounceRef.current) clearTimeout(debounceRef.current); 419 - if (!nsid || !NSID_RE.test(nsid)) { 420 - abortRef.current?.abort(); 421 - setTargetSchema(null); 422 - setTargetSchemaError(""); 423 - return; 424 - } 425 - debounceRef.current = setTimeout(async () => { 426 - abortRef.current?.abort(); 427 - const ctrl = new AbortController(); 428 - abortRef.current = ctrl; 429 - setTargetSchemaLoading(true); 430 - setTargetSchemaError(""); 431 - try { 432 - const res = await fetch(`/api/lexicons/${encodeURIComponent(nsid)}?schema=record`, { 433 - signal: ctrl.signal, 434 - }); 435 - const data = await res.json(); 436 - if (!res.ok) { 437 - setTargetSchemaError(data.error || "Failed to load schema"); 438 - setTargetSchema(null); 439 - } else { 440 - setTargetSchema(data.record ?? null); 441 - if (!data.record) setTargetSchemaError("No record schema found for this collection"); 442 - } 443 - } catch (err) { 444 - if ((err as Error).name === "AbortError") return; 445 - setTargetSchemaError("Failed to fetch target collection schema"); 446 - setTargetSchema(null); 447 - } finally { 448 - if (abortRef.current === ctrl) { 449 - abortRef.current = null; 450 - setTargetSchemaLoading(false); 451 - } 452 - } 453 - }, 400); 454 - }, []); 455 - 456 - const fetchSuggestions = useCallback((value: string) => { 457 - if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current); 458 - const dotIndex = value.lastIndexOf("."); 459 - const prefix = dotIndex > 0 ? value.slice(0, dotIndex + 1) : ""; 460 - if (!prefix || prefix.split(".").filter(Boolean).length < 2) return; 461 - if (prefix === lastSuggestPrefix.current) return; 462 - suggestDebounceRef.current = setTimeout(async () => { 463 - lastSuggestPrefix.current = prefix; 464 - try { 465 - const res = await fetch(`/api/lexicons/suggest?prefix=${encodeURIComponent(prefix)}`); 466 - if (res.ok) { 467 - const data = await res.json(); 468 - setNsidSuggestions(data.suggestions ?? []); 469 - } 470 - } catch { 471 - // ignore 472 - } 473 - }, 300); 474 - }, []); 475 - 476 - if (!initialFetched.current && initialCollection) { 477 - initialFetched.current = true; 478 - fetchTargetSchema(initialCollection); 479 - } 480 - 481 - return { 482 - targetSchema, 483 - targetSchemaLoading, 484 - targetSchemaError, 485 - nsidSuggestions, 486 - datalistId, 487 - fetchTargetSchema, 488 - fetchSuggestions, 489 - }; 490 - } 491 - 492 - // --------------------------------------------------------------------------- 493 - // Record action editor 494 - // --------------------------------------------------------------------------- 495 - 496 - function RecordActionEditor({ 497 - action, 498 - index, 499 - onChange, 500 - placeholders, 501 - }: { 502 - action: RecordDraft; 503 - index: number; 504 - onChange: (a: RecordDraft) => void; 505 - placeholders: string[]; 506 - }) { 507 - const { 508 - targetSchema, 509 - targetSchemaLoading, 510 - targetSchemaError, 511 - nsidSuggestions, 512 - datalistId, 513 - fetchTargetSchema, 514 - fetchSuggestions, 515 - } = useNsidSchema(action.targetCollection); 516 - 517 - const targetId = `action-${index}-target-collection`; 518 - const templateId = `action-${index}-record-template`; 519 - 520 - return ( 521 - <> 522 - <div class={s.fieldGroup}> 523 - <label class={s.label} for={targetId}> 524 - Target Lexicon NSID 525 - </label> 526 - <input 527 - id={targetId} 528 - class={s.input} 529 - type="text" 530 - list={datalistId} 531 - placeholder="e.g. app.bsky.feed.like" 532 - value={action.targetCollection} 533 - onInput={(e: Event) => { 534 - const val = (e.target as HTMLInputElement).value; 535 - onChange({ ...action, targetCollection: val }); 536 - fetchTargetSchema(val); 537 - fetchSuggestions(val); 538 - }} 539 - required 540 - autocomplete="off" 541 - /> 542 - <span class={s.hint}>NSID of the collection to create a record in</span> 543 - <datalist id={datalistId}> 544 - {nsidSuggestions.map((nsid) => ( 545 - <option key={nsid} value={nsid} /> 546 - ))} 547 - </datalist> 548 - </div> 549 - 550 - {(targetSchema || targetSchemaLoading) && ( 551 - <RecordFormBuilder 552 - schema={targetSchema} 553 - loading={targetSchemaLoading} 554 - error={targetSchemaError} 555 - placeholders={placeholders} 556 - initialTemplate={action.recordTemplate || undefined} 557 - onChange={(tpl) => onChange({ ...action, recordTemplate: tpl })} 558 - /> 559 - )} 560 - 561 - {!targetSchema && !targetSchemaLoading && action.targetCollection && ( 562 - <div class={s.fieldGroup}> 563 - <label class={s.label} for={templateId}> 564 - Record template 565 - </label> 566 - {targetSchemaError && <span class={s.errorText}>{targetSchemaError}</span>} 567 - <textarea 568 - id={templateId} 569 - class={s.textarea} 570 - placeholder={ 571 - '{\n "subject": {\n "uri": "{{event.commit.record.subject.uri}}",\n "cid": "{{event.commit.cid}}"\n },\n "createdAt": "{{now}}"\n}' 572 - } 573 - value={action.recordTemplate} 574 - onInput={(e: Event) => 575 - onChange({ 576 - ...action, 577 - recordTemplate: (e.target as HTMLTextAreaElement).value, 578 - }) 579 - } 580 - required 581 - autocomplete="off" 582 - /> 583 - </div> 584 - )} 585 - </> 586 - ); 587 - } 588 - 589 - // --------------------------------------------------------------------------- 590 - // Bluesky Post action editor 591 - // --------------------------------------------------------------------------- 592 - 593 - const BSKY_LABELS = [ 594 - { value: "sexual", label: "Suggestive" }, 595 - { value: "nudity", label: "Nudity" }, 596 - { value: "porn", label: "Adult Content" }, 597 - { value: "graphic-media", label: "Graphic Media" }, 598 - ]; 599 - 600 - function BskyPostActionEditor({ 601 - action, 602 - index, 603 - onChange, 604 - }: { 605 - action: BskyPostDraft; 606 - index: number; 607 - onChange: (a: BskyPostDraft) => void; 608 - }) { 609 - const textId = `action-${index}-bsky-text`; 610 - const langsId = `action-${index}-bsky-langs`; 611 - 612 - return ( 613 - <> 614 - <div class={s.fieldGroup}> 615 - <label class={s.label} for={textId}> 616 - Post text 617 - </label> 618 - <textarea 619 - id={textId} 620 - class={s.textarea} 621 - placeholder={"Write your post here...\nYou can use {{placeholders}}."} 622 - value={action.textTemplate} 623 - onInput={(e: Event) => 624 - onChange({ ...action, textTemplate: (e.target as HTMLTextAreaElement).value }) 625 - } 626 - rows={4} 627 - required 628 - autocomplete="off" 629 - /> 630 - <span class={s.hint}> 631 - Mentions (@handle), links, and #hashtags are detected automatically. 632 - </span> 633 - </div> 634 - 635 - <div class={s.fieldGroup}> 636 - <label class={s.label} for={langsId}> 637 - Languages <span class={s.hint}>(optional, max 3)</span> 638 - </label> 639 - <input 640 - id={langsId} 641 - class={s.input} 642 - type="text" 643 - placeholder="e.g. en, fr, pt" 644 - value={action.langsText} 645 - onInput={(e: Event) => 646 - onChange({ ...action, langsText: (e.target as HTMLInputElement).value }) 647 - } 648 - autocomplete="off" 649 - /> 650 - <span class={s.hint}>Comma-separated language codes (BCP-47)</span> 651 - </div> 652 - 653 - <fieldset class={s.groupFieldset}> 654 - <legend class={s.groupLegend}> 655 - Content warnings <span class={s.hint}>(optional)</span> 656 - </legend> 657 - <div class={s.operationCheckboxes}> 658 - {BSKY_LABELS.map(({ value, label }) => ( 659 - <label key={value} class={s.checkboxLabel}> 660 - <input 661 - type="checkbox" 662 - class={s.checkbox} 663 - checked={action.labels.includes(value)} 664 - onChange={() => { 665 - const labels = action.labels.includes(value) 666 - ? action.labels.filter((l) => l !== value) 667 - : [...action.labels, value]; 668 - onChange({ ...action, labels }); 669 - }} 670 - /> 671 - {label} 672 - </label> 673 - ))} 674 - </div> 675 - </fieldset> 676 - </> 677 - ); 678 - } 679 - 680 - // --------------------------------------------------------------------------- 681 - // Patch Record action editor 682 - // --------------------------------------------------------------------------- 683 - 684 - function PatchRecordActionEditor({ 685 - action, 686 - index, 687 - onChange, 688 - placeholders, 689 - }: { 690 - action: PatchRecordDraft; 691 - index: number; 692 - onChange: (a: PatchRecordDraft) => void; 693 - placeholders: string[]; 694 - }) { 695 - const { 696 - targetSchema, 697 - targetSchemaLoading, 698 - targetSchemaError, 699 - nsidSuggestions, 700 - datalistId, 701 - fetchTargetSchema, 702 - fetchSuggestions, 703 - } = useNsidSchema(action.targetCollection); 704 - 705 - const targetId = `action-${index}-target-collection`; 706 - const baseUriId = `action-${index}-base-record-uri`; 707 - const templateId = `action-${index}-record-template`; 708 - 709 - return ( 710 - <> 711 - <div class={s.fieldGroup}> 712 - <label class={s.label} for={targetId}> 713 - Target Lexicon NSID 714 - </label> 715 - <input 716 - id={targetId} 717 - class={s.input} 718 - type="text" 719 - list={datalistId} 720 - placeholder="e.g. site.standard.document" 721 - value={action.targetCollection} 722 - onInput={(e: Event) => { 723 - const val = (e.target as HTMLInputElement).value; 724 - onChange({ ...action, targetCollection: val }); 725 - fetchTargetSchema(val); 726 - fetchSuggestions(val); 727 - }} 728 - required 729 - autocomplete="off" 730 - /> 731 - <span class={s.hint}>NSID of the collection containing the record to update</span> 732 - <datalist id={datalistId}> 733 - {nsidSuggestions.map((nsid) => ( 734 - <option key={nsid} value={nsid} /> 735 - ))} 736 - </datalist> 737 - </div> 738 - 739 - <div class={s.fieldGroup}> 740 - <label class={s.label} for={baseUriId}> 741 - Base Record URI 742 - </label> 743 - <input 744 - id={baseUriId} 745 - class={s.input} 746 - type="text" 747 - placeholder="e.g. {{event.commit.record.subject.uri}}" 748 - value={action.baseRecordUri} 749 - onInput={(e: Event) => 750 - onChange({ ...action, baseRecordUri: (e.target as HTMLInputElement).value }) 751 - } 752 - required 753 - autocomplete="off" 754 - /> 755 - <span class={s.hint}>AT URI of the record to update. Supports {"{{placeholders}}"}.</span> 756 - </div> 757 - 758 - {(targetSchema || targetSchemaLoading) && ( 759 - <RecordFormBuilder 760 - schema={targetSchema} 761 - loading={targetSchemaLoading} 762 - error={targetSchemaError} 763 - placeholders={placeholders} 764 - initialTemplate={action.recordTemplate || undefined} 765 - onChange={(tpl) => onChange({ ...action, recordTemplate: tpl })} 766 - patchMode 767 - /> 768 - )} 769 - 770 - {!targetSchema && !targetSchemaLoading && action.targetCollection && ( 771 - <div class={s.fieldGroup}> 772 - <label class={s.label} for={templateId}> 773 - Patch template 774 - </label> 775 - {targetSchemaError && <span class={s.errorText}>{targetSchemaError}</span>} 776 - <textarea 777 - id={templateId} 778 - class={s.textarea} 779 - placeholder={'{\n "bskyPostRef": "{{action1.uri}}",\n "updatedAt": "{{now}}"\n}'} 780 - value={action.recordTemplate} 781 - onInput={(e: Event) => 782 - onChange({ 783 - ...action, 784 - recordTemplate: (e.target as HTMLTextAreaElement).value, 785 - }) 786 - } 787 - required 788 - autocomplete="off" 789 - /> 790 - <span class={s.hint}> 791 - Only include the fields you want to change. They will be merged on top of the existing 792 - record. 793 - </span> 794 - </div> 795 - )} 796 - </> 797 - ); 798 - } 799 - 800 - // --------------------------------------------------------------------------- 801 - // Margin bookmark action editor 802 - // --------------------------------------------------------------------------- 803 - 804 - function MarginBookmarkActionEditor({ 805 - action, 806 - index, 807 - onChange, 808 - }: { 809 - action: MarginBookmarkDraft; 810 - index: number; 811 - onChange: (a: MarginBookmarkDraft) => void; 812 - }) { 813 - const urlId = `action-${index}-margin-bookmark-url`; 814 - const bodyId = `action-${index}-margin-bookmark-body`; 815 - const tagsId = `action-${index}-margin-bookmark-tags`; 816 - 817 - return ( 818 - <> 819 - <div class={s.fieldGroup}> 820 - <label class={s.label} for={urlId}> 821 - Page URL 822 - </label> 823 - <input 824 - id={urlId} 825 - class={s.input} 826 - type="text" 827 - placeholder="e.g. https://example.com or {{event.commit.record.subject.uri}}" 828 - value={action.targetSource} 829 - onInput={(e: Event) => 830 - onChange({ ...action, targetSource: (e.target as HTMLInputElement).value }) 831 - } 832 - required 833 - autocomplete="off" 834 - /> 835 - <span class={s.hint}> 836 - URL of the page to bookmark. Supports {"{{placeholders}}"}. The page title is fetched 837 - automatically. 838 - </span> 839 - </div> 840 - 841 - <div class={s.fieldGroup}> 842 - <label class={s.label} for={bodyId}> 843 - Description <span class={s.hint}>(optional)</span> 844 - </label> 845 - <textarea 846 - id={bodyId} 847 - class={s.textarea} 848 - placeholder="A short note about this bookmark" 849 - value={action.bodyValue} 850 - onInput={(e: Event) => 851 - onChange({ ...action, bodyValue: (e.target as HTMLTextAreaElement).value }) 852 - } 853 - rows={3} 854 - autocomplete="off" 855 - /> 856 - <span class={s.hint}>Bookmark description. Supports {"{{placeholders}}"}.</span> 857 - </div> 858 - 859 - <div class={s.fieldGroup}> 860 - <label class={s.label} for={tagsId}> 861 - Tags <span class={s.hint}>(optional, max 10)</span> 862 - </label> 863 - <input 864 - id={tagsId} 865 - class={s.input} 866 - type="text" 867 - placeholder="e.g. reading, research, bluesky" 868 - value={action.tagsText} 869 - onInput={(e: Event) => 870 - onChange({ ...action, tagsText: (e.target as HTMLInputElement).value }) 871 - } 872 - autocomplete="off" 873 - /> 874 - <span class={s.hint}>Comma-separated. Each tag supports {"{{placeholders}}"}.</span> 875 - </div> 876 - </> 877 - ); 878 - } 879 - 880 - // --------------------------------------------------------------------------- 881 - // Semble save action editor 882 - // --------------------------------------------------------------------------- 883 - 884 - function SembleSaveActionEditor({ 885 - action, 886 - index, 887 - onChange, 888 - }: { 889 - action: SembleSaveDraft; 890 - index: number; 891 - onChange: (a: SembleSaveDraft) => void; 892 - }) { 893 - const urlId = `action-${index}-semble-url`; 894 - return ( 895 - <div class={s.fieldGroup}> 896 - <label class={s.label} for={urlId}> 897 - Page URL 898 - </label> 899 - <input 900 - id={urlId} 901 - class={s.input} 902 - type="text" 903 - placeholder="e.g. https://example.com or {{event.commit.record.subject.uri}}" 904 - value={action.url} 905 - onInput={(e: Event) => onChange({ ...action, url: (e.target as HTMLInputElement).value })} 906 - required 907 - autocomplete="off" 908 - /> 909 - <span class={s.hint}> 910 - URL of the page to save. Metadata (title, description, image) will be fetched automatically. 911 - Supports {"{{placeholders}}"}. 912 - </span> 913 - </div> 914 - ); 915 - } 916 - 917 - // --------------------------------------------------------------------------- 918 - // Follow (social graph) action editor, shared across bluesky / tangled / sifa 919 - // --------------------------------------------------------------------------- 920 - 921 - function FollowActionEditor({ 922 - action, 923 - index, 924 - onChange, 925 - }: { 926 - action: FollowDraft; 927 - index: number; 928 - onChange: (a: FollowDraft) => void; 929 - }) { 930 - const meta = FOLLOW_TARGETS[action.target]; 931 - const subjectId = `action-${index}-follow-subject`; 932 - return ( 933 - <> 934 - <div class={s.fieldGroup}> 935 - <label class={s.label} for={subjectId}> 936 - Subject DID 937 - </label> 938 - <input 939 - id={subjectId} 940 - class={s.input} 941 - type="text" 942 - placeholder="e.g. did:plc:... or {{event.did}}" 943 - value={action.subject} 944 - onInput={(e: Event) => 945 - onChange({ ...action, subject: (e.target as HTMLInputElement).value }) 946 - } 947 - required 948 - autocomplete="off" 949 - /> 950 - <span class={s.hint}> 951 - DID of the account to follow on {meta.appName}. Supports {"{{placeholders}}"} like{" "} 952 - {"{{event.did}}"} or {"{{event.commit.record.subject}}"}. 953 - <br /> 954 - Automatically checks that the subject has a {meta.appName} profile and that you don't 955 - already follow them. No extra conditions needed. 956 - </span> 957 - </div> 958 - </> 959 - ); 960 - } 961 - 962 - // --------------------------------------------------------------------------- 963 247 // Copy-to-clipboard placeholder 964 248 // --------------------------------------------------------------------------- 965 249 ··· 1232 516 1233 517 function toActionDrafts(actions: Action[]): ActionDraft[] { 1234 518 return actions.map((a) => { 519 + const draft = ACTION_UI_REGISTRY[a.$type].fromAction(a); 1235 520 const forEach = toForEachDraft(a.forEach); 1236 - const forEachField = forEach ? { forEach } : {}; 1237 - if (a.$type === "webhook") { 1238 - const headers: HeaderDraft[] = a.headers 1239 - ? Object.entries(a.headers).map(([key, value]) => ({ key, value })) 1240 - : []; 1241 - return { 1242 - type: "webhook", 1243 - callbackUrl: a.callbackUrl, 1244 - headers, 1245 - comment: a.comment ?? "", 1246 - ...forEachField, 1247 - }; 1248 - } 1249 - if (a.$type === "bsky-post") { 1250 - return { 1251 - type: "bsky-post", 1252 - textTemplate: a.textTemplate, 1253 - langsText: (a.langs ?? []).join(", "), 1254 - labels: a.labels ?? [], 1255 - comment: a.comment ?? "", 1256 - ...forEachField, 1257 - }; 1258 - } 1259 - if (a.$type === "patch-record") { 1260 - return { 1261 - type: "patch-record", 1262 - targetCollection: a.targetCollection, 1263 - baseRecordUri: a.baseRecordUri, 1264 - recordTemplate: a.recordTemplate, 1265 - comment: a.comment ?? "", 1266 - ...forEachField, 1267 - }; 1268 - } 1269 - if (a.$type === "margin-bookmark") { 1270 - return { 1271 - type: "margin-bookmark", 1272 - targetSource: a.targetSource, 1273 - bodyValue: a.bodyValue ?? "", 1274 - tagsText: (a.tags ?? []).join(", "), 1275 - comment: a.comment ?? "", 1276 - ...forEachField, 1277 - }; 1278 - } 1279 - if (a.$type === "semble-save") { 1280 - return { 1281 - type: "semble-save", 1282 - url: a.url, 1283 - comment: a.comment ?? "", 1284 - ...forEachField, 1285 - }; 1286 - } 1287 - if (a.$type === "follow") { 1288 - return { 1289 - type: "follow", 1290 - target: a.target, 1291 - subject: a.subject, 1292 - comment: a.comment ?? "", 1293 - ...forEachField, 1294 - }; 1295 - } 1296 - return { 1297 - type: "record", 1298 - targetCollection: a.targetCollection, 1299 - recordTemplate: a.recordTemplate, 1300 - comment: a.comment ?? "", 1301 - ...forEachField, 1302 - }; 521 + return forEach ? { ...draft, forEach } : draft; 1303 522 }); 1304 523 } 1305 524 ··· 1809 1028 [], 1810 1029 ); 1811 1030 1812 - const addAction = useCallback((type: AddableActionId) => { 1813 - if (type === "webhook") { 1814 - setActions((prev) => [ 1815 - ...prev, 1816 - { type: "webhook", callbackUrl: "", headers: [], comment: "" }, 1817 - ]); 1818 - } else if (type === "bsky-post") { 1819 - setActions((prev) => [ 1820 - ...prev, 1821 - { type: "bsky-post", textTemplate: "", langsText: "", labels: [], comment: "" }, 1822 - ]); 1823 - } else if (type === "patch-record") { 1824 - setActions((prev) => [ 1825 - ...prev, 1826 - { 1827 - type: "patch-record", 1828 - targetCollection: "", 1829 - baseRecordUri: "", 1830 - recordTemplate: "", 1831 - comment: "", 1832 - }, 1833 - ]); 1834 - } else if (type === "margin-bookmark") { 1835 - setActions((prev) => [ 1836 - ...prev, 1837 - { 1838 - type: "margin-bookmark", 1839 - targetSource: "", 1840 - bodyValue: "", 1841 - tagsText: "", 1842 - comment: "", 1843 - }, 1844 - ]); 1845 - } else if (type === "semble-save") { 1846 - setActions((prev) => [...prev, { type: "semble-save", url: "", comment: "" }]); 1847 - } else if (type.startsWith("follow-")) { 1848 - const target = type.slice("follow-".length) as FollowTarget; 1849 - setActions((prev) => [ 1850 - ...prev, 1851 - { type: "follow", target, subject: "{{event.commit.record.subject}}", comment: "" }, 1852 - ]); 1853 - } else { 1854 - setActions((prev) => [ 1855 - ...prev, 1856 - { type: "record", targetCollection: "", recordTemplate: "", comment: "" }, 1857 - ]); 1858 - } 1031 + const addAction = useCallback((id: AddableActionId) => { 1032 + // Tile ids and action $types diverge for follow only — picker exposes one 1033 + // tile per FOLLOW_TARGETS entry, but they all share the `follow` $type. 1034 + const isFollow = id.startsWith("follow-"); 1035 + const $type = (isFollow ? "follow" : id) as keyof typeof ACTION_UI_REGISTRY; 1036 + const followTarget = isFollow ? (id.slice("follow-".length) as FollowTarget) : undefined; 1037 + const draft = ACTION_UI_REGISTRY[$type].newDraft({ followTarget }); 1038 + setActions((prev) => [...prev, draft]); 1859 1039 }, []); 1860 1040 1861 1041 const removeAction = useCallback((index: number) => { ··· 1882 1062 payload.wantedDids = trimmedWantedDids; 1883 1063 } 1884 1064 payload.actions = actions.map((a) => { 1885 - const comment = a.comment ? { comment: a.comment } : {}; 1065 + const input = ACTION_UI_REGISTRY[a.type].toInput(a); 1886 1066 const forEach = forEachToPayload(a.forEach); 1887 - const forEachField = forEach ? { forEach } : {}; 1888 - if (a.type === "webhook") { 1889 - const filtered = a.headers.filter((h) => h.key.trim() && h.value.trim()); 1890 - const headers = 1891 - filtered.length > 0 1892 - ? Object.fromEntries(filtered.map((h) => [h.key.trim(), h.value.trim()])) 1893 - : undefined; 1894 - return { 1895 - type: "webhook", 1896 - callbackUrl: a.callbackUrl, 1897 - ...(headers ? { headers } : {}), 1898 - ...forEachField, 1899 - ...comment, 1900 - }; 1901 - } 1902 - if (a.type === "bsky-post") { 1903 - const langs = a.langsText 1904 - .split(",") 1905 - .map((l) => l.trim()) 1906 - .filter(Boolean); 1907 - return { 1908 - type: "bsky-post", 1909 - textTemplate: a.textTemplate, 1910 - ...(langs.length > 0 ? { langs } : {}), 1911 - ...(a.labels.length > 0 ? { labels: a.labels } : {}), 1912 - ...forEachField, 1913 - ...comment, 1914 - }; 1915 - } 1916 - if (a.type === "patch-record") { 1917 - return { 1918 - type: "patch-record", 1919 - targetCollection: a.targetCollection, 1920 - baseRecordUri: a.baseRecordUri, 1921 - recordTemplate: a.recordTemplate, 1922 - ...forEachField, 1923 - ...comment, 1924 - }; 1925 - } 1926 - if (a.type === "margin-bookmark") { 1927 - const bodyValue = a.bodyValue.trim(); 1928 - const tags = a.tagsText 1929 - .split(",") 1930 - .map((t) => t.trim()) 1931 - .filter(Boolean) 1932 - .slice(0, 10); 1933 - return { 1934 - type: "margin-bookmark", 1935 - targetSource: a.targetSource, 1936 - ...(bodyValue ? { bodyValue } : {}), 1937 - ...(tags.length > 0 ? { tags } : {}), 1938 - ...forEachField, 1939 - ...comment, 1940 - }; 1941 - } 1942 - if (a.type === "semble-save") { 1943 - return { 1944 - type: "semble-save", 1945 - url: a.url, 1946 - ...forEachField, 1947 - ...comment, 1948 - }; 1949 - } 1950 - if (a.type === "follow") { 1951 - return { 1952 - type: "follow", 1953 - target: a.target, 1954 - subject: a.subject, 1955 - ...forEachField, 1956 - ...comment, 1957 - }; 1958 - } 1959 1067 return { 1960 - type: "record", 1961 - targetCollection: a.targetCollection, 1962 - recordTemplate: a.recordTemplate, 1963 - ...forEachField, 1964 - ...comment, 1068 + ...input, 1069 + ...(forEach ? { forEach } : {}), 1070 + ...(a.comment ? { comment: a.comment } : {}), 1965 1071 }; 1966 1072 }); 1967 1073 return JSON.stringify(payload, null, 2); ··· 3002 2108 arrayPathSuggestions={arrayPathSuggestions} 3003 2109 itemFieldsByPath={itemFieldsByPath} 3004 2110 /> 3005 - {action.type === "webhook" ? ( 3006 - <WebhookActionEditor 3007 - action={action} 3008 - index={i} 3009 - onChange={(a) => updateAction(i, a)} 3010 - /> 3011 - ) : action.type === "bsky-post" ? ( 3012 - <BskyPostActionEditor 3013 - action={action} 3014 - index={i} 3015 - onChange={(a) => updateAction(i, a)} 3016 - /> 3017 - ) : action.type === "patch-record" ? ( 3018 - <PatchRecordActionEditor 3019 - action={action} 3020 - index={i} 3021 - onChange={(a) => updateAction(i, a)} 3022 - placeholders={allPlaceholders} 3023 - /> 3024 - ) : action.type === "margin-bookmark" ? ( 3025 - <MarginBookmarkActionEditor 3026 - action={action} 3027 - index={i} 3028 - onChange={(a) => updateAction(i, a)} 3029 - /> 3030 - ) : action.type === "semble-save" ? ( 3031 - <SembleSaveActionEditor 3032 - action={action} 3033 - index={i} 3034 - onChange={(a) => updateAction(i, a)} 3035 - /> 3036 - ) : action.type === "follow" ? ( 3037 - <FollowActionEditor 3038 - action={action} 3039 - index={i} 3040 - onChange={(a) => updateAction(i, a)} 3041 - /> 3042 - ) : ( 3043 - <RecordActionEditor 3044 - action={action} 3045 - index={i} 3046 - onChange={(a) => updateAction(i, a)} 3047 - placeholders={allPlaceholders} 3048 - /> 3049 - )} 2111 + {(() => { 2112 + const EditorBlock = ACTION_UI_REGISTRY[action.type].EditorBlock; 2113 + return ( 2114 + <EditorBlock 2115 + action={action} 2116 + index={i} 2117 + onChange={(a) => updateAction(i, a)} 2118 + placeholders={allPlaceholders} 2119 + /> 2120 + ); 2121 + })()} 3050 2122 <div class={s.fieldGroup}> 3051 2123 <label class={s.label} for={`action-${i}-note`}> 3052 2124 Note <span class={s.hint}>(optional)</span>
+139
app/islands/action-editors/bsky-post.tsx
··· 1 + import type { BskyPostAction } from "../../../lib/db/schema.js"; 2 + import { MessageSquare } from "../../icons.ts"; 3 + import * as s from "../AutomationForm.css.ts"; 4 + import type { ActionUIDefinition, ForEachDraft } from "./types.ts"; 5 + 6 + const BSKY_LABELS = [ 7 + { value: "sexual", label: "Suggestive" }, 8 + { value: "nudity", label: "Nudity" }, 9 + { value: "porn", label: "Porn" }, 10 + { value: "graphic-media", label: "Graphic media" }, 11 + ]; 12 + 13 + export type BskyPostDraft = { 14 + type: "bsky-post"; 15 + textTemplate: string; 16 + langsText: string; 17 + labels: string[]; 18 + comment: string; 19 + forEach?: ForEachDraft; 20 + }; 21 + 22 + function BskyPostActionEditor({ 23 + action, 24 + index, 25 + onChange, 26 + }: { 27 + action: BskyPostDraft; 28 + index: number; 29 + onChange: (a: BskyPostDraft) => void; 30 + }) { 31 + const textId = `action-${index}-bsky-text`; 32 + const langsId = `action-${index}-bsky-langs`; 33 + 34 + return ( 35 + <> 36 + <div class={s.fieldGroup}> 37 + <label class={s.label} for={textId}> 38 + Post text 39 + </label> 40 + <textarea 41 + id={textId} 42 + class={s.textarea} 43 + placeholder={"Write your post here...\nYou can use {{placeholders}}."} 44 + value={action.textTemplate} 45 + onInput={(e: Event) => 46 + onChange({ ...action, textTemplate: (e.target as HTMLTextAreaElement).value }) 47 + } 48 + rows={4} 49 + required 50 + autocomplete="off" 51 + /> 52 + <span class={s.hint}> 53 + Mentions (@handle), links, and #hashtags are detected automatically. 54 + </span> 55 + </div> 56 + 57 + <div class={s.fieldGroup}> 58 + <label class={s.label} for={langsId}> 59 + Languages <span class={s.hint}>(optional, max 3)</span> 60 + </label> 61 + <input 62 + id={langsId} 63 + class={s.input} 64 + type="text" 65 + placeholder="e.g. en, fr, pt" 66 + value={action.langsText} 67 + onInput={(e: Event) => 68 + onChange({ ...action, langsText: (e.target as HTMLInputElement).value }) 69 + } 70 + autocomplete="off" 71 + /> 72 + <span class={s.hint}>Comma-separated language codes (BCP-47)</span> 73 + </div> 74 + 75 + <fieldset class={s.groupFieldset}> 76 + <legend class={s.groupLegend}> 77 + Content warnings <span class={s.hint}>(optional)</span> 78 + </legend> 79 + <div class={s.operationCheckboxes}> 80 + {BSKY_LABELS.map(({ value, label }) => ( 81 + <label key={value} class={s.checkboxLabel}> 82 + <input 83 + type="checkbox" 84 + class={s.checkbox} 85 + checked={action.labels.includes(value)} 86 + onChange={() => { 87 + const labels = action.labels.includes(value) 88 + ? action.labels.filter((l) => l !== value) 89 + : [...action.labels, value]; 90 + onChange({ ...action, labels }); 91 + }} 92 + /> 93 + {label} 94 + </label> 95 + ))} 96 + </div> 97 + </fieldset> 98 + </> 99 + ); 100 + } 101 + 102 + export const bskyPostUiDefinition: ActionUIDefinition<BskyPostDraft, BskyPostAction> = { 103 + type: "bsky-post", 104 + recordProducing: true, 105 + catalogue: { 106 + label: "Post to Bluesky", 107 + description: "Publish a post to your Bluesky account", 108 + category: "bluesky", 109 + icon: MessageSquare, 110 + available: true, 111 + }, 112 + newDraft: () => ({ 113 + type: "bsky-post", 114 + textTemplate: "", 115 + langsText: "", 116 + labels: [], 117 + comment: "", 118 + }), 119 + fromAction: (a) => ({ 120 + type: "bsky-post", 121 + textTemplate: a.textTemplate, 122 + langsText: (a.langs ?? []).join(", "), 123 + labels: a.labels ?? [], 124 + comment: a.comment ?? "", 125 + }), 126 + toInput: (d) => { 127 + const langs = d.langsText 128 + .split(",") 129 + .map((l) => l.trim()) 130 + .filter(Boolean); 131 + return { 132 + type: "bsky-post", 133 + textTemplate: d.textTemplate, 134 + ...(langs.length > 0 ? { langs } : {}), 135 + ...(d.labels.length > 0 ? { labels: d.labels } : {}), 136 + }; 137 + }, 138 + EditorBlock: BskyPostActionEditor, 139 + };
+72
app/islands/action-editors/follow.tsx
··· 1 + import type { FollowAction, FollowTarget } from "../../../lib/db/schema.js"; 2 + import { FOLLOW_TARGETS } from "../../../lib/automations/follow-targets.js"; 3 + import * as s from "../AutomationForm.css.ts"; 4 + import type { ActionUIDefinition, ForEachDraft } from "./types.ts"; 5 + 6 + export type FollowDraft = { 7 + type: "follow"; 8 + target: FollowTarget; 9 + subject: string; 10 + comment: string; 11 + forEach?: ForEachDraft; 12 + }; 13 + 14 + function FollowActionEditor({ 15 + action, 16 + index, 17 + onChange, 18 + }: { 19 + action: FollowDraft; 20 + index: number; 21 + onChange: (a: FollowDraft) => void; 22 + }) { 23 + const meta = FOLLOW_TARGETS[action.target]; 24 + const subjectId = `action-${index}-follow-subject`; 25 + return ( 26 + <> 27 + <div class={s.fieldGroup}> 28 + <label class={s.label} for={subjectId}> 29 + Subject DID 30 + </label> 31 + <input 32 + id={subjectId} 33 + class={s.input} 34 + type="text" 35 + placeholder="e.g. did:plc:... or {{event.did}}" 36 + value={action.subject} 37 + onInput={(e: Event) => 38 + onChange({ ...action, subject: (e.target as HTMLInputElement).value }) 39 + } 40 + required 41 + autocomplete="off" 42 + /> 43 + <span class={s.hint}> 44 + DID of the account to follow on {meta.appName}. Supports {"{{placeholders}}"} like{" "} 45 + {"{{event.did}}"} or {"{{event.commit.record.subject}}"}. 46 + <br /> 47 + Automatically checks that the subject has a {meta.appName} profile and that you don't 48 + already follow them. No extra conditions needed. 49 + </span> 50 + </div> 51 + </> 52 + ); 53 + } 54 + 55 + export const followUiDefinition: ActionUIDefinition<FollowDraft, FollowAction> = { 56 + type: "follow", 57 + recordProducing: true, 58 + newDraft: ({ followTarget }) => ({ 59 + type: "follow", 60 + target: followTarget ?? "bluesky", 61 + subject: "{{event.commit.record.subject}}", 62 + comment: "", 63 + }), 64 + fromAction: (a) => ({ 65 + type: "follow", 66 + target: a.target, 67 + subject: a.subject, 68 + comment: a.comment ?? "", 69 + }), 70 + toInput: (d) => ({ type: "follow", target: d.target, subject: d.subject }), 71 + EditorBlock: FollowActionEditor, 72 + };
+134
app/islands/action-editors/margin-bookmark.tsx
··· 1 + import type { MarginBookmarkAction } from "../../../lib/db/schema.js"; 2 + import { Bookmark } from "../../icons.ts"; 3 + import * as s from "../AutomationForm.css.ts"; 4 + import type { ActionUIDefinition, ForEachDraft } from "./types.ts"; 5 + 6 + export type MarginBookmarkDraft = { 7 + type: "margin-bookmark"; 8 + targetSource: string; 9 + bodyValue: string; 10 + tagsText: string; 11 + comment: string; 12 + forEach?: ForEachDraft; 13 + }; 14 + 15 + function MarginBookmarkActionEditor({ 16 + action, 17 + index, 18 + onChange, 19 + }: { 20 + action: MarginBookmarkDraft; 21 + index: number; 22 + onChange: (a: MarginBookmarkDraft) => void; 23 + }) { 24 + const urlId = `action-${index}-margin-bookmark-url`; 25 + const bodyId = `action-${index}-margin-bookmark-body`; 26 + const tagsId = `action-${index}-margin-bookmark-tags`; 27 + 28 + return ( 29 + <> 30 + <div class={s.fieldGroup}> 31 + <label class={s.label} for={urlId}> 32 + Page URL 33 + </label> 34 + <input 35 + id={urlId} 36 + class={s.input} 37 + type="text" 38 + placeholder="e.g. https://example.com or {{event.commit.record.subject.uri}}" 39 + value={action.targetSource} 40 + onInput={(e: Event) => 41 + onChange({ ...action, targetSource: (e.target as HTMLInputElement).value }) 42 + } 43 + required 44 + autocomplete="off" 45 + /> 46 + <span class={s.hint}> 47 + URL of the page to bookmark. Supports {"{{placeholders}}"}. The page title is fetched 48 + automatically. 49 + </span> 50 + </div> 51 + 52 + <div class={s.fieldGroup}> 53 + <label class={s.label} for={bodyId}> 54 + Description <span class={s.hint}>(optional)</span> 55 + </label> 56 + <textarea 57 + id={bodyId} 58 + class={s.textarea} 59 + placeholder="A short note about this bookmark" 60 + value={action.bodyValue} 61 + onInput={(e: Event) => 62 + onChange({ ...action, bodyValue: (e.target as HTMLTextAreaElement).value }) 63 + } 64 + rows={3} 65 + autocomplete="off" 66 + /> 67 + <span class={s.hint}>Bookmark description. Supports {"{{placeholders}}"}.</span> 68 + </div> 69 + 70 + <div class={s.fieldGroup}> 71 + <label class={s.label} for={tagsId}> 72 + Tags <span class={s.hint}>(optional, max 10)</span> 73 + </label> 74 + <input 75 + id={tagsId} 76 + class={s.input} 77 + type="text" 78 + placeholder="e.g. reading, research, bluesky" 79 + value={action.tagsText} 80 + onInput={(e: Event) => 81 + onChange({ ...action, tagsText: (e.target as HTMLInputElement).value }) 82 + } 83 + autocomplete="off" 84 + /> 85 + <span class={s.hint}>Comma-separated. Each tag supports {"{{placeholders}}"}.</span> 86 + </div> 87 + </> 88 + ); 89 + } 90 + 91 + export const marginBookmarkUiDefinition: ActionUIDefinition< 92 + MarginBookmarkDraft, 93 + MarginBookmarkAction 94 + > = { 95 + type: "margin-bookmark", 96 + recordProducing: true, 97 + catalogue: { 98 + label: "Bookmark on Margin", 99 + description: "Create a bookmark note in Margin.at", 100 + category: "apps", 101 + icon: Bookmark, 102 + available: true, 103 + faviconDomain: "margin.at", 104 + }, 105 + newDraft: () => ({ 106 + type: "margin-bookmark", 107 + targetSource: "", 108 + bodyValue: "", 109 + tagsText: "", 110 + comment: "", 111 + }), 112 + fromAction: (a) => ({ 113 + type: "margin-bookmark", 114 + targetSource: a.targetSource, 115 + bodyValue: a.bodyValue ?? "", 116 + tagsText: (a.tags ?? []).join(", "), 117 + comment: a.comment ?? "", 118 + }), 119 + toInput: (d) => { 120 + const bodyValue = d.bodyValue.trim(); 121 + const tags = d.tagsText 122 + .split(",") 123 + .map((t) => t.trim()) 124 + .filter(Boolean) 125 + .slice(0, 10); 126 + return { 127 + type: "margin-bookmark", 128 + targetSource: d.targetSource, 129 + ...(bodyValue ? { bodyValue } : {}), 130 + ...(tags.length > 0 ? { tags } : {}), 131 + }; 132 + }, 133 + EditorBlock: MarginBookmarkActionEditor, 134 + };
+159
app/islands/action-editors/patch-record.tsx
··· 1 + import type { PatchRecordAction } from "../../../lib/db/schema.js"; 2 + import { Pencil } from "../../icons.ts"; 3 + import * as s from "../AutomationForm.css.ts"; 4 + import RecordFormBuilder from "../RecordFormBuilder.js"; 5 + import { useNsidSchema } from "./use-nsid-schema.ts"; 6 + import type { ActionUIDefinition, EditorBlockProps, ForEachDraft } from "./types.ts"; 7 + 8 + export type PatchRecordDraft = { 9 + type: "patch-record"; 10 + targetCollection: string; 11 + baseRecordUri: string; 12 + recordTemplate: string; 13 + comment: string; 14 + forEach?: ForEachDraft; 15 + }; 16 + 17 + function PatchRecordActionEditor({ 18 + action, 19 + index, 20 + onChange, 21 + placeholders, 22 + }: EditorBlockProps<PatchRecordDraft>) { 23 + const { 24 + targetSchema, 25 + targetSchemaLoading, 26 + targetSchemaError, 27 + nsidSuggestions, 28 + datalistId, 29 + fetchTargetSchema, 30 + fetchSuggestions, 31 + } = useNsidSchema(action.targetCollection); 32 + 33 + const targetId = `action-${index}-target-collection`; 34 + const baseUriId = `action-${index}-base-record-uri`; 35 + const templateId = `action-${index}-record-template`; 36 + 37 + return ( 38 + <> 39 + <div class={s.fieldGroup}> 40 + <label class={s.label} for={targetId}> 41 + Target Lexicon NSID 42 + </label> 43 + <input 44 + id={targetId} 45 + class={s.input} 46 + type="text" 47 + list={datalistId} 48 + placeholder="e.g. site.standard.document" 49 + value={action.targetCollection} 50 + onInput={(e: Event) => { 51 + const val = (e.target as HTMLInputElement).value; 52 + onChange({ ...action, targetCollection: val }); 53 + fetchTargetSchema(val); 54 + fetchSuggestions(val); 55 + }} 56 + required 57 + autocomplete="off" 58 + /> 59 + <span class={s.hint}>NSID of the collection containing the record to update</span> 60 + <datalist id={datalistId}> 61 + {nsidSuggestions.map((nsid) => ( 62 + <option key={nsid} value={nsid} /> 63 + ))} 64 + </datalist> 65 + </div> 66 + 67 + <div class={s.fieldGroup}> 68 + <label class={s.label} for={baseUriId}> 69 + Base Record URI 70 + </label> 71 + <input 72 + id={baseUriId} 73 + class={s.input} 74 + type="text" 75 + placeholder="e.g. {{event.commit.record.subject.uri}}" 76 + value={action.baseRecordUri} 77 + onInput={(e: Event) => 78 + onChange({ ...action, baseRecordUri: (e.target as HTMLInputElement).value }) 79 + } 80 + required 81 + autocomplete="off" 82 + /> 83 + <span class={s.hint}>AT URI of the record to update. Supports {"{{placeholders}}"}.</span> 84 + </div> 85 + 86 + {(targetSchema || targetSchemaLoading) && ( 87 + <RecordFormBuilder 88 + schema={targetSchema} 89 + loading={targetSchemaLoading} 90 + error={targetSchemaError} 91 + placeholders={placeholders} 92 + initialTemplate={action.recordTemplate || undefined} 93 + onChange={(tpl) => onChange({ ...action, recordTemplate: tpl })} 94 + patchMode 95 + /> 96 + )} 97 + 98 + {!targetSchema && !targetSchemaLoading && action.targetCollection && ( 99 + <div class={s.fieldGroup}> 100 + <label class={s.label} for={templateId}> 101 + Patch template 102 + </label> 103 + {targetSchemaError && <span class={s.errorText}>{targetSchemaError}</span>} 104 + <textarea 105 + id={templateId} 106 + class={s.textarea} 107 + placeholder={'{\n "bskyPostRef": "{{action1.uri}}",\n "updatedAt": "{{now}}"\n}'} 108 + value={action.recordTemplate} 109 + onInput={(e: Event) => 110 + onChange({ 111 + ...action, 112 + recordTemplate: (e.target as HTMLTextAreaElement).value, 113 + }) 114 + } 115 + required 116 + autocomplete="off" 117 + /> 118 + <span class={s.hint}> 119 + Only include the fields you want to change. They will be merged on top of the existing 120 + record. 121 + </span> 122 + </div> 123 + )} 124 + </> 125 + ); 126 + } 127 + 128 + export const patchRecordUiDefinition: ActionUIDefinition<PatchRecordDraft, PatchRecordAction> = { 129 + type: "patch-record", 130 + recordProducing: true, 131 + catalogue: { 132 + label: "Update a record", 133 + description: "Modify fields of an existing record", 134 + category: "pds", 135 + icon: Pencil, 136 + available: true, 137 + }, 138 + newDraft: () => ({ 139 + type: "patch-record", 140 + targetCollection: "", 141 + baseRecordUri: "", 142 + recordTemplate: "", 143 + comment: "", 144 + }), 145 + fromAction: (a) => ({ 146 + type: "patch-record", 147 + targetCollection: a.targetCollection, 148 + baseRecordUri: a.baseRecordUri, 149 + recordTemplate: a.recordTemplate, 150 + comment: a.comment ?? "", 151 + }), 152 + toInput: (d) => ({ 153 + type: "patch-record", 154 + targetCollection: d.targetCollection, 155 + baseRecordUri: d.baseRecordUri, 156 + recordTemplate: d.recordTemplate, 157 + }), 158 + EditorBlock: PatchRecordActionEditor, 159 + };
+127
app/islands/action-editors/record.tsx
··· 1 + import type { RecordAction } from "../../../lib/db/schema.js"; 2 + import { FilePlus2 } from "../../icons.ts"; 3 + import * as s from "../AutomationForm.css.ts"; 4 + import RecordFormBuilder from "../RecordFormBuilder.js"; 5 + import { useNsidSchema } from "./use-nsid-schema.ts"; 6 + import type { ActionUIDefinition, EditorBlockProps, ForEachDraft } from "./types.ts"; 7 + 8 + export type RecordDraft = { 9 + type: "record"; 10 + targetCollection: string; 11 + recordTemplate: string; 12 + comment: string; 13 + forEach?: ForEachDraft; 14 + }; 15 + 16 + function RecordActionEditor({ 17 + action, 18 + index, 19 + onChange, 20 + placeholders, 21 + }: EditorBlockProps<RecordDraft>) { 22 + const { 23 + targetSchema, 24 + targetSchemaLoading, 25 + targetSchemaError, 26 + nsidSuggestions, 27 + datalistId, 28 + fetchTargetSchema, 29 + fetchSuggestions, 30 + } = useNsidSchema(action.targetCollection); 31 + 32 + const targetId = `action-${index}-target-collection`; 33 + const templateId = `action-${index}-record-template`; 34 + 35 + return ( 36 + <> 37 + <div class={s.fieldGroup}> 38 + <label class={s.label} for={targetId}> 39 + Target Lexicon NSID 40 + </label> 41 + <input 42 + id={targetId} 43 + class={s.input} 44 + type="text" 45 + list={datalistId} 46 + placeholder="e.g. app.bsky.feed.like" 47 + value={action.targetCollection} 48 + onInput={(e: Event) => { 49 + const val = (e.target as HTMLInputElement).value; 50 + onChange({ ...action, targetCollection: val }); 51 + fetchTargetSchema(val); 52 + fetchSuggestions(val); 53 + }} 54 + required 55 + autocomplete="off" 56 + /> 57 + <span class={s.hint}>NSID of the collection to create a record in</span> 58 + <datalist id={datalistId}> 59 + {nsidSuggestions.map((nsid) => ( 60 + <option key={nsid} value={nsid} /> 61 + ))} 62 + </datalist> 63 + </div> 64 + 65 + {(targetSchema || targetSchemaLoading) && ( 66 + <RecordFormBuilder 67 + schema={targetSchema} 68 + loading={targetSchemaLoading} 69 + error={targetSchemaError} 70 + placeholders={placeholders} 71 + initialTemplate={action.recordTemplate || undefined} 72 + onChange={(tpl) => onChange({ ...action, recordTemplate: tpl })} 73 + /> 74 + )} 75 + 76 + {!targetSchema && !targetSchemaLoading && action.targetCollection && ( 77 + <div class={s.fieldGroup}> 78 + <label class={s.label} for={templateId}> 79 + Record template 80 + </label> 81 + {targetSchemaError && <span class={s.errorText}>{targetSchemaError}</span>} 82 + <textarea 83 + id={templateId} 84 + class={s.textarea} 85 + placeholder={ 86 + '{\n "subject": {\n "uri": "{{event.commit.record.subject.uri}}",\n "cid": "{{event.commit.cid}}"\n },\n "createdAt": "{{now}}"\n}' 87 + } 88 + value={action.recordTemplate} 89 + onInput={(e: Event) => 90 + onChange({ 91 + ...action, 92 + recordTemplate: (e.target as HTMLTextAreaElement).value, 93 + }) 94 + } 95 + required 96 + autocomplete="off" 97 + /> 98 + </div> 99 + )} 100 + </> 101 + ); 102 + } 103 + 104 + export const recordUiDefinition: ActionUIDefinition<RecordDraft, RecordAction> = { 105 + type: "record", 106 + recordProducing: true, 107 + catalogue: { 108 + label: "Create a record", 109 + description: "Create a new record in any collection", 110 + category: "pds", 111 + icon: FilePlus2, 112 + available: true, 113 + }, 114 + newDraft: () => ({ type: "record", targetCollection: "", recordTemplate: "", comment: "" }), 115 + fromAction: (a) => ({ 116 + type: "record", 117 + targetCollection: a.targetCollection, 118 + recordTemplate: a.recordTemplate, 119 + comment: a.comment ?? "", 120 + }), 121 + toInput: (d) => ({ 122 + type: "record", 123 + targetCollection: d.targetCollection, 124 + recordTemplate: d.recordTemplate, 125 + }), 126 + EditorBlock: RecordActionEditor, 127 + };
+55
app/islands/action-editors/registry.ts
··· 1 + import type { ActionType } from "../../../lib/actions/registry.js"; 2 + import { webhookUiDefinition, type WebhookDraft } from "./webhook.tsx"; 3 + import { recordUiDefinition, type RecordDraft } from "./record.tsx"; 4 + import { bskyPostUiDefinition, type BskyPostDraft } from "./bsky-post.tsx"; 5 + import { patchRecordUiDefinition, type PatchRecordDraft } from "./patch-record.tsx"; 6 + import { marginBookmarkUiDefinition, type MarginBookmarkDraft } from "./margin-bookmark.tsx"; 7 + import { sembleSaveUiDefinition, type SembleSaveDraft } from "./semble-save.tsx"; 8 + import { followUiDefinition, type FollowDraft } from "./follow.tsx"; 9 + import type { ActionUIDefinition } from "./types.ts"; 10 + 11 + /** Form-side counterpart to `ACTION_REGISTRY`. Keyed by the same `$type` as 12 + * the server-side registry, so the two can be paired by the AutomationForm 13 + * without an extra translation table. */ 14 + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- per-action entries are precisely typed at their declaration site. 15 + export const ACTION_UI_REGISTRY: Record<ActionType, ActionUIDefinition<any, any>> = { 16 + webhook: webhookUiDefinition, 17 + record: recordUiDefinition, 18 + "bsky-post": bskyPostUiDefinition, 19 + "patch-record": patchRecordUiDefinition, 20 + "margin-bookmark": marginBookmarkUiDefinition, 21 + "semble-save": sembleSaveUiDefinition, 22 + follow: followUiDefinition, 23 + }; 24 + 25 + /** Per-action draft union — replaces the hand-written `ActionDraft` that 26 + * used to live in AutomationForm.tsx. Adding a new action type here means 27 + * appending its draft to this union and registering it in 28 + * `ACTION_UI_REGISTRY`. */ 29 + export type ActionDraft = 30 + | WebhookDraft 31 + | RecordDraft 32 + | BskyPostDraft 33 + | PatchRecordDraft 34 + | MarginBookmarkDraft 35 + | SembleSaveDraft 36 + | FollowDraft; 37 + 38 + export type { 39 + WebhookDraft, 40 + RecordDraft, 41 + BskyPostDraft, 42 + PatchRecordDraft, 43 + MarginBookmarkDraft, 44 + SembleSaveDraft, 45 + FollowDraft, 46 + }; 47 + 48 + /** Client-safe variant of `lib/actions/registry.isRecordProducingAction`. 49 + * Importing the server registry from a client island drags drizzle/sqlite 50 + * through transitive dependencies (executor → dispatcher → db). The 51 + * `recordProducing` field on each UI definition mirrors the server flag, 52 + * enforced by a vitest test. */ 53 + export function isRecordProducingAction(type: string): boolean { 54 + return ACTION_UI_REGISTRY[type as ActionType]?.recordProducing ?? false; 55 + }
+62
app/islands/action-editors/semble-save.tsx
··· 1 + import type { SembleSaveAction } from "../../../lib/db/schema.js"; 2 + import { BookmarkPlus } from "../../icons.ts"; 3 + import * as s from "../AutomationForm.css.ts"; 4 + import type { ActionUIDefinition, ForEachDraft } from "./types.ts"; 5 + 6 + export type SembleSaveDraft = { 7 + type: "semble-save"; 8 + url: string; 9 + comment: string; 10 + forEach?: ForEachDraft; 11 + }; 12 + 13 + function SembleSaveActionEditor({ 14 + action, 15 + index, 16 + onChange, 17 + }: { 18 + action: SembleSaveDraft; 19 + index: number; 20 + onChange: (a: SembleSaveDraft) => void; 21 + }) { 22 + const urlId = `action-${index}-semble-url`; 23 + return ( 24 + <div class={s.fieldGroup}> 25 + <label class={s.label} for={urlId}> 26 + Page URL 27 + </label> 28 + <input 29 + id={urlId} 30 + class={s.input} 31 + type="text" 32 + placeholder="e.g. https://example.com or {{event.commit.record.subject.uri}}" 33 + value={action.url} 34 + onInput={(e: Event) => onChange({ ...action, url: (e.target as HTMLInputElement).value })} 35 + required 36 + autocomplete="off" 37 + /> 38 + <span class={s.hint}> 39 + URL of the page to save. Metadata (title, description, image) will be fetched automatically. 40 + Supports {"{{placeholders}}"}. 41 + </span> 42 + </div> 43 + ); 44 + } 45 + 46 + export const sembleSaveUiDefinition: ActionUIDefinition<SembleSaveDraft, SembleSaveAction> = { 47 + type: "semble-save", 48 + recordProducing: true, 49 + catalogue: { 50 + label: "Save on Semble", 51 + description: "Save a URL as a card on Semble", 52 + category: "apps", 53 + icon: BookmarkPlus, 54 + available: true, 55 + colorKey: "cosmik", 56 + faviconDomain: "semble.so", 57 + }, 58 + newDraft: () => ({ type: "semble-save", url: "", comment: "" }), 59 + fromAction: (a) => ({ type: "semble-save", url: a.url, comment: a.comment ?? "" }), 60 + toInput: (d) => ({ type: "semble-save", url: d.url }), 61 + EditorBlock: SembleSaveActionEditor, 62 + };
+80
app/islands/action-editors/types.ts
··· 1 + import type { FC } from "hono/jsx"; 2 + import type { Action, FollowTarget } from "../../../lib/db/schema.js"; 3 + import type { ActionInput } from "../../../lib/actions/validation.js"; 4 + import type { ColorKey } from "../../../lib/automations/follow-targets.js"; 5 + 6 + /** Common prop signature for the lucide-style icon components. */ 7 + type IconProps = { size?: number; class?: string; color?: string; "stroke-width"?: number }; 8 + export type ActionIcon = FC<IconProps>; 9 + 10 + /** Picker-tile metadata shown in the form's "+ Add action" picker. Lives on 11 + * the UI side because it's pure display data — the server has no use for 12 + * icons or human-readable labels. */ 13 + export type CatalogueTile = { 14 + /** Imperative phrase shown in the picker (e.g. "Post to Bluesky"). */ 15 + label: string; 16 + description: string; 17 + category: "webhook" | "bluesky" | "apps" | "pds"; 18 + icon: ActionIcon; 19 + available: boolean; 20 + colorKey?: ColorKey; 21 + faviconDomain?: string; 22 + }; 23 + 24 + /** Form-side representation of a per-iteration `forEach` config. Mirrors 25 + * `ForEachConfig` but always materializes the conditions array (the form 26 + * needs it for incremental editing). */ 27 + export type ForEachDraft = { 28 + path: string; 29 + conditions: Array<{ field: string; operator: string; value: string; comment: string }>; 30 + }; 31 + 32 + /** Custom-header row inside a webhook draft. Two-step entry — the user types 33 + * key and value separately and we only emit the entry once both are 34 + * non-empty. */ 35 + export type HeaderDraft = { key: string; value: string }; 36 + 37 + /** Props every action-editor block receives. `placeholders` is universally 38 + * threaded so editors can opt into placeholder autocomplete without each 39 + * editor declaring its own prop type. */ 40 + export type EditorBlockProps<TDraft> = { 41 + action: TDraft; 42 + index: number; 43 + onChange: (a: TDraft) => void; 44 + placeholders: string[]; 45 + }; 46 + 47 + /** Init payload for `newDraft`. `followTarget` is set when the picker tile is 48 + * one of the per-target follow tiles (`follow-bluesky` etc.); other actions 49 + * ignore it. */ 50 + export type NewDraftInit = { 51 + followTarget?: FollowTarget; 52 + }; 53 + 54 + /** UI counterpart to `ActionDefinition`: editor JSX, draft↔action conversions, 55 + * and the toInput projection that produces the API payload shape. The form 56 + * reaches for these via `ACTION_UI_REGISTRY`. */ 57 + export type ActionUIDefinition< 58 + TDraft extends { type: Action["$type"]; comment: string; forEach?: ForEachDraft }, 59 + TAction extends Action, 60 + > = { 61 + type: TAction["$type"]; 62 + /** Mirrors `ActionDefinition.recordProducing` on the server. Duplicated 63 + * here so client code (form, LexiconFlow) doesn't have to reach into the 64 + * server registry — which would drag drizzle/sqlite into the client 65 + * bundle. The mirror is verified by a vitest test. */ 66 + recordProducing: boolean; 67 + /** Picker-tile metadata. Optional because some action types map to 68 + * multiple tiles (e.g. follow expands into one tile per FOLLOW_TARGETS 69 + * entry); those keep their tile list hand-curated in `action-catalogue`. */ 70 + catalogue?: CatalogueTile; 71 + /** Build an empty draft for "+ Add action" clicks. */ 72 + newDraft: (init: NewDraftInit) => TDraft; 73 + /** Project a stored Action into the editor's draft shape. */ 74 + fromAction: (action: TAction) => TDraft; 75 + /** Project a draft back to the API input shape. The route's POST/PATCH 76 + * passes this directly into the server-side registry's `validate`. Common 77 + * fields (forEach, comment) are added by the form. */ 78 + toInput: (draft: TDraft) => Omit<ActionInput, "forEach" | "comment">; 79 + EditorBlock: FC<EditorBlockProps<TDraft>>; 80 + };
+94
app/islands/action-editors/use-nsid-schema.ts
··· 1 + import { useState, useRef, useCallback } from "hono/jsx"; 2 + import type { RecordSchema } from "../../../lib/lexicons/schema-types.js"; 3 + 4 + const NSID_RE = /^[a-z][a-z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*){2,}$/; 5 + 6 + /** Shared hook for record-producing editors that need to load the target 7 + * collection's lexicon schema (record + patch-record). Owns NSID typeahead, 8 + * debounced schema fetch, and a stable datalist id for input suggestions. */ 9 + export function useNsidSchema(initialCollection?: string) { 10 + const [targetSchema, setTargetSchema] = useState<RecordSchema | null>(null); 11 + const [targetSchemaLoading, setTargetSchemaLoading] = useState(false); 12 + const [targetSchemaError, setTargetSchemaError] = useState(""); 13 + const [nsidSuggestions, setNsidSuggestions] = useState<string[]>([]); 14 + const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 15 + const suggestDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 16 + const abortRef = useRef<AbortController | null>(null); 17 + const lastSuggestPrefix = useRef(""); 18 + const [datalistId] = useState(() => `nsid-${Math.random().toString(36).slice(2, 8)}`); 19 + const initialFetched = useRef(false); 20 + 21 + const fetchTargetSchema = useCallback((nsid: string) => { 22 + if (debounceRef.current) clearTimeout(debounceRef.current); 23 + if (!nsid || !NSID_RE.test(nsid)) { 24 + abortRef.current?.abort(); 25 + setTargetSchema(null); 26 + setTargetSchemaError(""); 27 + return; 28 + } 29 + debounceRef.current = setTimeout(async () => { 30 + abortRef.current?.abort(); 31 + const ctrl = new AbortController(); 32 + abortRef.current = ctrl; 33 + setTargetSchemaLoading(true); 34 + setTargetSchemaError(""); 35 + try { 36 + const res = await fetch(`/api/lexicons/${encodeURIComponent(nsid)}?schema=record`, { 37 + signal: ctrl.signal, 38 + }); 39 + const data = await res.json(); 40 + if (!res.ok) { 41 + setTargetSchemaError(data.error || "Failed to load schema"); 42 + setTargetSchema(null); 43 + } else { 44 + setTargetSchema(data.record ?? null); 45 + if (!data.record) setTargetSchemaError("No record schema found for this collection"); 46 + } 47 + } catch (err) { 48 + if ((err as Error).name === "AbortError") return; 49 + setTargetSchemaError("Failed to fetch target collection schema"); 50 + setTargetSchema(null); 51 + } finally { 52 + if (abortRef.current === ctrl) { 53 + abortRef.current = null; 54 + setTargetSchemaLoading(false); 55 + } 56 + } 57 + }, 400); 58 + }, []); 59 + 60 + const fetchSuggestions = useCallback((value: string) => { 61 + if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current); 62 + const dotIndex = value.lastIndexOf("."); 63 + const prefix = dotIndex > 0 ? value.slice(0, dotIndex + 1) : ""; 64 + if (!prefix || prefix.split(".").filter(Boolean).length < 2) return; 65 + if (prefix === lastSuggestPrefix.current) return; 66 + suggestDebounceRef.current = setTimeout(async () => { 67 + lastSuggestPrefix.current = prefix; 68 + try { 69 + const res = await fetch(`/api/lexicons/suggest?prefix=${encodeURIComponent(prefix)}`); 70 + if (res.ok) { 71 + const data = await res.json(); 72 + setNsidSuggestions(data.suggestions ?? []); 73 + } 74 + } catch { 75 + // ignore 76 + } 77 + }, 300); 78 + }, []); 79 + 80 + if (!initialFetched.current && initialCollection) { 81 + initialFetched.current = true; 82 + fetchTargetSchema(initialCollection); 83 + } 84 + 85 + return { 86 + targetSchema, 87 + targetSchemaLoading, 88 + targetSchemaError, 89 + nsidSuggestions, 90 + datalistId, 91 + fetchTargetSchema, 92 + fetchSuggestions, 93 + }; 94 + }
+132
app/islands/action-editors/webhook.tsx
··· 1 + import type { WebhookAction } from "../../../lib/db/schema.js"; 2 + import { Webhook } from "../../icons.ts"; 3 + import * as s from "../AutomationForm.css.ts"; 4 + import type { ActionUIDefinition, ForEachDraft, HeaderDraft } from "./types.ts"; 5 + 6 + export type WebhookDraft = { 7 + type: "webhook"; 8 + callbackUrl: string; 9 + headers: HeaderDraft[]; 10 + comment: string; 11 + forEach?: ForEachDraft; 12 + }; 13 + 14 + function WebhookActionEditor({ 15 + action, 16 + index, 17 + onChange, 18 + }: { 19 + action: WebhookDraft; 20 + index: number; 21 + onChange: (a: WebhookDraft) => void; 22 + }) { 23 + const updateHeader = (i: number, key: "key" | "value", val: string) => { 24 + const headers = action.headers.map((h, j) => (j === i ? { ...h, [key]: val } : h)); 25 + onChange({ ...action, headers }); 26 + }; 27 + const addHeader = () => { 28 + onChange({ ...action, headers: [...action.headers, { key: "", value: "" }] }); 29 + }; 30 + const removeHeader = (i: number) => { 31 + onChange({ ...action, headers: action.headers.filter((_, j) => j !== i) }); 32 + }; 33 + 34 + const callbackId = `action-${index}-callback-url`; 35 + const headersGroupId = `action-${index}-headers-group`; 36 + 37 + return ( 38 + <> 39 + <div class={s.fieldGroup}> 40 + <label class={s.label} for={callbackId}> 41 + Callback URL 42 + </label> 43 + <input 44 + id={callbackId} 45 + class={s.input} 46 + type="url" 47 + placeholder="e.g. https://example.com/hooks/events" 48 + value={action.callbackUrl} 49 + onInput={(e: Event) => 50 + onChange({ ...action, callbackUrl: (e.target as HTMLInputElement).value }) 51 + } 52 + required 53 + autocomplete="off" 54 + /> 55 + </div> 56 + <div class={s.fieldGroup} role="group" aria-labelledby={headersGroupId}> 57 + <span id={headersGroupId} class={s.label}> 58 + Custom headers 59 + </span> 60 + <span class={s.hint}> 61 + Use <code>{"{{secret:name}}"}</code> to reference stored secrets 62 + </span> 63 + {action.headers.map((header, i) => ( 64 + <div key={i} class={s.conditionRow}> 65 + <div class={s.conditionField}> 66 + <input 67 + class={s.input} 68 + type="text" 69 + placeholder="e.g. Authorization" 70 + value={header.key} 71 + onInput={(e: Event) => updateHeader(i, "key", (e.target as HTMLInputElement).value)} 72 + aria-label="Header name" 73 + autocomplete="off" 74 + /> 75 + </div> 76 + <div class={s.conditionValue}> 77 + <input 78 + class={s.input} 79 + type="text" 80 + placeholder="e.g. Bearer {{secret:my-token}}" 81 + value={header.value} 82 + onInput={(e: Event) => 83 + updateHeader(i, "value", (e.target as HTMLInputElement).value) 84 + } 85 + aria-label="Header value" 86 + autocomplete="off" 87 + /> 88 + </div> 89 + <button type="button" class={s.removeBtn} onClick={() => removeHeader(i)}> 90 + Remove 91 + </button> 92 + </div> 93 + ))} 94 + <button type="button" class={s.addBtn} onClick={addHeader}> 95 + + Add Header 96 + </button> 97 + </div> 98 + </> 99 + ); 100 + } 101 + 102 + export const webhookUiDefinition: ActionUIDefinition<WebhookDraft, WebhookAction> = { 103 + type: "webhook", 104 + recordProducing: false, 105 + catalogue: { 106 + label: "Send a webhook", 107 + description: "POST event data to an external URL", 108 + category: "webhook", 109 + icon: Webhook, 110 + available: true, 111 + }, 112 + newDraft: () => ({ type: "webhook", callbackUrl: "", headers: [], comment: "" }), 113 + fromAction: (a) => ({ 114 + type: "webhook", 115 + callbackUrl: a.callbackUrl, 116 + headers: a.headers ? Object.entries(a.headers).map(([key, value]) => ({ key, value })) : [], 117 + comment: a.comment ?? "", 118 + }), 119 + toInput: (d) => { 120 + const filtered = d.headers.filter((h) => h.key.trim() && h.value.trim()); 121 + const headers = 122 + filtered.length > 0 123 + ? Object.fromEntries(filtered.map((h) => [h.key.trim(), h.value.trim()])) 124 + : undefined; 125 + return { 126 + type: "webhook", 127 + callbackUrl: d.callbackUrl, 128 + ...(headers ? { headers } : {}), 129 + }; 130 + }, 131 + EditorBlock: WebhookActionEditor, 132 + };
-8
lib/actions/bsky-post.ts
··· 6 6 import type { MatchedEvent } from "../jetstream/consumer.js"; 7 7 import { AUTOMATION_LIMITS } from "../automations/limits.js"; 8 8 import { BCP47_RE, VALID_BSKY_LABELS } from "./validation.js"; 9 - import { MessageSquare } from "../../app/icons.js"; 10 9 import type { ActionDefinition } from "./registry.js"; 11 10 import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 12 11 ··· 185 184 toPds, 186 185 execute: executeBskyPost, 187 186 dryRunDescribe, 188 - catalogue: { 189 - label: "Post to Bluesky", 190 - description: "Publish a post to your Bluesky account", 191 - category: "bluesky", 192 - icon: MessageSquare, 193 - available: true, 194 - }, 195 187 };
-8
lib/actions/executor.ts
··· 4 4 import { parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js"; 5 5 import type { MatchedEvent } from "../jetstream/consumer.js"; 6 6 import { isValidNsid } from "../lexicons/resolver.js"; 7 - import { FilePlus2 } from "../../app/icons.js"; 8 7 import type { ActionDefinition } from "./registry.js"; 9 8 import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 10 9 ··· 137 136 toPds, 138 137 execute: executeAction, 139 138 dryRunDescribe, 140 - catalogue: { 141 - label: "Create a record", 142 - description: "Create a new record in any collection", 143 - category: "pds", 144 - icon: FilePlus2, 145 - available: true, 146 - }, 147 139 };
-9
lib/actions/margin-bookmark.ts
··· 7 7 import { fetchURLMetadata } from "../url-metadata.js"; 8 8 import { config } from "../config.js"; 9 9 import { MARGIN_BOOKMARK_LIMITS } from "../automations/limits.js"; 10 - import { Bookmark } from "../../app/icons.js"; 11 10 import type { ActionDefinition } from "./registry.js"; 12 11 import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 13 12 ··· 330 329 toPds, 331 330 execute: executeMarginBookmark, 332 331 dryRunDescribe, 333 - catalogue: { 334 - label: "Bookmark on Margin", 335 - description: "Create a bookmark note in Margin.at", 336 - category: "apps", 337 - icon: Bookmark, 338 - available: true, 339 - faviconDomain: "margin.at", 340 - }, 341 332 };
-8
lib/actions/patch-record.ts
··· 11 11 import { parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js"; 12 12 import type { MatchedEvent } from "../jetstream/consumer.js"; 13 13 import { isValidNsid } from "../lexicons/resolver.js"; 14 - import { Pencil } from "../../app/icons.js"; 15 14 import type { ActionDefinition } from "./registry.js"; 16 15 import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 17 16 ··· 215 214 toPds, 216 215 execute: executePatchRecord, 217 216 dryRunDescribe, 218 - catalogue: { 219 - label: "Update a record", 220 - description: "Modify fields of an existing record", 221 - category: "pds", 222 - icon: Pencil, 223 - available: true, 224 - }, 225 217 };
+23
lib/actions/registry-contract.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { ACTION_REGISTRY, ACTION_TYPES } from "./registry.ts"; 3 + import { ACTION_UI_REGISTRY } from "../../app/islands/action-editors/registry.ts"; 4 + 5 + // The form/island side keeps a `recordProducing` mirror on each 6 + // ActionUIDefinition because client code can't import the server registry 7 + // (it would drag drizzle/sqlite through transitive deps). This test pins the 8 + // two halves together so a future change to the server flag isn't silently 9 + // out of sync with the client form. 10 + describe("server vs UI registry contract", () => { 11 + it("recordProducing flags match across both registries", () => { 12 + for (const type of ACTION_TYPES) { 13 + expect(ACTION_UI_REGISTRY[type].recordProducing, type).toBe( 14 + ACTION_REGISTRY[type].recordProducing, 15 + ); 16 + } 17 + }); 18 + 19 + it("every action $type is present in both registries", () => { 20 + expect(Object.keys(ACTION_REGISTRY).sort()).toEqual([...ACTION_TYPES].sort()); 21 + expect(Object.keys(ACTION_UI_REGISTRY).sort()).toEqual([...ACTION_TYPES].sort()); 22 + }); 23 + });
+19 -48
lib/actions/registry.ts
··· 1 - import type { FC } from "hono/jsx"; 2 1 import type { Action } from "../db/schema.js"; 3 2 import type { PdsAction } from "../automations/pds.js"; 4 - import type { ColorKey } from "../automations/follow-targets.js"; 5 - 6 - /** Common prop signature for the lucide-style icon components in `app/icons.ts`. */ 7 - type IconProps = { size?: number; class?: string; color?: string; "stroke-width"?: number }; 8 - export type ActionIcon = FC<IconProps>; 9 3 import type { 10 4 ActionHandler, 11 5 DryRunContext, ··· 17 11 * key. Derived from `Action` so adding a new variant is a type error here. */ 18 12 export type ActionType = Action["$type"]; 19 13 20 - /** Catalogue tile metadata that today lives in `action-catalogue.ts`. Mirrored 21 - * here so each registry entry owns its UI footprint. The hand-curated 22 - * ACTION_CATALOGUE array in action-catalogue.ts becomes a derived view in 23 - * Phase 4. */ 24 - export type CatalogueTile = { 25 - /** Imperative phrase shown in the "add action" picker (e.g. "Post to Bluesky"). */ 26 - label: string; 27 - description: string; 28 - category: "webhook" | "bluesky" | "apps" | "pds"; 29 - /** Lucide-style icon component (one of the exports from `app/icons.ts`). */ 30 - icon: ActionIcon; 31 - available: boolean; 32 - /** Override for the tile's data-cat color (e.g. follow-sifa is sifa-blue 33 - * while still living under "Bluesky"). */ 34 - colorKey?: ColorKey; 35 - /** Domain whose favicon is rendered next to the icon. */ 36 - faviconDomain?: string; 37 - }; 38 - 39 14 /** Validation outcome for the API POST/PATCH routes. The single function call 40 15 * collapses what's currently a per-action if-branch in both routes. PDS 41 16 * serialization is a separate concern (see `toPds`) so PATCH can reproject a ··· 44 19 | { ok: true; local: TAction } 45 20 | { ok: false; error: string; status?: number }; 46 21 47 - /** Single source of truth per action type. Subsequent phases populate 48 - * `ACTION_REGISTRY` with one of these per `$type`; the dispatchers in 49 - * handler.ts, the API routes, auth/client.ts, and pds-serialize.ts then 50 - * reduce to `ACTION_REGISTRY[type].method(...)` lookups. */ 22 + /** Server-side single source of truth per action type. The dispatchers in 23 + * handler.ts, the API routes, auth/client.ts, and pds-serialize.ts reduce 24 + * to `ACTION_REGISTRY[type].method(...)` lookups. 25 + * 26 + * Pure UI metadata (icons, picker tiles) lives on the client side in 27 + * `app/islands/action-editors`. Keeping it out of the server registry means 28 + * importing this file from a route or executor doesn't drag the icon 29 + * components or app-layer code into the server bundle, and conversely the 30 + * client form bundle doesn't transitively pull drizzle/sqlite. */ 51 31 export type ActionDefinition< 52 32 TAction extends Action = Action, 53 33 TInput = unknown, ··· 57 37 pdsType: TPdsAction["$type"]; 58 38 59 39 /** Short noun-phrase label used in the dashboard automation list and 60 - * delivery-log filters (e.g. "Bluesky Post", "Webhook"). Distinct from 61 - * `catalogue.label`, which is the imperative phrase in the picker. */ 40 + * delivery-log filters (e.g. "Bluesky Post", "Webhook"). */ 62 41 displayLabel: string; 63 42 64 43 /** Replaces `RECORD_PRODUCING_TYPES` and the `actionResultNames.push` calls 65 - * in API routes. */ 44 + * in API routes. Mirrored on the client side via `ActionUIDefinition` 45 + * to keep the form's `isRecordProducingAction` check off the server registry. */ 66 46 recordProducing: boolean; 67 47 68 48 /** Replaces the per-$type checks in `actionsNeedFullScope`. */ ··· 82 62 /** Build the dry-run delivery_logs row content for this action type. */ 83 63 dryRunDescribe(action: TAction, ctx: DryRunContext): Promise<DryRunDescription>; 84 64 85 - /** Catalogue tile shown in the form's "add action" picker. Optional 86 - * because some action types map to multiple tiles (e.g. `follow` expands 87 - * into one tile per FOLLOW_TARGETS entry); those keep their tile list 88 - * hand-curated in `action-catalogue.ts`. */ 89 - catalogue?: CatalogueTile; 90 - 91 65 /** Optional override for how the GET /api/automations route serializes a 92 66 * stored action — webhooks use this to strip the secret. */ 93 67 serializeForApi?(action: TAction): unknown; ··· 95 69 96 70 /** Insertion order is the canonical action ordering used everywhere a list of 97 71 * action types matters (catalogue tiles, label tables, lexicon refs, drift 98 - * tests). Phase 2 starts populating ACTION_REGISTRY by type. */ 72 + * tests). */ 99 73 export const ACTION_TYPES: readonly ActionType[] = [ 100 74 "webhook", 101 75 "bsky-post", ··· 106 80 "patch-record", 107 81 ] as const; 108 82 109 - /** Map of $type → definition. Phase 2 fills this in one entry at a time; 110 - * consumers begin reading from it incrementally while the legacy switches 111 - * remain in place. Empty until then so this scaffolding commit is a pure 112 - * type-level addition. */ 113 83 import { sembleSaveDefinition } from "./semble-save.js"; 114 84 import { marginBookmarkDefinition } from "./margin-bookmark.js"; 115 85 import { followDefinition } from "./follow.js"; ··· 118 88 import { recordDefinition } from "./executor.js"; 119 89 import { webhookDefinition } from "./webhook.js"; 120 90 121 - /** Map of $type → definition. Every action type is registered after Phase 3. 122 - * Subsequent dispatchers consume the registry directly and need no fallback. */ 91 + /** Map of $type → definition. Server-side only — importing this file from a 92 + * client island will pull drizzle/sqlite through transitive deps. The client 93 + * form uses `ACTION_UI_REGISTRY` instead (purely UI metadata + JSX). */ 123 94 // eslint-disable-next-line @typescript-eslint/no-explicit-any -- per-action entries are precisely typed at their declaration site. 124 95 export const ACTION_REGISTRY: Record<ActionType, ActionDefinition<any, any, any>> = { 125 96 "semble-save": sembleSaveDefinition, ··· 131 102 webhook: webhookDefinition, 132 103 }; 133 104 134 - /** Action types that produce a record result (uri, cid, rkey) for chaining. 135 - * Lookup goes through the registry — adding a new action with 136 - * `recordProducing: true` is enough; no parallel set to maintain. */ 105 + /** Server-side `isRecordProducingAction`. The form/island side has its own 106 + * client-safe copy in `action-editors/registry.ts` driven by the UI 107 + * definitions; both stay in sync via a vitest contract test. */ 137 108 export function isRecordProducingAction(type: string): boolean { 138 109 return ACTION_REGISTRY[type as ActionType]?.recordProducing ?? false; 139 110 }
-11
lib/actions/semble-save.ts
··· 5 5 import type { MatchedEvent } from "../jetstream/consumer.js"; 6 6 import { fetchURLMetadata, type UrlMetadata } from "../url-metadata.js"; 7 7 import { SEMBLE_SAVE_LIMITS } from "../automations/limits.js"; 8 - import { BookmarkPlus } from "../../app/icons.js"; 9 - import type { ColorKey } from "../automations/follow-targets.js"; 10 8 import type { ActionDefinition } from "./registry.js"; 11 9 import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 12 10 ··· 209 207 toPds, 210 208 execute: executeSembleSave, 211 209 dryRunDescribe, 212 - catalogue: { 213 - label: "Save on Semble", 214 - description: "Save a URL as a card on Semble", 215 - category: "apps", 216 - icon: BookmarkPlus, 217 - available: true, 218 - colorKey: "cosmik" satisfies ColorKey, 219 - faviconDomain: "semble.so", 220 - }, 221 210 };
-8
lib/actions/webhook.ts
··· 3 3 import { dispatch, buildPayload } from "../webhooks/dispatcher.js"; 4 4 import { assertPublicUrl, UrlGuardError } from "../url-guard.js"; 5 5 import { verifyCallback } from "../automations/verify.js"; 6 - import { Webhook } from "../../app/icons.js"; 7 6 import { validateWebhookHeaders } from "./validation.js"; 8 7 import type { ActionDefinition } from "./registry.js"; 9 8 import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; ··· 114 113 execute: dispatch, 115 114 dryRunDescribe, 116 115 serializeForApi, 117 - catalogue: { 118 - label: "Send a webhook", 119 - description: "POST event data to an external URL", 120 - category: "webhook", 121 - icon: Webhook, 122 - available: true, 123 - }, 124 116 };
+4 -3
lib/automations/action-catalogue.ts
··· 1 1 import { Heart, Trash2, UserPlus } from "../../app/icons.js"; 2 - import { ACTION_REGISTRY, type ActionIcon } from "../actions/registry.js"; 2 + import { ACTION_UI_REGISTRY } from "../../app/islands/action-editors/registry.ts"; 3 + import type { ActionIcon } from "../../app/islands/action-editors/types.ts"; 3 4 import { FOLLOW_TARGETS, type ColorKey, type FollowTarget } from "./follow-targets.js"; 4 5 5 6 export type AddableActionId = ··· 34 35 }; 35 36 36 37 /** Tile entry derived from a registered action's `catalogue` metadata. */ 37 - function tileFromRegistry(type: keyof typeof ACTION_REGISTRY): Tile { 38 - const def = ACTION_REGISTRY[type]!; 38 + function tileFromRegistry(type: keyof typeof ACTION_UI_REGISTRY): Tile { 39 + const def = ACTION_UI_REGISTRY[type]!; 39 40 const cat = def.catalogue!; 40 41 return { 41 42 id: type,