@anchrd/intel-ui 0.32.0 → 0.34.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.
@@ -24,15 +24,19 @@ import {
24
24
  useReactFlow,
25
25
  } from "@xyflow/react";
26
26
  import { FileSearch, Info, Send, Wrench } from "lucide-react";
27
- import { useEffect, useId, useMemo, useState } from "react";
27
+ import { createContext, useContext, useEffect, useId, useMemo, useState } from "react";
28
28
  import { TreeEntryMediaType } from "@/app/app-tree/app-tree.tsx";
29
29
  import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
30
30
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
31
+ import { IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
32
+ import { RefusalNotice } from "@/data/request-refusal/refusal-notice.tsx";
33
+ import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
31
34
  import { EntryPicker } from "@/entry-picker/entry-picker.tsx";
32
35
  import { FlowRuns } from "@/flow-runs/flow-runs.tsx";
33
36
  import { nodeIcon } from "@/flows/node-icon/node-icon.ts";
34
37
  import { NodePalette, usePaletteOpen } from "@/flows/node-palette/node-palette.tsx";
35
38
  import { GraphPane } from "@/graph-pane/graph-pane.tsx";
39
+ import type { I18n } from "@/i18n/i18n.types.ts";
36
40
  import { useI18n } from "@/i18n/i18n-context.tsx";
37
41
  import { Modal } from "@/modal/modal.tsx";
38
42
  import { useIntelRouterContext } from "@/router/router-context.ts";
@@ -90,9 +94,24 @@ const flowKindOfEntry: Record<string, FlowNode["kind"] | undefined> = {
90
94
  flow: "subflow",
91
95
  };
92
96
 
97
+ /**
98
+ * The tree links of this flow that name nothing any more, by resource id (#509).
99
+ *
100
+ * ⚠️ A context rather than a field on the node's `data`, because the canvas nodes are React state
101
+ * that the loaded version owns: writing the answer of a query into them would put a second writer
102
+ * on that state and let a background refetch land in the middle of an edit. The set is read where
103
+ * it is drawn and nowhere else.
104
+ */
105
+ const InvalidLinks = createContext<ReadonlySet<string>>(new Set());
106
+
93
107
  function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
94
108
  const i18n = useI18n();
95
109
  const contract = data.node;
110
+ const invalidLinks = useContext(InvalidLinks);
111
+ // ⚠️ Said in a WORD, not by colour alone. A card that only turned red would say nothing to a
112
+ // colour-blind reader and nothing at all to a screen reader — and this is the one state on the
113
+ // canvas that stops the flow from running (#509).
114
+ const broken = isLinkNode(contract) && invalidLinks.has(contract.configuration.resourceId);
96
115
  const Icon = nodeIcon[contract.kind];
97
116
  const branching = contract.kind === "condition";
98
117
  // ⚠️ The start is told apart by silhouette first (#39): a pill among rectangles, in the primary
@@ -117,9 +136,14 @@ function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
117
136
  <span className="min-w-0">
118
137
  <span className="block truncate text-sm font-medium">{contract.label}</span>
119
138
  {/* The kind through the catalog, not the raw key: the start node is called Start on the
120
- canvas as well, and `trigger` would name an event that no longer exists (#39). */}
121
- <span className="block text-xs text-muted-foreground">
122
- {i18n.t(`flows.node.${contract.kind}`)}
139
+ canvas as well, and `trigger` would name an event that no longer exists (#39).
140
+ ⚠️ A broken link says so INSTEAD of naming its kind, because "Document" beside a
141
+ reference that names nothing is the sentence #509 is about: the card looked like every
142
+ valid link on the canvas. */}
143
+ <span
144
+ className={`block text-xs ${broken ? "text-destructive" : "text-muted-foreground"}`}
145
+ >
146
+ {broken ? i18n.t("flows.nodeInvalid") : i18n.t(`flows.node.${contract.kind}`)}
123
147
  </span>
124
148
  </span>
125
149
  {riding === "follows" || riding === "pinned" ? (
@@ -348,6 +372,31 @@ function newNode(
348
372
  }
349
373
  }
350
374
 
375
+ /**
376
+ * Which sentence a refused SAVE is told with.
377
+ *
378
+ * ⚠️ `400 flow_graph_invalid` and `409` are two different answers, and until #508 they shared one
379
+ * sentence — the conflict one. For a rejected graph that advice is not merely useless but
380
+ * destructive: reloading discards the unsaved canvas, and the next attempt is refused for exactly
381
+ * the same reason, so following it costs the work and changes nothing.
382
+ *
383
+ * The graph rule therefore shows the API's OWN sentence: only the server knows which step broke
384
+ * which rule, and it names that step by its label since #508. A conflict keeps the reload sentence,
385
+ * because there reloading IS the way out. Everything else gets neither — a timeout or a `502` is
386
+ * worth repeating, and nothing about it says the loaded version is stale.
387
+ *
388
+ * ⚠️ Not `resourceErrorKey`: that maps a code onto a catalog key, and this case has no key to map
389
+ * onto — its whole content is the server's sentence.
390
+ */
391
+ function saveRefusalText(error: unknown, i18n: I18n): string {
392
+ const refusal = error instanceof IntelRequestError ? error : null;
393
+ if (refusal?.status === 400 && refusal.code === "flow_graph_invalid") {
394
+ return i18n.t("flows.saveInvalid", { detail: refusal.message });
395
+ }
396
+ if (refusal?.status === 409) return i18n.t("flows.saveConflict");
397
+ return i18n.t("flows.saveFailed");
398
+ }
399
+
351
400
  // ⚠️ The provider wraps the editor rather than sitting inside it: `useReactFlow` — which is how the
352
401
  // drop turns a screen point into a graph point — has to be called from a component UNDER it, and
353
402
  // the hook lives in the same component that renders the canvas.
@@ -401,6 +450,21 @@ function FlowsEditor() {
401
450
  queryKey: ["flows"],
402
451
  queryFn: () => data.listFlows(),
403
452
  });
453
+ /**
454
+ * The refusal of the top level, read off the query the picker already holds.
455
+ *
456
+ * ⚠️ No second query, and that is the whole difference to #445 next door: `["flows"]` is asked
457
+ * with or without a selection, so the answer for the empty screen is already in the cache. What
458
+ * the screen did was throw it away — `listTreeChildren` fails whole once `flows/read` is missing,
459
+ * the sidebar has said so since #430, and the main area beside it went on inviting the reader to
460
+ * pick a flow they cannot see (#557).
461
+ *
462
+ * ⚠️ Off the ERROR rather than off a missing answer. A sentence about a permission, shown while
463
+ * the request is still travelling, claims a refusal nobody has spoken yet — the mistake from #350,
464
+ * and `packages/ui/CLAUDE.md` has the rule: a missing answer is a reason to say less, never to
465
+ * refuse more. Until a refusal arrives, what stands is the invitation, which claims nothing.
466
+ */
467
+ const rootRefusal = refusalOf(callable.error);
404
468
  // The tree in the sidebar and the header search both name the flow to open through `?select=`;
405
469
  // `?view=` says how it is shown — the editor, the relation graph (#19) or the runs (#35).
406
470
  const selection = useRouterState({
@@ -426,6 +490,10 @@ function FlowsEditor() {
426
490
  queryFn: () => data.getFlow(selectedFlowId ?? ""),
427
491
  enabled: Boolean(selectedFlowId),
428
492
  });
493
+ // ⚠️ A refusal is not a failure. "Reload the latest version and try again." is advice for a
494
+ // network that came back; against a `403` or a `404` it sends somebody to reload a screen that
495
+ // will answer the same thing forever, and it hides that the answer was final (#430).
496
+ const documentRefusal = refusalOf(document.error);
429
497
  const initial = useMemo(() => canvas(defaultGraph()), []);
430
498
  const [nodes, setNodes] = useState<CanvasNode[]>(initial.nodes);
431
499
  const [edges, setEdges] = useState<CanvasEdge[]>(initial.edges);
@@ -450,6 +518,19 @@ function FlowsEditor() {
450
518
  setDirty(false);
451
519
  }, [document.data, selectedFlowId]);
452
520
  const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null;
521
+ // ⚠️ The same query `FlowNeeds` asks, under the same key: TanStack answers both from one cache
522
+ // entry, so the canvas and the panel cannot end up disagreeing about which link is dead. A second
523
+ // key would be a second answer to one question, and the one people would trust is the one they
524
+ // happen to be looking at (#509).
525
+ const needs = useQuery({
526
+ queryKey: ["flow-requirements", selectedFlowId],
527
+ queryFn: () => data.getFlowRequirements(selectedFlowId ?? ""),
528
+ enabled: Boolean(selectedFlowId),
529
+ });
530
+ const invalidLinks = useMemo(
531
+ () => new Set(needs.data?.invalidNodes ?? []),
532
+ [needs.data?.invalidNodes],
533
+ );
453
534
 
454
535
  const save = useMutation({
455
536
  mutationFn: () =>
@@ -496,9 +577,25 @@ function FlowsEditor() {
496
577
  ) : null}
497
578
  <UnsavedChangesGuard dirty={dirty} />
498
579
  <div className="flex min-h-0 flex-1">
499
- {selectedFlowId && selection.view === "runs" ? (
580
+ {!selectedFlowId ? (
581
+ rootRefusal ? (
582
+ <RefusalNotice refusal={rootRefusal} />
583
+ ) : (
584
+ <div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
585
+ {i18n.t("flows.select")}
586
+ </div>
587
+ )
588
+ ) : /* ⚠️ The refusal comes BEFORE the view switch, and that placement is the whole of #568.
589
+ `?view=graph` and `?view=runs` used to be read first, so a flow the reader may not
590
+ open still rendered its two side views — each with a failure sentence and a "Try
591
+ again" button on an answer that will not change (#430). Whether the reader may see
592
+ this flow at all is answered by `getFlow`, once, for every view of it; asking it
593
+ after the switch means asking it three times and getting two of them wrong. */
594
+ documentRefusal ? (
595
+ <RefusalNotice refusal={documentRefusal} />
596
+ ) : selection.view === "runs" ? (
500
597
  <FlowRuns flowId={selectedFlowId} />
501
- ) : selectedFlowId && selection.view === "graph" ? (
598
+ ) : selection.view === "graph" ? (
502
599
  <GraphPane
503
600
  query={relations}
504
601
  select={(node) =>
@@ -508,10 +605,6 @@ function FlowsEditor() {
508
605
  })
509
606
  }
510
607
  />
511
- ) : !selectedFlowId ? (
512
- <div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
513
- {i18n.t("flows.select")}
514
- </div>
515
608
  ) : document.isError ? (
516
609
  <div
517
610
  role="alert"
@@ -566,102 +659,104 @@ function FlowsEditor() {
566
659
  media query that sets the class, so both turn together and mid-session. What the
567
660
  surfaces are actually coloured with is in `styles.css`, through the library's own
568
661
  theming variables — for the same reason `.react-sigma` is there. */}
569
- <ReactFlow<CanvasNode, CanvasEdge>
570
- colorMode={theme}
571
- nodes={nodes}
572
- edges={edges}
573
- nodeTypes={nodeTypes}
574
- fitView
575
- onNodesChange={(changes: NodeChange<CanvasNode>[]) => {
576
- if (editsNodes(changes)) setDirty(true);
577
- setNodes((current) => applyNodeChanges(changes, current));
578
- }}
579
- onEdgesChange={(changes: EdgeChange<CanvasEdge>[]) => {
580
- if (editsEdges(changes)) setDirty(true);
581
- setEdges((current) => applyEdgeChanges(changes, current));
582
- }}
583
- // ⚠️ The two meanings cannot be mixed on one edge: a line from a context point into
584
- // a flow input would be stored as context and drawn as an attachment, while the
585
- // author meant "and then". Refused at the point where it is still visible.
586
- isValidConnection={(connection) =>
587
- (connection.sourceHandle === ContextHandle) ===
588
- (connection.targetHandle === ContextHandle)
589
- }
590
- onConnect={(connection: Connection) => {
591
- setDirty(true);
592
- setEdges((current) =>
593
- addEdge(
662
+ <InvalidLinks.Provider value={invalidLinks}>
663
+ <ReactFlow<CanvasNode, CanvasEdge>
664
+ colorMode={theme}
665
+ nodes={nodes}
666
+ edges={edges}
667
+ nodeTypes={nodeTypes}
668
+ fitView
669
+ onNodesChange={(changes: NodeChange<CanvasNode>[]) => {
670
+ if (editsNodes(changes)) setDirty(true);
671
+ setNodes((current) => applyNodeChanges(changes, current));
672
+ }}
673
+ onEdgesChange={(changes: EdgeChange<CanvasEdge>[]) => {
674
+ if (editsEdges(changes)) setDirty(true);
675
+ setEdges((current) => applyEdgeChanges(changes, current));
676
+ }}
677
+ // ⚠️ The two meanings cannot be mixed on one edge: a line from a context point into
678
+ // a flow input would be stored as context and drawn as an attachment, while the
679
+ // author meant "and then". Refused at the point where it is still visible.
680
+ isValidConnection={(connection) =>
681
+ (connection.sourceHandle === ContextHandle) ===
682
+ (connection.targetHandle === ContextHandle)
683
+ }
684
+ onConnect={(connection: Connection) => {
685
+ setDirty(true);
686
+ setEdges((current) =>
687
+ addEdge(
688
+ {
689
+ ...connection,
690
+ id: crypto.randomUUID(),
691
+ ...contextEdgeStyle(connection.sourceHandle === ContextHandle),
692
+ },
693
+ current,
694
+ ),
695
+ );
696
+ }}
697
+ onNodeClick={(_event, node) => setSelectedNodeId(node.id)}
698
+ onPaneClick={() => {
699
+ setSelectedNodeId(null);
700
+ setPaletteOpen(false);
701
+ }}
702
+ // ⚠️ `copy`, not `move` (#75). The tree's own drop targets say `move`, and the two
703
+ // have to feel different while the pointer is still travelling: dropping here makes
704
+ // a node that POINTS at the row, it does not take the row out of its folder.
705
+ onDragOver={(event) => {
706
+ if (!event.dataTransfer.types.includes(TreeEntryMediaType)) return;
707
+ event.preventDefault();
708
+ event.dataTransfer.dropEffect = "copy";
709
+ }}
710
+ onDrop={(event) => {
711
+ const payload = event.dataTransfer.getData(TreeEntryMediaType);
712
+ if (!payload) return;
713
+ event.preventDefault();
714
+ const dropped = droppedEntry(payload);
715
+ if (!dropped) return;
716
+ const kind = flowKindOfEntry[dropped.kind];
717
+ if (!kind) return;
718
+ // Where the pointer let go, in the graph's own coordinates — not the screen's, or
719
+ // the node would land somewhere else at every zoom level.
720
+ const position = flow.screenToFlowPosition({
721
+ x: event.clientX,
722
+ y: event.clientY,
723
+ });
724
+ setDirty(true);
725
+ setNodes((current) => [
726
+ ...current,
594
727
  {
595
- ...connection,
596
- id: crypto.randomUUID(),
597
- ...contextEdgeStyle(connection.sourceHandle === ContextHandle),
598
- },
599
- current,
600
- ),
601
- );
602
- }}
603
- onNodeClick={(_event, node) => setSelectedNodeId(node.id)}
604
- onPaneClick={() => {
605
- setSelectedNodeId(null);
606
- setPaletteOpen(false);
607
- }}
608
- // ⚠️ `copy`, not `move` (#75). The tree's own drop targets say `move`, and the two
609
- // have to feel different while the pointer is still travelling: dropping here makes
610
- // a node that POINTS at the row, it does not take the row out of its folder.
611
- onDragOver={(event) => {
612
- if (!event.dataTransfer.types.includes(TreeEntryMediaType)) return;
613
- event.preventDefault();
614
- event.dataTransfer.dropEffect = "copy";
615
- }}
616
- onDrop={(event) => {
617
- const payload = event.dataTransfer.getData(TreeEntryMediaType);
618
- if (!payload) return;
619
- event.preventDefault();
620
- const dropped = droppedEntry(payload);
621
- if (!dropped) return;
622
- const kind = flowKindOfEntry[dropped.kind];
623
- if (!kind) return;
624
- // Where the pointer let go, in the graph's own coordinates — not the screen's, or
625
- // the node would land somewhere else at every zoom level.
626
- const position = flow.screenToFlowPosition({
627
- x: event.clientX,
628
- y: event.clientY,
629
- });
630
- setDirty(true);
631
- setNodes((current) => [
632
- ...current,
633
- {
634
- id: `${kind}-${crypto.randomUUID()}`,
635
- type: "intel" as const,
636
- position,
637
- data: {
638
- node: {
639
- id: `${kind}-${crypto.randomUUID()}`,
640
- kind,
641
- label: dropped.title,
642
- position,
643
- configuration:
644
- kind === "subflow"
645
- ? { flowId: dropped.id, version: { mode: "latest" as const } }
646
- : { resourceId: dropped.id },
647
- } as FlowNode,
728
+ id: `${kind}-${crypto.randomUUID()}`,
729
+ type: "intel" as const,
730
+ position,
731
+ data: {
732
+ node: {
733
+ id: `${kind}-${crypto.randomUUID()}`,
734
+ kind,
735
+ label: dropped.title,
736
+ position,
737
+ configuration:
738
+ kind === "subflow"
739
+ ? { flowId: dropped.id, version: { mode: "latest" as const } }
740
+ : { resourceId: dropped.id },
741
+ } as FlowNode,
742
+ },
648
743
  },
649
- },
650
- ]);
651
- }}
652
- >
653
- <Background />
654
- {/* The overview map has no room for a label, so the start is marked there the only
744
+ ]);
745
+ }}
746
+ >
747
+ <Background />
748
+ {/* The overview map has no room for a label, so the start is marked there the only
655
749
  way that is left: its own colour, from the tokens rather than a fixed value. */}
656
- <MiniMap
657
- pannable
658
- zoomable
659
- nodeClassName={(node) =>
660
- (node as CanvasNode).data.node.kind === "trigger" ? "intel-minimap-start" : ""
661
- }
662
- />
663
- <Controls />
664
- </ReactFlow>
750
+ <MiniMap
751
+ pannable
752
+ zoomable
753
+ nodeClassName={(node) =>
754
+ (node as CanvasNode).data.node.kind === "trigger" ? "intel-minimap-start" : ""
755
+ }
756
+ />
757
+ <Controls />
758
+ </ReactFlow>
759
+ </InvalidLinks.Provider>
665
760
  </section>
666
761
  <aside className="flex w-80 shrink-0 flex-col border-l bg-card">
667
762
  <TitleRowScrollArea className="min-h-0 flex-1 overflow-y-auto">
@@ -696,7 +791,7 @@ function FlowsEditor() {
696
791
  role="alert"
697
792
  className="fixed bottom-5 left-[18rem] z-20 rounded-lg border border-destructive/30 bg-card px-4 py-3 text-sm text-destructive shadow-xl"
698
793
  >
699
- {i18n.t("flows.operationFailed")}
794
+ {saveRefusalText(save.error, i18n)}
700
795
  </p>
701
796
  )}
702
797
  {publishing && selectedFlowId && document.data?.flow.currentVersionId ? (
@@ -712,7 +807,9 @@ function FlowsEditor() {
712
807
  // graph that just changed.
713
808
  await Promise.all([
714
809
  queryClient.invalidateQueries({ queryKey: ["flow", selectedFlowId] }),
715
- queryClient.invalidateQueries({ queryKey: ["flow-publish-preview", selectedFlowId] }),
810
+ queryClient.invalidateQueries({
811
+ queryKey: ["flow-publish-preview", selectedFlowId],
812
+ }),
716
813
  queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
717
814
  ]);
718
815
  }}
@@ -1249,11 +1346,13 @@ function FlowNeeds({ flowId }: { flowId: string }) {
1249
1346
  queryKey: ["flow-requirements", flowId],
1250
1347
  queryFn: () => data.getFlowRequirements(flowId),
1251
1348
  });
1252
- // ⚠️ A flow that touches nothing and a flow whose every reference is hidden are two different
1253
- // answers, so the counted ones keep the panel from claiming the first when it means the second.
1349
+ // ⚠️ A flow that touches nothing and a flow whose every reference is hidden or dead are three
1350
+ // different answers, so neither the counted nor the broken ones may fall out of this condition
1351
+ // otherwise the panel claims the first when it means one of the others.
1254
1352
  const empty =
1255
1353
  needs.data !== undefined &&
1256
1354
  needs.data.nodes.length === 0 &&
1355
+ needs.data.invalidNodes.length === 0 &&
1257
1356
  needs.data.hiddenNodes === 0 &&
1258
1357
  needs.data.servers.length === 0;
1259
1358
  return (
@@ -1270,7 +1369,9 @@ function FlowNeeds({ flowId }: { flowId: string }) {
1270
1369
  {empty && <p className="mt-2 text-sm text-muted-foreground">{i18n.t("flows.needsEmpty")}</p>}
1271
1370
  {needs.data && !empty && (
1272
1371
  <>
1273
- {(needs.data.nodes.length > 0 || needs.data.hiddenNodes > 0) && (
1372
+ {(needs.data.nodes.length > 0 ||
1373
+ needs.data.invalidNodes.length > 0 ||
1374
+ needs.data.hiddenNodes > 0) && (
1274
1375
  <>
1275
1376
  <h3 className="mt-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
1276
1377
  {i18n.t("flows.needsNodes")}
@@ -1282,6 +1383,16 @@ function FlowNeeds({ flowId }: { flowId: string }) {
1282
1383
  <span className="min-w-0 truncate">{reference.title}</span>
1283
1384
  </li>
1284
1385
  ))}
1386
+ {/* ⚠️ A dead reference and an unreachable one are two entries, not one (#509).
1387
+ They used to share the counted line, which said "you cannot see it" about a
1388
+ document that is gone — a sentence about a permission, in front of something no
1389
+ permission can fix. This one says what to do instead, because there is exactly
1390
+ one thing to do: replace the step or take it out. */}
1391
+ {needs.data.invalidNodes.length > 0 && (
1392
+ <li className="text-destructive">
1393
+ {i18n.t("flows.needsInvalid", { count: needs.data.invalidNodes.length })}
1394
+ </li>
1395
+ )}
1285
1396
  {/* ⚠️ Counted, never named. A title is exactly what someone without access to the
1286
1397
  document may not learn from a list about it. */}
1287
1398
  {needs.data.hiddenNodes > 0 && (
@@ -1,6 +1,8 @@
1
1
  import type { RelationGraph } from "@anchrd/intel-contract/flow";
2
2
  import type { UseQueryResult } from "@tanstack/react-query";
3
3
  import { lazy, Suspense } from "react";
4
+ import { RefusalNotice } from "@/data/request-refusal/refusal-notice.tsx";
5
+ import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
4
6
  import { useI18n } from "@/i18n/i18n-context.tsx";
5
7
 
6
8
  const RelationGraphView = lazy(async () => ({
@@ -20,6 +22,13 @@ export function GraphPane({
20
22
  const i18n = useI18n();
21
23
  const loading = <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
22
24
  if (query.isPending) return loading;
25
+ // ⚠️ Read off this pane's OWN error, not handed down from the screen around it (#568). The screen
26
+ // above answers whether the flow or folder may be opened at all; this answers whether its
27
+ // relations may be read, and the graph endpoint can refuse on its own — an authorized folder
28
+ // whose graph is not shared. Two questions, so two places, and the one that is refused says so
29
+ // where it happened.
30
+ const refusal = refusalOf(query.error);
31
+ if (refusal) return <RefusalNotice refusal={refusal} />;
23
32
  if (query.isError || !query.data) {
24
33
  return (
25
34
  <div role="alert" className="grid flex-1 place-items-center p-8 text-center text-sm">
package/src/i18n/de.json CHANGED
@@ -74,6 +74,7 @@
74
74
  "resource.move": "Verschieben",
75
75
  "resource.renameTitle": "{title} umbenennen",
76
76
  "resource.archive": "Archivieren",
77
+ "resource.columns": "Spalten",
77
78
  "resource.export": "Export",
78
79
  "resource.import": "Import",
79
80
  "resource.validate": "Prüfen",
@@ -117,6 +118,23 @@
117
118
  "node.table.summary": "{rows} Zeilen, {columns} Spalten",
118
119
  "node.table.empty": "Noch keine Zeilen. Zeilen werden von Flows angehängt.",
119
120
  "node.table.undefined": "Diese Tabelle hat noch keine Spalten.",
121
+ "node.table.columnsTitle": "Spalten von {title}",
122
+ "node.table.columnName": "Name der Spalte „{column}“",
123
+ "node.table.columnNewName": "Name der neuen Spalte",
124
+ "node.table.columnAdd": "Spalte hinzufügen",
125
+ "node.table.columnDrop": "Die Spalte „{column}“ entfernen",
126
+ "node.table.columnDropNew": "Die neue Spalte entfernen",
127
+ "node.table.columnsDistinct": "Zwei Spalten können nicht denselben Namen tragen.",
128
+ "node.table.columnsNamed": "Jede Spalte braucht einen Namen.",
129
+ "node.table.columnsAtLeastOne": "Eine Tabelle braucht mindestens eine Spalte.",
130
+ "node.table.columnsTooLong": "Ein Spaltenname darf höchstens 120 Zeichen lang sein.",
131
+ "node.table.columnsTooMany": "Eine Tabelle kann höchstens 64 Spalten haben.",
132
+ "node.table.columnsDropWarning.one": "Diese Spalte wird mitsamt allen Zellen darin entfernt: {columns}",
133
+ "node.table.columnsDropWarning.many": "Diese Spalten werden mitsamt allen Zellen darin entfernt: {columns}",
134
+ "node.table.columnsDropRows.none": "Keine Zeile trägt dort Inhalt, es geht also nichts mit ihnen verloren.",
135
+ "node.table.columnsDropRows.one": "Eine Zeile trägt dort Inhalt. Er bleibt im Versionsverlauf lesbar und sonst nirgends.",
136
+ "node.table.columnsDropRows.many": "{count} Zeilen tragen dort Inhalt. Er bleibt im Versionsverlauf lesbar und sonst nirgends.",
137
+ "node.table.columnsDropConfirm": "Entfernen und speichern",
120
138
  "node.select": "Wähle ein Dokument oder einen Ordner, um damit zu arbeiten.",
121
139
  "node.folderEmpty": "In diesem Ordner ist noch nichts. Lege etwas über das Plus in der Seitenleiste an.",
122
140
  "node.folderFailed": "Dieser Ordner konnte nicht gelesen werden.",
@@ -241,6 +259,7 @@
241
259
  "flows.node.condition": "Bedingung",
242
260
  "flows.node.subflow": "Flow",
243
261
  "flows.node.output": "Ende",
262
+ "flows.nodeInvalid": "Gibt es nicht mehr — ersetze oder entferne diesen Schritt",
244
263
  "flows.instruction": "Anweisung für die KI oder die Person",
245
264
  "flows.instructionHint": "Bedingungen, Prüfungen und Verzweigungen lassen sich hier in Worten beschreiben. Für jede kleine Entscheidung braucht es keinen eigenen Schritt.",
246
265
  "flows.linkEmpty": "Noch nichts gewählt",
@@ -275,9 +294,13 @@
275
294
  "flows.tool": "MCP-Server",
276
295
  "flows.selectTool": "Server auswählen",
277
296
  "flows.operationFailed": "Der Flow-Vorgang ist fehlgeschlagen. Lade die neueste Fassung und versuche es erneut.",
297
+ "flows.saveInvalid": "Nicht gespeichert: {detail}",
298
+ "flows.saveConflict": "Nicht gespeichert: Jemand anderes hat diesen Flow zuerst geändert. Lade die neueste Fassung und versuche es erneut.",
299
+ "flows.saveFailed": "Der Flow wurde nicht gespeichert. Versuche es erneut.",
278
300
  "flows.needs": "Was dieser Flow braucht",
279
301
  "flows.needsNodes": "Dokumente",
280
302
  "flows.needsTools": "Werkzeuge",
303
+ "flows.needsInvalid": "{count} weitere, die es nicht mehr gibt — ersetze oder entferne diese Schritte",
281
304
  "flows.needsHidden": "{count} weitere, die du nicht sehen kannst",
282
305
  "flows.needsEmpty": "Dieser Flow liest keine Dokumente und ruft keine Werkzeuge auf.",
283
306
  "flows.needsFailed": "Was dieser Flow braucht, konnte nicht geladen werden.",
@@ -344,6 +367,7 @@
344
367
  "common.loading": "Wird geladen …",
345
368
  "common.retry": "Erneut versuchen",
346
369
  "common.close": "Schließen",
370
+ "common.cancel": "Abbrechen",
347
371
  "common.unavailable": "Intel ist derzeit nicht verfügbar.",
348
372
  "common.noAccess": "Das ist für dich nicht verfügbar. Es existiert nicht, oder es ist nicht mehr für dich freigegeben.",
349
373
  "common.noPermission": "Dir fehlt die Berechtigung, das zu sehen.",
package/src/i18n/en.json CHANGED
@@ -74,6 +74,7 @@
74
74
  "resource.move": "Move",
75
75
  "resource.renameTitle": "Rename {title}",
76
76
  "resource.archive": "Archive",
77
+ "resource.columns": "Columns",
77
78
  "resource.export": "Export",
78
79
  "resource.import": "Import",
79
80
  "resource.validate": "Check",
@@ -117,6 +118,23 @@
117
118
  "node.table.summary": "{rows} rows, {columns} columns",
118
119
  "node.table.empty": "No rows yet. Rows are appended by flows.",
119
120
  "node.table.undefined": "This table has no columns yet.",
121
+ "node.table.columnsTitle": "Columns of {title}",
122
+ "node.table.columnName": "Name of the column “{column}”",
123
+ "node.table.columnNewName": "Name of the new column",
124
+ "node.table.columnAdd": "Add column",
125
+ "node.table.columnDrop": "Remove the column “{column}”",
126
+ "node.table.columnDropNew": "Remove the new column",
127
+ "node.table.columnsDistinct": "Two columns cannot carry the same name.",
128
+ "node.table.columnsNamed": "Every column needs a name.",
129
+ "node.table.columnsAtLeastOne": "A table needs at least one column.",
130
+ "node.table.columnsTooLong": "A column name can be at most 120 characters long.",
131
+ "node.table.columnsTooMany": "A table can have at most 64 columns.",
132
+ "node.table.columnsDropWarning.one": "This column is removed with every cell in it: {columns}",
133
+ "node.table.columnsDropWarning.many": "These columns are removed with every cell in them: {columns}",
134
+ "node.table.columnsDropRows.none": "No row carries content there, so nothing is lost with them.",
135
+ "node.table.columnsDropRows.one": "One row carries content there. It stays readable in the version history and nowhere else.",
136
+ "node.table.columnsDropRows.many": "{count} rows carry content there. It stays readable in the version history and nowhere else.",
137
+ "node.table.columnsDropConfirm": "Remove and save",
120
138
  "node.select": "Select a document or folder to work with it.",
121
139
  "node.folderEmpty": "Nothing in this folder yet. Create something with the plus in the sidebar.",
122
140
  "node.folderFailed": "This folder could not be read.",
@@ -241,6 +259,7 @@
241
259
  "flows.node.condition": "Condition",
242
260
  "flows.node.subflow": "Flow",
243
261
  "flows.node.output": "End",
262
+ "flows.nodeInvalid": "Gone — replace or remove this step",
244
263
  "flows.instruction": "Instruction for the AI or person",
245
264
  "flows.instructionHint": "Conditions, checks and branches can be described here in words. You do not need a separate node for every small decision.",
246
265
  "flows.linkEmpty": "Nothing chosen yet",
@@ -275,9 +294,13 @@
275
294
  "flows.tool": "MCP server",
276
295
  "flows.selectTool": "Select a server",
277
296
  "flows.operationFailed": "The flow operation failed. Reload the latest version and try again.",
297
+ "flows.saveInvalid": "Not saved: {detail}",
298
+ "flows.saveConflict": "Not saved: somebody else changed this flow first. Reload the latest version and try again.",
299
+ "flows.saveFailed": "The flow was not saved. Try again.",
278
300
  "flows.needs": "What this flow needs",
279
301
  "flows.needsNodes": "Documents",
280
302
  "flows.needsTools": "Tools",
303
+ "flows.needsInvalid": "{count} more that no longer exist — replace or remove those steps",
281
304
  "flows.needsHidden": "{count} more you cannot see",
282
305
  "flows.needsEmpty": "This flow reads no documents and calls no tools.",
283
306
  "flows.needsFailed": "What this flow needs could not be loaded.",
@@ -344,6 +367,7 @@
344
367
  "common.loading": "Loading…",
345
368
  "common.retry": "Try again",
346
369
  "common.close": "Close",
370
+ "common.cancel": "Cancel",
347
371
  "common.unavailable": "Intel is currently unavailable.",
348
372
  "common.noAccess": "This is not available to you. It may not exist, or it may no longer be shared with you.",
349
373
  "common.noPermission": "You do not have permission to see this.",