diff --git a/Cargo.lock b/Cargo.lock index f1313a57..4adf00f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11223,6 +11223,7 @@ version = "0.1.0" dependencies = [ "async-trait", "base64 0.22.1", + "chrono", "log 0.4.29", "md5 0.8.0", "rusqlite", diff --git a/apps/yaak-client/components/ImportDataDialog.tsx b/apps/yaak-client/components/ImportDataDialog.tsx index 6db9b222..95161301 100644 --- a/apps/yaak-client/components/ImportDataDialog.tsx +++ b/apps/yaak-client/components/ImportDataDialog.tsx @@ -2,20 +2,25 @@ import { type Folder, type ImportDestination, type ImportPlan, - modelTypeLabel, + type ImportPlanItem, + type ImportSource, type Workspace, } from "@yaakapp-internal/models"; -import { HStack, Icon, VStack } from "@yaakapp-internal/ui"; +import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; import { platform } from "@yaakapp-internal/platform"; import classNames from "classnames"; -import { useEffect, useRef, useState } from "react"; -import { useLocalStorage } from "react-use"; -import { pluralizeCount } from "../lib/pluralize"; +import { formatDistanceToNowStrict } from "date-fns"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { pluralize } from "../lib/pluralize"; import { CommercialUseBanner } from "./CommercialUseBanner"; import { Button } from "./core/Button"; import { Checkbox } from "./core/Checkbox"; +import type { CheckboxTreeNode } from "./core/CheckboxTree"; +import { CheckboxTree } from "./core/CheckboxTree"; +import { IconTooltip } from "./core/IconTooltip"; import { PlainInput } from "./core/PlainInput"; import { Select } from "./core/Select"; +import { SegmentedControl } from "./core/SegmentedControl"; interface Props { currentWorkspace: Workspace | null; @@ -23,15 +28,13 @@ interface Props { selectedFolder: Folder | null; planFile: (filePath: string, destination: ImportDestination) => Promise; planUrl: (url: string, destination: ImportDestination) => Promise; + listSources: (workspaceId: string) => Promise; + findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise; commit: (plan: ImportPlan) => Promise; cancel: () => void; onError: (err: unknown) => void; } -/** Sentinel for the "create a new workspace" option. Workspace IDs are prefixed `wk_`, so this - * can never collide with a real one. */ -const NEW_WORKSPACE = "new_workspace"; - /** * An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a * bare host like `example.com/openapi.json` still works (the backend defaults it to https). @@ -51,22 +54,74 @@ function fileName(path: string): string { return path.split(/[/\\]/).at(-1) || path; } -export function ImportDataDialog({ +/** + * Loads the current workspace's linked sources before rendering the dialog, so the inner + * component can construct its initial state (prefilled path, destination) in one pass instead of + * patching it in with effects after the first paint. + */ +export function ImportDataDialog(props: Props) { + const [initialSources, setInitialSources] = useState(null); + const { currentWorkspace, listSources } = props; + + useEffect(() => { + let cancelled = false; + const load = currentWorkspace == null ? Promise.resolve([]) : listSources(currentWorkspace.id); + load + .then((sources) => { + if (!cancelled) setInitialSources(sources); + }) + .catch(() => { + if (!cancelled) setInitialSources([]); + }); + return () => { + cancelled = true; + }; + }, [currentWorkspace, listSources]); + + if (initialSources == null) return null; + return ; +} + +function latestSource(sources: ImportSource[]): ImportSource | null { + return sources.reduce( + (latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest), + null, + ); +} + +function LoadedImportDataDialog({ currentWorkspace, workspaces, selectedFolder, planFile, planUrl, + listSources, + findSourcesForOrigin, commit, cancel, onError, -}: Props) { + initialSources, +}: Props & { initialSources: ImportSource[] }) { + // A workspace with a linked source is probably being re-imported, so start from that source + const prefill = latestSource(initialSources); + const [isLoading, setIsLoading] = useState(false); const [plan, setPlan] = useState(null); - const [destinationId, setDestinationId] = useState(NEW_WORKSPACE); + const [items, setItems] = useState([]); + // null means no explicit choice yet, so the default below applies + const [destinationChoice, setDestinationChoice] = useState<"new" | "current" | "other" | null>( + null, + ); + const [otherWorkspaceId, setOtherWorkspaceId] = useState(null); const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null); + const [linkedSources, setLinkedSources] = useState( + prefill != null ? initialSources : [], + ); + const [originSources, setOriginSources] = useState( + prefill != null ? [prefill] : [], + ); // A file path or a URL. Both inputs write here, so there is only ever one thing to import - const [source, setSource] = useLocalStorage("importPathOrUrl", null); + const [source, setSource] = useState(prefill?.origin ?? null); const [forceUpdateKey, setForceUpdateKey] = useState(0); const [isHovering, setIsHovering] = useState(false); const ref = useRef(null); @@ -97,6 +152,63 @@ export function ImportDataDialog({ }); }, [isHovering, setSource]); + useEffect(() => { + if (trimmedSource === "") { + setOriginSources([]); + return; + } + let cancelled = false; + const timeout = setTimeout(() => { + findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource }) + .then((sources) => { + if (!cancelled) setOriginSources(sources); + }) + .catch(() => setOriginSources([])); + }, 300); + return () => { + cancelled = true; + clearTimeout(timeout); + }; + }, [trimmedSource, filePath, findSourcesForOrigin]); + + // The one workspace this file is linked to, if there is exactly one. + const linkedWorkspace = useMemo(() => { + const ids = [...new Set(originSources.map((s) => s.workspaceId))]; + if (ids.length !== 1) return null; + return workspaces.find((w) => w.id === ids[0]) ?? null; + }, [originSources, workspaces]); + + // A file linked to the current workspace defaults back into it, so re-importing doesn't + // accidentally create a duplicate workspace. A file linked elsewhere only gets a suggestion — + // silently targeting a workspace that is neither new nor current is too surprising. An + // explicit choice always wins. + const destinationKind = + destinationChoice ?? + (linkedWorkspace != null && linkedWorkspace.id === currentWorkspace?.id ? "current" : "new"); + + const destinationWorkspaceId = + destinationKind === "current" + ? (currentWorkspace?.id ?? null) + : destinationKind === "other" + ? otherWorkspaceId + : null; + + useEffect(() => { + if (destinationWorkspaceId == null) { + setLinkedSources([]); + return; + } + let cancelled = false; + listSources(destinationWorkspaceId) + .then((sources) => { + if (!cancelled) setLinkedSources(sources); + }) + .catch(() => setLinkedSources([])); + return () => { + cancelled = true; + }; + }, [destinationWorkspaceId, listSources]); + const handleSelectFile = async () => { const selected = await platform.dialog.open({ title: "Select File", multiple: false }); if (selected == null) return; @@ -105,16 +217,15 @@ export function ImportDataDialog({ // The selected folder belongs to the workspace being viewed, so it is only offerable when that // is also the destination. - const canTargetSelectedFolder = - selectedFolder != null && destinationId === currentWorkspace?.id; + const canTargetSelectedFolder = selectedFolder != null && destinationKind === "current"; const destination = (): ImportDestination => { - if (destinationId === NEW_WORKSPACE) { + if (destinationWorkspaceId == null) { return { type: "new_workspace" }; } return { type: "existing_workspace", - workspaceId: destinationId, + workspaceId: destinationWorkspaceId, folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined, }; }; @@ -127,6 +238,7 @@ export function ImportDataDialog({ ? await planFile(filePath, destination()) : await planUrl(trimmedSource, destination()); setPlan(nextPlan); + setItems(nextPlan.items); } catch (err) { onError(err); } finally { @@ -138,7 +250,7 @@ export function ImportDataDialog({ if (plan == null) return; setIsLoading(true); try { - await commit(plan); + await commit({ ...plan, items }); } catch (err) { onError(err); } finally { @@ -146,15 +258,62 @@ export function ImportDataDialog({ } }; + const itemTree = useMemo(() => buildItemTree(items), [items]); + + // A folder row's checkbox aggregates its subtree the way the git commit tree does: creates and + // updates toggle together, while removals only ever cascade beneath a removed folder. + const toggleNode = (node: CheckboxTreeNode, checked: boolean) => { + const targets = new Set( + collectItems(node) + .filter((i) => togglesWith(node.data, i)) + .map((i) => i.modelId), + ); + setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i))); + }; + + const resolveConflict = (modelId: string, resolution: "keep_mine" | "take_source") => { + setItems((prev) => + prev.map((item) => (item.modelId === modelId ? { ...item, resolution } : item)), + ); + }; + + // A row the user can't meaningfully toggle on its own: a planned resource inside a deselected + // new folder can't exist, and a removed folder takes its contents with it. + const disabledIds = useMemo(() => { + const disabled = new Set(); + const byId = new Map(items.map((i) => [i.modelId, i])); + for (const item of items) { + const seen = new Set(); + let parentId = item.parentId; + while (parentId != null && !seen.has(parentId)) { + seen.add(parentId); + const parent = byId.get(parentId); + if (parent == null || parent.model !== "folder") break; + if (parent.action === "create" && !parent.selected && item.action !== "delete") { + disabled.add(item.modelId); + } + if (parent.action === "delete" && parent.selected && item.action === "delete") { + disabled.add(item.modelId); + } + parentId = parent.parentId; + } + } + return disabled; + }, [items]); + if (plan != null) { - const counts = [ - [plan.resources.workspaces[0], plan.resources.workspaces.length], - [plan.resources.environments[0], plan.resources.environments.length], - [plan.resources.folders[0], plan.resources.folders.length], - [plan.resources.httpRequests[0], plan.resources.httpRequests.length], - [plan.resources.grpcRequests[0], plan.resources.grpcRequests.length], - [plan.resources.websocketRequests[0], plan.resources.websocketRequests.length], - ] as const; + const unchanged = items.filter((i) => i.action === "unchanged"); + const footerNote = + unchanged.length > 0 + ? `${unchanged.length} ${pluralize("resource", unchanged.length)} unchanged` + : ""; + const changeCount = items.filter((item) => { + if (disabledIds.has(item.modelId)) return false; + if (item.action === "conflict") return item.resolution === "take_source"; + if (item.action === "unchanged") return false; + return item.selected; + }).length; + const destinationLabel = (() => { if (plan.destination.type === "new_workspace") return "New workspace"; const { workspaceId, folderId } = plan.destination; @@ -164,6 +323,28 @@ export function ImportDataDialog({ : name; })(); + // The destination workspace roots the tree. It is not a plan item — commit always applies + // it — so its checkbox only aggregates the subtree. + const workspaceRoot: CheckboxTreeNode = (() => { + const planned = plan.resources.workspaces[0]; + const planDestination = plan.destination; + const existing = + planDestination.type === "existing_workspace" + ? workspaces.find((w) => w.id === planDestination.workspaceId) + : null; + return { + key: existing?.id ?? planned?.id ?? "workspace", + data: { + action: plan.destination.type === "new_workspace" ? "create" : "unchanged", + model: "workspace", + modelId: existing?.id ?? planned?.id ?? "workspace", + name: existing?.name ?? planned?.name ?? "New workspace", + selected: true, + }, + children: itemTree, + }; + })(); + return (
@@ -171,15 +352,15 @@ export function ImportDataDialog({
-
-
Resources
-
    - {counts.map(([model, count]) => - model == null ? null : ( -
  • {pluralizeCount(modelTypeLabel(model), count)}
  • - ), - )} -
+
+ disabledIds.has(n.key)} + isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"} + renderRow={(n) => } + />
{plan.warnings.length > 0 && ( @@ -202,18 +383,39 @@ export function ImportDataDialog({
)} - -
); } + const lastImported = + originSources.find((s) => s.workspaceId === destinationWorkspaceId) ?? + linkedSources.reduce( + (latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest), + null, + ); + return ( @@ -224,7 +426,9 @@ export function ImportDataDialog({ className={classNames( "w-full rounded-lg border border-dashed px-4 py-6", "flex flex-col items-center gap-1 text-center", - isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text", + isHovering + ? "border-notice bg-surface-highlight" + : "border-border hover:border-text-subtle", )} > @@ -256,24 +460,60 @@ export function ImportDataDialog({ setOtherWorkspaceId(id === "" ? null : id)} + filterable + options={[ + { value: "", label: "Select a workspace" }, + ...workspaces + .filter((w) => w.id !== currentWorkspace?.id) + .map((w) => ({ value: w.id, label: w.name })), + ]} + /> + )} + {lastImported != null && destinationWorkspaceId != null ? ( +
+ Last imported from {lastImported.originLabel} ·{" "} + {formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })} +
+ ) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? ( +
+ This file was last imported into{" "} + +
+ ) : null} {canTargetSelectedFolder && ( + ) : ( + + )} + {checked === "hidden" ? ( + + ) : ( + props.onCheck(node, checked)} + /> + )} + {selectable ? ( + + ) : ( + rowContent + )} + + + {!collapsed && + node.children.map((child) => ( + + ))} + + ); +} + +function hasRelevantNode( + node: CheckboxTreeNode, + isRelevant: (node: CheckboxTreeNode) => boolean, +): boolean { + return isRelevant(node) || node.children.some((c) => hasRelevantNode(c, isRelevant)); +} diff --git a/apps/yaak-client/components/git/GitCommitDialog.tsx b/apps/yaak-client/components/git/GitCommitDialog.tsx index 75752861..08aa163c 100644 --- a/apps/yaak-client/components/git/GitCommitDialog.tsx +++ b/apps/yaak-client/components/git/GitCommitDialog.tsx @@ -21,6 +21,8 @@ import { CommercialUseBanner } from "../CommercialUseBanner"; import { Button } from "../core/Button"; import type { CheckboxProps } from "../core/Checkbox"; import { Checkbox } from "../core/Checkbox"; +import type { CheckboxTreeNode } from "../core/CheckboxTree"; +import { CheckboxTree } from "../core/CheckboxTree"; import { DiffViewer } from "../core/Editor/DiffViewer"; import { Input } from "../core/Input"; import { Separator } from "../core/Separator"; @@ -43,10 +45,7 @@ interface CommitTreeNode { export function GitCommitDialog({ syncDir, onDone, workspace }: Props) { const callbacks = useGitCallbacks(syncDir); - const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit( - syncDir, - callbacks, - ); + const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(syncDir, callbacks); const [isPushing, setIsPushing] = useState(false); const [commitError, setCommitError] = useState(null); const [message, setMessage] = useState(""); @@ -143,6 +142,15 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) { return next(workspace, []); }, [workspace, internalEntries]); + const treeNode: CheckboxTreeNode | null = useMemo(() => { + const toTreeNode = (n: CommitTreeNode): CheckboxTreeNode => ({ + key: n.status.relaPath + n.status.status + n.status.staged, + data: n, + children: n.children.map(toTreeNode), + }); + return tree == null ? null : toTreeNode(tree); + }, [tree]); + const checkNode = useCallback( (treeNode: CommitTreeNode) => { const checked = nodeCheckedStatus(treeNode); @@ -190,7 +198,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) { [restore], ); - if (tree == null) { + if (tree == null || treeNode == null) { return null; } @@ -221,12 +229,18 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) { style={innerStyle} className="h-full overflow-y-auto pb-3 pr-0.5 transform-cpu" > - nodeCheckedStatus(n.data)} + onCheck={(n) => checkNode(n.data)} + checkboxTitle={(n) => + nodeCheckedStatus(n.data) ? "Unstage change" : "Stage change" + } + isRelevant={(n) => n.data.status.status !== "current"} + canSelectRow={(n) => n.data.status.status !== "current"} + onSelectRow={(n) => handleSelectChild(n.data.status)} + isRowSelected={(n) => selectedEntry?.relaPath === n.data.status.relaPath} + renderRow={(n) => } /> {externalEntries.find((e) => e.status !== "current") && ( <> @@ -244,10 +258,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) { )} secondSlot={({ style: innerStyle }) => ( -
+
void; - onSelect: (entry: GitStatusEntry) => void; - selectedPath: string | null; -}) { - if (node === null) return null; - if (!isNodeRelevant(node)) return null; - - const checked = nodeCheckedStatus(node); - const isSelected = selectedPath === node.status.relaPath; - +function CommitTreeRow({ node }: { node: CommitTreeNode }) { return ( -
0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative", - )} - > -
- {isSelected && ( -
- )} - onCheck(node, checked)} + <> + {node.model.model !== "http_request" && + node.model.model !== "grpc_request" && + node.model.model !== "websocket_request" ? ( + - -
- - {node.children.map((childNode) => { - return ( - - ); - })} -
+ {node.status.status} + + )} + ); } @@ -495,15 +449,6 @@ function setCheckedAndChildren( if (toUnstage.length > 0) unstage({ relaPaths: toUnstage }); } -function isNodeRelevant(node: CommitTreeNode): boolean { - if (node.status.status !== "current") { - return true; - } - - // Recursively check children - return node.children.some((c) => isNodeRelevant(c)); -} - function DiffPanel({ entry, onDiscardChanges, @@ -526,13 +471,11 @@ function DiffPanel({ size="2xs" variant="border" onClick={() => onDiscardChanges(entry)} - >Discard Changes + > + Discard Changes +
- +
); } diff --git a/apps/yaak-client/lib/importData.tsx b/apps/yaak-client/lib/importData.tsx index 215401ba..b4296ee1 100644 --- a/apps/yaak-client/lib/importData.tsx +++ b/apps/yaak-client/lib/importData.tsx @@ -2,6 +2,7 @@ import { type BatchUpsertResult, type ImportDestination, type ImportPlan, + type ImportSource, workspacesAtom, } from "@yaakapp-internal/models"; import { FormattedError, VStack } from "@yaakapp-internal/ui"; @@ -17,6 +18,17 @@ import { pluralizeCount } from "./pluralize"; import { router } from "./router"; import { rpc } from "./rpc"; +// Stable identities so the dialog's effects don't re-run (and cancel in-flight +// fetches) every time the dialog container re-renders. +const planFile = (filePath: string, destination: ImportDestination) => + rpc("cmd_import_data", { filePath, destination }); +const planUrl = (url: string, destination: ImportDestination) => + rpc("cmd_import_url", { url, destination }); +const listSources = (workspaceId: string) => + rpc("cmd_list_import_sources", { workspaceId }); +const findSourcesForOrigin = (args: { filePath?: string; url?: string }) => + rpc("cmd_import_sources_for_origin", args); + export const importData = createFastMutation({ mutationKey: ["import_data"], onError: (err: string) => { @@ -35,7 +47,7 @@ export const importData = createFastMutation({ showDialog({ id: "import", title: "Import Data", - size: "sm", + size: "lg", disableClose: true, render: ({ hide }) => { const cancel = () => { @@ -57,12 +69,10 @@ export const importData = createFastMutation({ currentWorkspace={currentWorkspace} workspaces={workspaces} selectedFolder={selectedFolder} - planFile={(filePath: string, destination: ImportDestination) => - rpc("cmd_import_data", { filePath, destination }) - } - planUrl={(url: string, destination: ImportDestination) => - rpc("cmd_import_url", { url, destination }) - } + planFile={planFile} + planUrl={planUrl} + listSources={listSources} + findSourcesForOrigin={findSourcesForOrigin} commit={commit} cancel={cancel} onError={fail} diff --git a/crates-cli/yaak-cli/src/commands/import_export.rs b/crates-cli/yaak-cli/src/commands/import_export.rs index 61573e18..cf6bc2f9 100644 --- a/crates-cli/yaak-cli/src/commands/import_export.rs +++ b/crates-cli/yaak-cli/src/commands/import_export.rs @@ -5,15 +5,20 @@ use std::fs; use std::io::ErrorKind; use yaak::export::{self, ExportDataParams}; use yaak::import; -use yaak_models::util::{BatchUpsertResult, ImportDestination}; +use yaak_models::util::{ + BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem, +}; use yaak_plugins::events::{ImportResources, PluginContext}; type CommandResult = std::result::Result; pub async fn run_import(ctx: &CliContext, args: ImportArgs) -> i32 { match import(ctx, args).await { - Ok(result) => { + Ok((result, items)) => { println!("Imported {}", format_counts(&result)); + if let Some(skipped) = format_skipped(&items) { + println!("Skipped {skipped}"); + } 0 } Err(error) => { @@ -36,7 +41,10 @@ pub fn run_export(ctx: &CliContext, args: ExportArgs) -> i32 { } } -async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult { +async fn import( + ctx: &CliContext, + args: ImportArgs, +) -> CommandResult<(BatchUpsertResult, Vec)> { if let Some(workspace_id) = args.workspace_id.as_deref() { ctx.db() .get_workspace(workspace_id) @@ -69,11 +77,48 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult ImportOrigin { + let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let label = canonical + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| path.display().to_string()); + ImportOrigin { origin: canonical.to_string_lossy().to_string(), label } +} + +/// Summarize what the default selection left untouched during a merging re-import. +fn format_skipped(items: &[ImportPlanItem]) -> Option { + let count = |action: ImportPlanAction| items.iter().filter(|i| i.action == action).count(); + let plural = |n: usize| if n == 1 { "" } else { "s" }; + + let mut parts = Vec::new(); + let deletions = count(ImportPlanAction::Delete); + if deletions > 0 { + parts.push(format!("{deletions} removed from source (not deleted locally)")); + } + let conflicts = count(ImportPlanAction::Conflict); + if conflicts > 0 { + parts.push(format!("{conflicts} conflict{} (kept local changes)", plural(conflicts))); + } + let keep_local = count(ImportPlanAction::KeepLocal); + if keep_local > 0 { + parts.push(format!("{keep_local} with local edits")); + } + let unchanged = count(ImportPlanAction::Unchanged); + if unchanged > 0 { + parts.push(format!("{unchanged} unchanged")); + } + + if parts.is_empty() { None } else { Some(parts.join(", ")) } } fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult { diff --git a/crates-cli/yaak-cli/tests/import_export_commands.rs b/crates-cli/yaak-cli/tests/import_export_commands.rs index 0b332cb0..f4d05e9e 100644 --- a/crates-cli/yaak-cli/tests/import_export_commands.rs +++ b/crates-cli/yaak-cli/tests/import_export_commands.rs @@ -167,3 +167,93 @@ fn import_postman_environment_uses_workspace_id() { environments.iter().find(|e| e.name == "Local").expect("postman environment imported"); assert_eq!(imported_environment.workspace_id, workspace_id); } + +fn write_linked_fixture(path: &std::path::Path, requests: &[(&str, &str, &str)]) { + let requests = requests + .iter() + .map(|(id, name, url)| { + format!( + r#"{{ "model": "http_request", "id": "{id}", "workspaceId": "wrk_link", + "name": "{name}", "method": "GET", "url": "{url}" }}"# + ) + }) + .collect::>() + .join(","); + std::fs::write( + path, + format!( + r#"{{ + "yaakVersion": "test", + "yaakSchema": 4, + "resources": {{ + "workspaces": [{{ "model": "workspace", "id": "wrk_link", "name": "Linked Workspace" }}], + "httpRequests": [{requests}] + }} +}}"# + ), + ) + .expect("write linked fixture"); +} + +#[test] +fn re_import_merges_into_linked_workspace() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path(); + let import_path = temp_dir.path().join("linked.json"); + + write_linked_fixture( + &import_path, + &[ + ("req_a", "Request A", "https://example.com/a"), + ("req_b", "Request B", "https://example.com/b"), + ], + ); + cli_cmd(data_dir) + .args(["import", import_path.to_str().expect("import path is utf-8")]) + .assert() + .success() + .stdout(contains("Imported 1 workspace, 2 HTTP requests")); + + let workspace_id = { + let query_manager = query_manager(data_dir); + let db = query_manager.connect(); + db.list_workspaces() + .expect("list workspaces") + .into_iter() + .find(|w| w.name == "Linked Workspace") + .expect("workspace imported") + .id + }; + + // The source doc changes A, drops B, and adds C. The default selection applies the + // update and the create but leaves the removal as an offer. + write_linked_fixture( + &import_path, + &[ + ("req_a", "Request A", "https://example.com/a-v2"), + ("req_c", "Request C", "https://example.com/c"), + ], + ); + cli_cmd(data_dir) + .args([ + "import", + import_path.to_str().expect("import path is utf-8"), + "--workspace-id", + &workspace_id, + ]) + .assert() + .success() + .stdout(contains("Imported 2 HTTP requests")) + .stdout(contains("Skipped 1 removed from source")); + + let query_manager = query_manager(data_dir); + let db = query_manager.connect(); + let requests = db.list_http_requests(&workspace_id).expect("list requests"); + assert_eq!(requests.len(), 3, "merge must not duplicate: {requests:?}"); + assert_eq!( + requests.iter().find(|r| r.name == "Request A").expect("request A").url, + "https://example.com/a-v2" + ); + assert!(requests.iter().any(|r| r.name == "Request B"), "removal must not auto-apply"); + assert!(requests.iter().any(|r| r.name == "Request C")); +} diff --git a/crates-tauri/yaak-app-client/src/import.rs b/crates-tauri/yaak-app-client/src/import.rs index f7c0bbfd..127f3eec 100644 --- a/crates-tauri/yaak-app-client/src/import.rs +++ b/crates-tauri/yaak-app-client/src/import.rs @@ -6,13 +6,16 @@ use std::io::ErrorKind; use tauri::{Manager, Runtime, WebviewWindow}; use yaak::import::{self, PlanImportDataParams}; use yaak_api::{ApiClientKind, yaak_api_client}; -use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan}; +use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlan}; pub(crate) async fn import_data( window: &WebviewWindow, file_path: &str, + origin: Option, ) -> Result { - let plan = plan_import_data(window, file_path, ImportDestination::NewWorkspace).await?; + let contents = read_import_file(file_path)?; + let plan = + plan_import_contents(window, &contents, ImportDestination::NewWorkspace, origin).await?; commit_import(window, plan) } @@ -22,7 +25,7 @@ pub(crate) async fn plan_import_data( destination: ImportDestination, ) -> Result { let contents = read_import_file(file_path)?; - plan_import_contents(window, &contents, destination).await + plan_import_contents(window, &contents, destination, Some(file_origin(file_path))).await } pub(crate) async fn plan_import_url( @@ -30,14 +33,16 @@ pub(crate) async fn plan_import_url( url: &str, destination: ImportDestination, ) -> Result { - let contents = fetch_import_url(window, url).await?; - plan_import_contents(window, &contents, destination).await + let url = normalize_import_url(url)?; + let contents = fetch_import_url(window, &url).await?; + plan_import_contents(window, &contents, destination, Some(url_origin(&url))).await } async fn plan_import_contents( window: &WebviewWindow, contents: &str, destination: ImportDestination, + origin: Option, ) -> Result { let plugin_manager = crate::plugins_ext::plugin_manager(window).await?; let query_manager = window.db_manager(); @@ -49,10 +54,27 @@ async fn plan_import_contents( plugin_context: &plugin_context, destination, contents, + origin, }) .await?) } +/// Canonicalize so re-importing the same file through a different spelling of its path still +/// matches the linked source. +pub(crate) fn file_origin(file_path: &str) -> ImportOrigin { + let path = std::path::Path::new(file_path); + let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let label = canonical + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| file_path.to_string()); + ImportOrigin { origin: canonical.to_string_lossy().to_string(), label } +} + +pub(crate) fn url_origin(url: &str) -> ImportOrigin { + ImportOrigin { origin: url.to_string(), label: url.to_string() } +} + pub(crate) fn commit_import( window: &WebviewWindow, plan: ImportPlan, @@ -88,7 +110,7 @@ async fn fetch_import_url(window: &WebviewWindow, url: &str) -> R .map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}"))) } -fn normalize_import_url(url: &str) -> Result { +pub(crate) fn normalize_import_url(url: &str) -> Result { let url = url.trim(); if url.is_empty() { return Err(Error::GenericError("Import URL must not be empty".to_string())); diff --git a/crates-tauri/yaak-app-client/src/rpc_ext.rs b/crates-tauri/yaak-app-client/src/rpc_ext.rs index 5aa8ceb7..4d1f5f26 100644 --- a/crates-tauri/yaak-app-client/src/rpc_ext.rs +++ b/crates-tauri/yaak-app-client/src/rpc_ext.rs @@ -37,7 +37,8 @@ use yaak_grpc::ServiceDefinition; use yaak_models::blob_manager::BlobManager; use yaak_models::models::{ GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, - HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta, + HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent, + WorkspaceMeta, }; use yaak_models::query_manager::QueryManager; use yaak_models::util::{BatchUpsertResult, ImportPlan}; @@ -464,6 +465,24 @@ async fn cmd_commit_import(ctx: ClientCtx, req: CmdCommitImportRe Ok(crate::cmd_commit_import(ctx.window.clone(), req.plan).await?) } +async fn cmd_list_import_sources(ctx: ClientCtx, req: CmdListImportSourcesReq) -> Result> { + use crate::models_ext::QueryManagerExt; + Ok(ctx.window.db().list_import_sources(&req.workspace_id)?) +} + +async fn cmd_import_sources_for_origin(ctx: ClientCtx, req: CmdImportSourcesForOriginReq) -> Result> { + use crate::models_ext::QueryManagerExt; + let origin = match (req.file_path, req.url) { + (Some(file_path), _) => crate::import::file_origin(&file_path).origin, + (None, Some(url)) => match crate::import::normalize_import_url(&url) { + Ok(url) => crate::import::url_origin(&url).origin, + Err(_) => return Ok(Vec::new()), + }, + (None, None) => return Ok(Vec::new()), + }; + Ok(ctx.window.db().list_import_sources_by_origin(&origin)?) +} + async fn cmd_http_request_actions(ctx: ClientCtx, req: CmdHttpRequestActionsReq) -> Result> { Ok(yaak_commands::actions::cmd_http_request_actions(ctx, req).await?) } diff --git a/crates-tauri/yaak-app-client/src/uri_scheme.rs b/crates-tauri/yaak-app-client/src/uri_scheme.rs index 874ad2cd..e6bd13da 100644 --- a/crates-tauri/yaak-app-client/src/uri_scheme.rs +++ b/crates-tauri/yaak-app-client/src/uri_scheme.rs @@ -1,6 +1,6 @@ use crate::PluginContextExt; use crate::error::Result; -use crate::import::import_data; +use crate::import::{file_origin, import_data, url_origin}; use crate::models_ext::QueryManagerExt; use log::{info, warn}; use std::collections::HashMap; @@ -69,6 +69,7 @@ pub(crate) async fn handle_deep_link( } "import-data" => { let mut file_path = query_map.get("path").map(|s| s.to_owned()); + let mut origin = None; let name = query_map.get("name").map(|s| s.to_owned()).unwrap_or("data".to_string()); _ = window.set_focus(); @@ -98,6 +99,7 @@ pub(crate) async fn handle_deep_link( .to_string(); fs::write(&p, json)?; file_path = Some(p); + origin = Some(url_origin(file_url)); } let file_path = match file_path { @@ -116,7 +118,8 @@ pub(crate) async fn handle_deep_link( } }; - let results = import_data(window, &file_path).await?; + let origin = origin.unwrap_or_else(|| file_origin(&file_path)); + let results = import_data(window, &file_path, Some(origin)).await?; window.emit( "show_toast", ShowToastRequest { diff --git a/crates/common/yaak-rpc-schema/bindings/gen_models.ts b/crates/common/yaak-rpc-schema/bindings/gen_models.ts index 500d9c94..aa5fa233 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_models.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_models.ts @@ -11,6 +11,7 @@ export type AnyModel = | HttpRequest | HttpResponse | HttpResponseEvent + | ImportSource | KeyValue | Plugin | Settings @@ -318,6 +319,29 @@ export type HttpUrlParameter = { export type HttpVersion = "auto" | "http1" | "http2"; +export type ImportSource = { + model: "import_source"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + importer: string; + origin: string; + originLabel: string; + lastImportedAt: string; +}; + +export type ImportSourceResource = { + model: "import_source_resource"; + createdAt: string; + updatedAt: string; + importSourceId: string; + sourceKey: string; + modelType: string; + modelId: string; + snapshot: string; +}; + export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion }; diff --git a/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts b/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts index 1be78d1e..07841251 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts @@ -3,7 +3,7 @@ import type { PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse } f import type { CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest, CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, JsonPrimitive, RenderPurpose } from "./gen_events"; import type { BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote, GitStatusSummary, GitWorktreeStatus, PullResult, PushResult } from "./gen_git"; import type { ServiceDefinition } from "./gen_grpc"; -import type { AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta } from "./gen_models"; +import type { AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta } from "./gen_models"; import type { PluginMetadata } from "./gen_search"; import type { SyncOp } from "./gen_sync"; import type { BatchUpsertResult, ImportDestination, ImportPlan } from "./gen_util"; @@ -145,8 +145,12 @@ export type CmdHttpResponseBodyReq = { responseId: string, filter: string | null export type CmdImportDataReq = { filePath: string, destination: ImportDestination, }; +export type CmdImportSourcesForOriginReq = { filePath?: string, url?: string, }; + export type CmdImportUrlReq = { url: string, destination: ImportDestination, }; +export type CmdListImportSourcesReq = { workspaceId: string, }; + export type CmdMetadataReq = Record; export type CmdNewChildWindowReq = { url: string, label: string, title: string, innerSize: [number, number], }; @@ -250,6 +254,6 @@ export type ModelsWebsocketEventsReq = { connectionId: string, }; export type ModelsWorkspaceModelsReq = { workspaceId: string | null, }; -export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_import_url: [CmdImportUrlReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, null], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array], models_grpc_events: [ModelsGrpcEventsReq, Array], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_worktree_status: [CmdGitWorktreeStatusReq, GitWorktreeStatus], cmd_git_log: [CmdGitLogReq, Array], cmd_git_log_for_file: [CmdGitLogForFileReq, Array], cmd_git_file_diff_for_commit: [CmdGitFileDiffForCommitReq, GitFileDiff], cmd_git_initialize: [CmdGitInitializeReq, null], cmd_git_clone: [CmdGitCloneReq, CloneResult], cmd_git_commit: [CmdGitCommitReq, null], cmd_git_fetch_all: [CmdGitFetchAllReq, null], cmd_git_push: [CmdGitPushReq, PushResult], cmd_git_pull: [CmdGitPullReq, PullResult], cmd_git_pull_force_reset: [CmdGitPullForceResetReq, PullResult], cmd_git_pull_merge: [CmdGitPullMergeReq, PullResult], cmd_git_add: [CmdGitAddReq, null], cmd_git_unstage: [CmdGitUnstageReq, null], cmd_git_reset_changes: [CmdGitResetChangesReq, null], cmd_git_restore_files: [CmdGitRestoreFilesReq, null], cmd_git_restore_file_from_commit: [CmdGitRestoreFileFromCommitReq, null], cmd_git_add_credential: [CmdGitAddCredentialReq, null], cmd_git_remotes: [CmdGitRemotesReq, Array], cmd_git_add_remote: [CmdGitAddRemoteReq, GitRemote], cmd_git_rm_remote: [CmdGitRmRemoteReq, null], cmd_sync_calculate: [CmdSyncCalculateReq, Array], cmd_sync_calculate_fs: [CmdSyncCalculateFsReq, Array], cmd_sync_apply: [CmdSyncApplyReq, null], cmd_ws_delete_connections: [CmdWsDeleteConnectionsReq, null], cmd_ws_send: [CmdWsSendReq, WebsocketConnection], cmd_ws_close: [CmdWsCloseReq, WebsocketConnection], cmd_ws_connect: [CmdWsConnectReq, WebsocketConnection], cmd_plugins_search: [CmdPluginsSearchReq, PluginSearchResponse], cmd_plugins_install: [CmdPluginsInstallReq, null], cmd_plugins_install_from_directory: [CmdPluginsInstallFromDirectoryReq, Plugin], cmd_plugins_uninstall: [CmdPluginsUninstallReq, Plugin], cmd_plugin_init_errors: [CmdPluginInitErrorsReq, Array<[string, string]>], cmd_plugins_updates: [CmdPluginsUpdatesReq, PluginUpdatesResponse], cmd_plugins_update_all: [CmdPluginsUpdateAllReq, Array], cmd_git_watch_worktree_status: [CmdGitWatchWorktreeStatusReq, GitWatchResult], cmd_sync_watch: [CmdSyncWatchReq, WatchResult], }; +export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_import_url: [CmdImportUrlReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, null], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array], models_grpc_events: [ModelsGrpcEventsReq, Array], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_worktree_status: [CmdGitWorktreeStatusReq, GitWorktreeStatus], cmd_git_log: [CmdGitLogReq, Array], cmd_git_log_for_file: [CmdGitLogForFileReq, Array], cmd_git_file_diff_for_commit: [CmdGitFileDiffForCommitReq, GitFileDiff], cmd_git_initialize: [CmdGitInitializeReq, null], cmd_git_clone: [CmdGitCloneReq, CloneResult], cmd_git_commit: [CmdGitCommitReq, null], cmd_git_fetch_all: [CmdGitFetchAllReq, null], cmd_git_push: [CmdGitPushReq, PushResult], cmd_git_pull: [CmdGitPullReq, PullResult], cmd_git_pull_force_reset: [CmdGitPullForceResetReq, PullResult], cmd_git_pull_merge: [CmdGitPullMergeReq, PullResult], cmd_git_add: [CmdGitAddReq, null], cmd_git_unstage: [CmdGitUnstageReq, null], cmd_git_reset_changes: [CmdGitResetChangesReq, null], cmd_git_restore_files: [CmdGitRestoreFilesReq, null], cmd_git_restore_file_from_commit: [CmdGitRestoreFileFromCommitReq, null], cmd_git_add_credential: [CmdGitAddCredentialReq, null], cmd_git_remotes: [CmdGitRemotesReq, Array], cmd_git_add_remote: [CmdGitAddRemoteReq, GitRemote], cmd_git_rm_remote: [CmdGitRmRemoteReq, null], cmd_sync_calculate: [CmdSyncCalculateReq, Array], cmd_sync_calculate_fs: [CmdSyncCalculateFsReq, Array], cmd_sync_apply: [CmdSyncApplyReq, null], cmd_ws_delete_connections: [CmdWsDeleteConnectionsReq, null], cmd_ws_send: [CmdWsSendReq, WebsocketConnection], cmd_ws_close: [CmdWsCloseReq, WebsocketConnection], cmd_ws_connect: [CmdWsConnectReq, WebsocketConnection], cmd_plugins_search: [CmdPluginsSearchReq, PluginSearchResponse], cmd_plugins_install: [CmdPluginsInstallReq, null], cmd_plugins_install_from_directory: [CmdPluginsInstallFromDirectoryReq, Plugin], cmd_plugins_uninstall: [CmdPluginsUninstallReq, Plugin], cmd_plugin_init_errors: [CmdPluginInitErrorsReq, Array<[string, string]>], cmd_plugins_updates: [CmdPluginsUpdatesReq, PluginUpdatesResponse], cmd_plugins_update_all: [CmdPluginsUpdateAllReq, Array], cmd_git_watch_worktree_status: [CmdGitWatchWorktreeStatusReq, GitWatchResult], cmd_sync_watch: [CmdSyncWatchReq, WatchResult], }; export type WatchResult = { unlistenEvent: string, }; diff --git a/crates/common/yaak-rpc-schema/bindings/gen_util.ts b/crates/common/yaak-rpc-schema/bindings/gen_util.ts index 68b119de..49d52e9f 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_util.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_util.ts @@ -3,6 +3,8 @@ import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, W export type BatchUpsertResult = { workspaces: Array, environments: Array, folders: Array, httpRequests: Array, grpcRequests: Array, websocketRequests: Array, }; +export type ImportConflictResolution = "keep_mine" | "take_source"; + /** * Where a staged import will be committed. * @@ -11,10 +13,36 @@ export type BatchUpsertResult = { workspaces: Array, environments: Ar */ export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, }; +/** + * Where an import's contents came from, used to link the committed workspace back to it. + */ +export type ImportOrigin = { +/** + * The absolute file path or URL the contents were read from. + */ +origin: string, label: string, }; + export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array, /** - * Stable source key for every model in `resources`, keyed by its freshly minted ID. + * Stable source key for every model in `resources`, keyed by its planned ID. */ -sourceKeys: { [key in string]?: string }, }; +sourceKeys: { [key in string]?: string }, +/** + * One entry per plannable resource; commit applies only the selected ones. + */ +items: Array, origin?: ImportOrigin, }; + +export type ImportPlanAction = "create" | "update" | "delete" | "unchanged" | "keep_local" | "conflict"; + +export type ImportPlanItem = { action: ImportPlanAction, model: ImportResourceType, modelId: string, name: string, +/** + * Planned parent folder ID for incoming resources; current parent for deletions. + */ +parentId?: string, selected: boolean, resolution?: ImportConflictResolution, }; export type ImportPlanWarning = { title: string, detail: string, }; + +/** + * The model types an import plan can contain. + */ +export type ImportResourceType = "environment" | "folder" | "grpc_request" | "http_request" | "websocket_request" | "workspace"; diff --git a/crates/common/yaak-rpc-schema/src/lib.rs b/crates/common/yaak-rpc-schema/src/lib.rs index 4425dc1f..776f91cf 100644 --- a/crates/common/yaak-rpc-schema/src/lib.rs +++ b/crates/common/yaak-rpc-schema/src/lib.rs @@ -21,7 +21,8 @@ use yaak_git::{ use yaak_grpc::ServiceDefinition; use yaak_models::models::{ AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, - HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta, + HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent, + WorkspaceMeta, }; use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan}; use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse}; @@ -247,6 +248,23 @@ pub struct CmdCommitImportReq { pub plan: ImportPlan, } +#[derive(Debug, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct CmdListImportSourcesReq { + pub workspace_id: String, +} + +#[derive(Debug, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct CmdImportSourcesForOriginReq { + #[ts(optional)] + pub file_path: Option, + #[ts(optional)] + pub url: Option, +} + #[derive(Debug, Deserialize, TS)] #[ts(export, export_to = "gen_rpc.ts")] pub struct CmdHttpRequestActionsReq {} @@ -921,6 +939,8 @@ macro_rules! with_commands { cmd_import_data(CmdImportDataReq) -> ImportPlan, cmd_import_url(CmdImportUrlReq) -> ImportPlan, cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult, + cmd_list_import_sources(CmdListImportSourcesReq) -> Vec, + cmd_import_sources_for_origin(CmdImportSourcesForOriginReq) -> Vec, cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec, cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec, cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (), diff --git a/crates/yaak-models/bindings/gen_models.ts b/crates/yaak-models/bindings/gen_models.ts index e6981f82..5965fecd 100644 --- a/crates/yaak-models/bindings/gen_models.ts +++ b/crates/yaak-models/bindings/gen_models.ts @@ -12,6 +12,7 @@ export type AnyModel = | HttpRequest | HttpResponse | HttpResponseEvent + | ImportSource | KeyValue | Plugin | Settings @@ -336,6 +337,29 @@ export type HttpUrlParameter = { export type HttpVersion = "auto" | "http1" | "http2"; +export type ImportSource = { + model: "import_source"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + importer: string; + origin: string; + originLabel: string; + lastImportedAt: string; +}; + +export type ImportSourceResource = { + model: "import_source_resource"; + createdAt: string; + updatedAt: string; + importSourceId: string; + sourceKey: string; + modelType: string; + modelId: string; + snapshot: string; +}; + export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion }; diff --git a/crates/yaak-models/bindings/gen_util.ts b/crates/yaak-models/bindings/gen_util.ts index 68b119de..49d52e9f 100644 --- a/crates/yaak-models/bindings/gen_util.ts +++ b/crates/yaak-models/bindings/gen_util.ts @@ -3,6 +3,8 @@ import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, W export type BatchUpsertResult = { workspaces: Array, environments: Array, folders: Array, httpRequests: Array, grpcRequests: Array, websocketRequests: Array, }; +export type ImportConflictResolution = "keep_mine" | "take_source"; + /** * Where a staged import will be committed. * @@ -11,10 +13,36 @@ export type BatchUpsertResult = { workspaces: Array, environments: Ar */ export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, }; +/** + * Where an import's contents came from, used to link the committed workspace back to it. + */ +export type ImportOrigin = { +/** + * The absolute file path or URL the contents were read from. + */ +origin: string, label: string, }; + export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array, /** - * Stable source key for every model in `resources`, keyed by its freshly minted ID. + * Stable source key for every model in `resources`, keyed by its planned ID. */ -sourceKeys: { [key in string]?: string }, }; +sourceKeys: { [key in string]?: string }, +/** + * One entry per plannable resource; commit applies only the selected ones. + */ +items: Array, origin?: ImportOrigin, }; + +export type ImportPlanAction = "create" | "update" | "delete" | "unchanged" | "keep_local" | "conflict"; + +export type ImportPlanItem = { action: ImportPlanAction, model: ImportResourceType, modelId: string, name: string, +/** + * Planned parent folder ID for incoming resources; current parent for deletions. + */ +parentId?: string, selected: boolean, resolution?: ImportConflictResolution, }; export type ImportPlanWarning = { title: string, detail: string, }; + +/** + * The model types an import plan can contain. + */ +export type ImportResourceType = "environment" | "folder" | "grpc_request" | "http_request" | "websocket_request" | "workspace"; diff --git a/crates/yaak-models/guest-js/util.ts b/crates/yaak-models/guest-js/util.ts index 7435a267..cee67587 100644 --- a/crates/yaak-models/guest-js/util.ts +++ b/crates/yaak-models/guest-js/util.ts @@ -12,6 +12,7 @@ export function newStoreData(): ModelStoreData { http_request: {}, http_response: {}, http_response_event: {}, + import_source: {}, key_value: {}, plugin: {}, settings: {}, diff --git a/crates/yaak-models/migrations/20260901000000_import-sources.sql b/crates/yaak-models/migrations/20260901000000_import-sources.sql new file mode 100644 index 00000000..83d42d00 --- /dev/null +++ b/crates/yaak-models/migrations/20260901000000_import-sources.sql @@ -0,0 +1,25 @@ +CREATE TABLE import_sources +( + id TEXT NOT NULL PRIMARY KEY, + model TEXT DEFAULT 'import_source' NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + workspace_id TEXT NOT NULL, + importer TEXT NOT NULL, + origin TEXT NOT NULL, + origin_label TEXT NOT NULL, + last_imported_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE TABLE import_source_resources +( + model TEXT DEFAULT 'import_source_resource' NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + import_source_id TEXT NOT NULL, + source_key TEXT NOT NULL, + model_type TEXT NOT NULL, + model_id TEXT NOT NULL, + snapshot TEXT NOT NULL, + PRIMARY KEY (import_source_id, source_key) +); diff --git a/crates/yaak-models/src/models.rs b/crates/yaak-models/src/models.rs index a03c91f5..ae335efc 100644 --- a/crates/yaak-models/src/models.rs +++ b/crates/yaak-models/src/models.rs @@ -3022,6 +3022,123 @@ impl<'s> TryFrom<&Row<'s>> for PluginKeyValue { } } +#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_models.ts")] +#[enum_def(table_name = "import_sources")] +pub struct ImportSource { + #[ts(type = "\"import_source\"")] + pub model: String, + pub id: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub workspace_id: String, + + pub importer: String, + pub origin: String, + pub origin_label: String, + pub last_imported_at: NaiveDateTime, +} + +impl UpsertModelInfo for ImportSource { + fn table_name() -> impl IntoTableRef + IntoIden { + ImportSourceIden::Table + } + + fn id_column() -> impl IntoIden + Eq + Clone { + ImportSourceIden::Id + } + + fn generate_id() -> String { + generate_prefixed_id("im") + } + + fn order_by() -> (impl IntoColumnRef, Order) { + (ImportSourceIden::CreatedAt, Desc) + } + + fn get_id(&self) -> String { + self.id.clone() + } + + fn insert_values( + self, + source: &UpdateSource, + ) -> DbResult)>> { + use ImportSourceIden::*; + Ok(vec![ + (CreatedAt, upsert_date(source, self.created_at)), + (UpdatedAt, upsert_date(source, self.updated_at)), + (WorkspaceId, self.workspace_id.into()), + (Importer, self.importer.into()), + (Origin, self.origin.into()), + (OriginLabel, self.origin_label.into()), + (LastImportedAt, self.last_imported_at.into()), + ]) + } + + fn update_columns() -> Vec { + vec![ + ImportSourceIden::UpdatedAt, + ImportSourceIden::Importer, + ImportSourceIden::Origin, + ImportSourceIden::OriginLabel, + ImportSourceIden::LastImportedAt, + ] + } + + fn from_row(row: &Row) -> rusqlite::Result + where + Self: Sized, + { + Ok(Self { + id: row.get("id")?, + model: row.get("model")?, + created_at: row.get("created_at")?, + updated_at: row.get("updated_at")?, + workspace_id: row.get("workspace_id")?, + importer: row.get("importer")?, + origin: row.get("origin")?, + origin_label: row.get("origin_label")?, + last_imported_at: row.get("last_imported_at")?, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_models.ts")] +#[enum_def(table_name = "import_source_resources")] +pub struct ImportSourceResource { + #[ts(type = "\"import_source_resource\"")] + pub model: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + + pub import_source_id: String, + pub source_key: String, + pub model_type: String, + pub model_id: String, + pub snapshot: String, +} + +impl<'s> TryFrom<&Row<'s>> for ImportSourceResource { + type Error = rusqlite::Error; + + fn try_from(r: &Row<'s>) -> std::result::Result { + Ok(Self { + model: r.get("model")?, + created_at: r.get("created_at")?, + updated_at: r.get("updated_at")?, + import_source_id: r.get("import_source_id")?, + source_key: r.get("source_key")?, + model_type: r.get("model_type")?, + model_id: r.get("model_id")?, + snapshot: r.get("snapshot")?, + }) + } +} + /// Only used as a `from_row` fallback for an unparseable settings column. The /// value a *new* model gets comes from that model's `Default` impl. fn default_request_message_size_setting() -> InheritedIntSetting { @@ -3093,6 +3210,7 @@ define_any_model! { HttpRequest, HttpResponse, HttpResponseEvent, + ImportSource, KeyValue, Plugin, Settings, @@ -3125,6 +3243,7 @@ impl<'de> Deserialize<'de> for AnyModel { Some(m) if m == "http_request" => HttpRequest(fv(value).unwrap()), Some(m) if m == "http_response" => HttpResponse(fv(value).unwrap()), Some(m) if m == "http_response_event" => HttpResponseEvent(fv(value).unwrap()), + Some(m) if m == "import_source" => ImportSource(fv(value).unwrap()), Some(m) if m == "key_value" => KeyValue(fv(value).unwrap()), Some(m) if m == "plugin" => Plugin(fv(value).unwrap()), Some(m) if m == "settings" => Settings(fv(value).unwrap()), diff --git a/crates/yaak-models/src/queries/import_source_resources.rs b/crates/yaak-models/src/queries/import_source_resources.rs new file mode 100644 index 00000000..aac7f0b0 --- /dev/null +++ b/crates/yaak-models/src/queries/import_source_resources.rs @@ -0,0 +1,94 @@ +use crate::client_db::ClientDb; +use crate::error::Result; +use crate::models::{ImportSourceResource, ImportSourceResourceIden}; +use sea_query::ExprTrait; +use sea_query::Keyword::CurrentTimestamp; +use sea_query::{Asterisk, Cond, Expr, OnConflict, Query, SqliteQueryBuilder}; +use sea_query_rusqlite::RusqliteBinder; + +impl<'a> ClientDb<'a> { + pub fn list_import_source_resources( + &self, + import_source_id: &str, + ) -> Result> { + let (sql, params) = Query::select() + .from(ImportSourceResourceIden::Table) + .column(Asterisk) + .cond_where(Expr::col(ImportSourceResourceIden::ImportSourceId).eq(import_source_id)) + .build_rusqlite(SqliteQueryBuilder); + let mut stmt = self.conn().prepare(sql.as_str())?; + let items = stmt.query_map(&*params.as_params(), |row| row.try_into())?; + Ok(items.filter_map(|v| v.ok()).collect()) + } + + pub fn upsert_import_source_resource( + &self, + resource: &ImportSourceResource, + ) -> Result { + let (sql, params) = Query::insert() + .into_table(ImportSourceResourceIden::Table) + .columns([ + ImportSourceResourceIden::CreatedAt, + ImportSourceResourceIden::UpdatedAt, + ImportSourceResourceIden::ImportSourceId, + ImportSourceResourceIden::SourceKey, + ImportSourceResourceIden::ModelType, + ImportSourceResourceIden::ModelId, + ImportSourceResourceIden::Snapshot, + ]) + .values_panic([ + CurrentTimestamp.into(), + CurrentTimestamp.into(), + resource.import_source_id.as_str().into(), + resource.source_key.as_str().into(), + resource.model_type.as_str().into(), + resource.model_id.as_str().into(), + resource.snapshot.as_str().into(), + ]) + .on_conflict( + OnConflict::columns([ + ImportSourceResourceIden::ImportSourceId, + ImportSourceResourceIden::SourceKey, + ]) + .update_columns([ + ImportSourceResourceIden::UpdatedAt, + ImportSourceResourceIden::ModelType, + ImportSourceResourceIden::ModelId, + ImportSourceResourceIden::Snapshot, + ]) + .to_owned(), + ) + .returning_all() + .build_rusqlite(SqliteQueryBuilder); + + let mut stmt = self.conn().prepare(sql.as_str())?; + let m = stmt.query_row(&*params.as_params(), |row| row.try_into())?; + Ok(m) + } + + pub fn delete_import_source_resource( + &self, + import_source_id: &str, + source_key: &str, + ) -> Result<()> { + let (sql, params) = Query::delete() + .from_table(ImportSourceResourceIden::Table) + .cond_where( + Cond::all() + .add(Expr::col(ImportSourceResourceIden::ImportSourceId).eq(import_source_id)) + .add(Expr::col(ImportSourceResourceIden::SourceKey).eq(source_key)), + ) + .build_rusqlite(SqliteQueryBuilder); + self.conn().execute(sql.as_str(), &*params.as_params())?; + Ok(()) + } + + pub fn delete_import_source_resources(&self, import_source_id: &str) -> Result<()> { + let (sql, params) = Query::delete() + .from_table(ImportSourceResourceIden::Table) + .cond_where(Expr::col(ImportSourceResourceIden::ImportSourceId).eq(import_source_id)) + .build_rusqlite(SqliteQueryBuilder); + self.conn().execute(sql.as_str(), &*params.as_params())?; + Ok(()) + } +} diff --git a/crates/yaak-models/src/queries/import_sources.rs b/crates/yaak-models/src/queries/import_sources.rs new file mode 100644 index 00000000..c077cac4 --- /dev/null +++ b/crates/yaak-models/src/queries/import_sources.rs @@ -0,0 +1,45 @@ +use crate::client_db::ClientDb; +use crate::error::Result; +use crate::models::{ImportSource, ImportSourceIden}; +use crate::util::UpdateSource; + +impl<'a> ClientDb<'a> { + pub fn get_import_source(&self, id: &str) -> Result { + self.find_one(ImportSourceIden::Id, id) + } + + pub fn list_import_sources(&self, workspace_id: &str) -> Result> { + self.find_many(ImportSourceIden::WorkspaceId, workspace_id, None) + } + + pub fn list_import_sources_by_origin(&self, origin: &str) -> Result> { + self.find_many(ImportSourceIden::Origin, origin, None) + } + + pub fn find_import_source( + &self, + workspace_id: &str, + importer: &str, + origin: &str, + ) -> Result> { + let sources = self.list_import_sources(workspace_id)?; + Ok(sources.into_iter().find(|s| s.importer == importer && s.origin == origin)) + } + + pub fn upsert_import_source( + &self, + import_source: &ImportSource, + source: &UpdateSource, + ) -> Result { + self.upsert(import_source, source) + } + + pub fn delete_import_source( + &self, + import_source: &ImportSource, + source: &UpdateSource, + ) -> Result { + self.delete_import_source_resources(&import_source.id)?; + self.delete(import_source, source) + } +} diff --git a/crates/yaak-models/src/queries/mod.rs b/crates/yaak-models/src/queries/mod.rs index e2e8dabc..e28df092 100644 --- a/crates/yaak-models/src/queries/mod.rs +++ b/crates/yaak-models/src/queries/mod.rs @@ -11,6 +11,8 @@ mod grpc_requests; mod http_requests; mod http_response_events; mod http_responses; +mod import_source_resources; +mod import_sources; mod key_values; mod model_changes; mod plugin_key_values; diff --git a/crates/yaak-models/src/queries/workspaces.rs b/crates/yaak-models/src/queries/workspaces.rs index bf6ae03b..3b9934a1 100644 --- a/crates/yaak-models/src/queries/workspaces.rs +++ b/crates/yaak-models/src/queries/workspaces.rs @@ -6,8 +6,9 @@ use crate::models::{ AnyModel, CookieJar, CookieJarIden, Environment, EnvironmentIden, Folder, FolderIden, GraphQlIntrospection, GraphQlIntrospectionIden, GrpcConnection, GrpcConnectionIden, GrpcEvent, GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, HttpRequestIden, - HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden, - ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden, WebsocketConnection, + HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden, ImportSource, + ImportSourceIden, ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden, + WebsocketConnection, WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden, WebsocketRequest, WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta, WorkspaceMetaIden, }; @@ -85,6 +86,10 @@ impl<'a> ClientDb<'a> { self.delete_many_untracked::(FolderIden::WorkspaceId, wid)?; self.delete_many_untracked::(EnvironmentIden::WorkspaceId, wid)?; self.delete_many_untracked::(CookieJarIden::WorkspaceId, wid)?; + for import_source in self.list_import_sources(wid)? { + self.delete_import_source_resources(&import_source.id)?; + } + self.delete_many_untracked::(ImportSourceIden::WorkspaceId, wid)?; self.delete_many_untracked::(SyncStateIden::WorkspaceId, wid)?; self.delete_many_untracked::(WorkspaceMetaIden::WorkspaceId, wid)?; self.delete(workspace, source) diff --git a/crates/yaak-models/src/util.rs b/crates/yaak-models/src/util.rs index 819dd3ba..f3c0befe 100644 --- a/crates/yaak-models/src/util.rs +++ b/crates/yaak-models/src/util.rs @@ -111,6 +111,90 @@ pub struct ImportPlanWarning { pub detail: String, } +/// Where an import's contents came from, used to link the committed workspace back to it. +#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_util.ts")] +pub struct ImportOrigin { + /// The absolute file path or URL the contents were read from. + pub origin: String, + pub label: String, +} + +/// The model types an import plan can contain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_util.ts")] +pub enum ImportResourceType { + Environment, + Folder, + GrpcRequest, + HttpRequest, + WebsocketRequest, + Workspace, +} + +impl ImportResourceType { + pub fn as_str(&self) -> &'static str { + match self { + ImportResourceType::Environment => "environment", + ImportResourceType::Folder => "folder", + ImportResourceType::GrpcRequest => "grpc_request", + ImportResourceType::HttpRequest => "http_request", + ImportResourceType::WebsocketRequest => "websocket_request", + ImportResourceType::Workspace => "workspace", + } + } + + pub fn from_str(value: &str) -> Option { + match value { + "environment" => Some(ImportResourceType::Environment), + "folder" => Some(ImportResourceType::Folder), + "grpc_request" => Some(ImportResourceType::GrpcRequest), + "http_request" => Some(ImportResourceType::HttpRequest), + "websocket_request" => Some(ImportResourceType::WebsocketRequest), + "workspace" => Some(ImportResourceType::Workspace), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_util.ts")] +pub enum ImportPlanAction { + Create, + Update, + Delete, + Unchanged, + KeepLocal, + Conflict, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_util.ts")] +pub enum ImportConflictResolution { + KeepMine, + TakeSource, +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_util.ts")] +pub struct ImportPlanItem { + pub action: ImportPlanAction, + pub model: ImportResourceType, + pub model_id: String, + pub name: String, + /// Planned parent folder ID for incoming resources; current parent for deletions. + #[ts(optional)] + pub parent_id: Option, + pub selected: bool, + #[ts(optional)] + pub resolution: Option, +} + #[derive(Debug, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "gen_util.ts")] @@ -120,8 +204,16 @@ pub struct ImportPlan { pub resources: BatchUpsertResult, pub warnings: Vec, - /// Stable source key for every model in `resources`, keyed by its freshly minted ID. + /// Stable source key for every model in `resources`, keyed by its planned ID. pub source_keys: BTreeMap, + + /// One entry per plannable resource; commit applies only the selected ones. + #[serde(default)] + pub items: Vec, + + #[serde(default)] + #[ts(optional)] + pub origin: Option, } pub fn get_workspace_export_resources( diff --git a/crates/yaak-sync/src/models.rs b/crates/yaak-sync/src/models.rs index 9c3c75fe..c90e3036 100644 --- a/crates/yaak-sync/src/models.rs +++ b/crates/yaak-sync/src/models.rs @@ -209,6 +209,7 @@ impl TryFrom for SyncModel { AnyModel::GrpcEvent(m) => return Err(UnknownModel(m.model)), AnyModel::HttpResponse(m) => return Err(UnknownModel(m.model)), AnyModel::HttpResponseEvent(m) => return Err(UnknownModel(m.model)), + AnyModel::ImportSource(m) => return Err(UnknownModel(m.model)), AnyModel::KeyValue(m) => return Err(UnknownModel(m.model)), AnyModel::Plugin(m) => return Err(UnknownModel(m.model)), AnyModel::Settings(m) => return Err(UnknownModel(m.model)), @@ -226,6 +227,14 @@ mod migration_tests { use crate::error::Result; use crate::models::SyncModel; + #[test] + fn import_sources_are_excluded_from_sync() { + let model = yaak_models::models::AnyModel::ImportSource( + yaak_models::models::ImportSource::default(), + ); + assert!(SyncModel::try_from(model).is_err()); + } + #[test] fn deserializes_environment_via_syncmodel_with_fixups() -> Result<()> { let raw = r#" diff --git a/crates/yaak/Cargo.toml b/crates/yaak/Cargo.toml index f81740ba..59e5a0fa 100644 --- a/crates/yaak/Cargo.toml +++ b/crates/yaak/Cargo.toml @@ -9,6 +9,7 @@ async-trait = "0.1" base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport log = { workspace = true } md5 = "0.8.0" +chrono = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["sync", "rt"] } diff --git a/crates/yaak/src/import.rs b/crates/yaak/src/import.rs index b519935b..82501f1e 100644 --- a/crates/yaak/src/import.rs +++ b/crates/yaak/src/import.rs @@ -1,14 +1,17 @@ use crate::Result; +use chrono::Utc; use log::info; +use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; use yaak_models::client_db::ClientDb; use yaak_models::models::{ - DEFAULT_REQUEST_MESSAGE_SIZE, Environment, Folder, GrpcRequest, HttpRequest, UpsertModelInfo, - WebsocketRequest, Workspace, + AnyModel, DEFAULT_REQUEST_MESSAGE_SIZE, Environment, Folder, GrpcRequest, HttpRequest, + ImportSource, ImportSourceResource, UpsertModelInfo, WebsocketRequest, Workspace, }; use yaak_models::query_manager::QueryManager; use yaak_models::util::{ - BatchUpsertResult, ImportDestination, ImportPlan, ImportPlanWarning, UpdateSource, + BatchUpsertResult, ImportConflictResolution, ImportDestination, ImportOrigin, ImportPlan, + ImportPlanAction, ImportPlanItem, ImportPlanWarning, ImportResourceType, UpdateSource, }; use yaak_plugins::events::{ImportResources, PluginContext}; use yaak_plugins::manager::PluginManager; @@ -19,6 +22,7 @@ pub struct PlanImportDataParams<'a> { pub plugin_context: &'a PluginContext, pub destination: ImportDestination, pub contents: &'a str, + pub origin: Option, } /// Parse importer output and turn it into a commit-ready plan without mutating the database. @@ -32,6 +36,7 @@ pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result>, + origin: Option, ) -> Result { let mut warnings = Vec::new(); validate_destination(query_manager, &destination)?; let plugin_keys = source_keys.unwrap_or_default(); - let source_ids = SourceIds::collect(&resources); + // Source keys and merge decisions describe the document as the importer produced it, not as + // this destination reshapes it, so keep the original around. + let original = resources.clone(); let source_folder_ids = resources.folders.iter().map(|v| v.id.clone()).collect::>(); let mut folder_ids = BTreeMap::new(); @@ -262,39 +270,605 @@ pub fn plan_import_resources( websocket_requests, }; - Ok(ImportPlan { + let mut plan = ImportPlan { importer, destination, - source_keys: assign_source_keys(&resources, &source_ids, &plugin_keys), + source_keys: assign_source_keys(&resources, &original, &plugin_keys), resources, warnings, - }) + items: Vec::new(), + origin, + }; + merge_with_linked_source(query_manager, &mut plan, &original)?; + Ok(plan) } -/// Commit a previously prepared plan in one transaction. +/// Commit a previously prepared plan in one transaction, applying only its selected items. pub fn commit_import_plan( query_manager: &QueryManager, plan: ImportPlan, ) -> Result { validate_plan(&plan)?; - let resources = plan.resources; info!("Committing staged import from {}", plan.importer); query_manager.with_tx(|tx| { validate_destination_db(tx, &plan.destination)?; - tx.batch_upsert( - resources.workspaces, - resources.environments, - resources.folders, - resources.http_requests, - resources.grpc_requests, - resources.websocket_requests, - &UpdateSource::Import, - ) - .map_err(crate::Error::from) + commit_plan_in_tx(tx, plan) }) } +fn commit_plan_in_tx(db: &ClientDb, plan: ImportPlan) -> Result { + let items: BTreeMap = + plan.items.iter().map(|item| (item.model_id.clone(), item.clone())).collect(); + + // A resource without an item (workspaces, plans from older callers) always applies. + // A selected keep-local item is an explicit request to revert the local edits. + let applies = |id: &str| match items.get(id) { + None => true, + Some(item) => match item.action { + ImportPlanAction::Create | ImportPlanAction::Update | ImportPlanAction::KeepLocal => { + item.selected + } + ImportPlanAction::Conflict => { + item.resolution == Some(ImportConflictResolution::TakeSource) + } + ImportPlanAction::Delete | ImportPlanAction::Unchanged => false, + }, + }; + + // A deselected new folder takes its planned descendants with it: nothing can be + // created inside a folder that will not exist. + let is_new_folder = |id: &str| items.get(id).is_some_and(|i| i.action == ImportPlanAction::Create); + let mut missing_folders: BTreeSet = plan + .resources + .folders + .iter() + .filter(|f| is_new_folder(&f.id) && !applies(&f.id)) + .map(|f| f.id.clone()) + .collect(); + loop { + let before = missing_folders.len(); + for folder in &plan.resources.folders { + if let Some(parent_id) = &folder.folder_id + && missing_folders.contains(parent_id) + { + missing_folders.insert(folder.id.clone()); + } + } + if missing_folders.len() == before { + break; + } + } + + let folder_available = |folder_id: &Option| match folder_id { + Some(id) => !missing_folders.contains(id), + None => true, + }; + + let resources = &plan.resources; + let upserted = db.batch_upsert( + resources.workspaces.clone(), + resources + .environments + .iter() + .filter(|v| applies(&v.id)) + .filter(|v| v.parent_model != "folder" || folder_available(&v.parent_id)) + .cloned() + .collect(), + resources + .folders + .iter() + .filter(|v| applies(&v.id) && !missing_folders.contains(&v.id)) + .cloned() + .collect(), + resources + .http_requests + .iter() + .filter(|v| applies(&v.id) && folder_available(&v.folder_id)) + .cloned() + .collect(), + resources + .grpc_requests + .iter() + .filter(|v| applies(&v.id) && folder_available(&v.folder_id)) + .cloned() + .collect(), + resources + .websocket_requests + .iter() + .filter(|v| applies(&v.id) && folder_available(&v.folder_id)) + .cloned() + .collect(), + &UpdateSource::Import, + )?; + + let selected_deletes = plan + .items + .iter() + .filter(|i| i.action == ImportPlanAction::Delete && i.selected) + .collect::>(); + // Folders last so their cascade only has to cover what was not deleted explicitly. + for item in selected_deletes.iter().filter(|i| i.model != ImportResourceType::Folder) { + delete_existing_model(db, item.model, &item.model_id)?; + } + for item in selected_deletes.iter().filter(|i| i.model == ImportResourceType::Folder) { + delete_existing_model(db, item.model, &item.model_id)?; + } + + record_import_source(db, &plan, &items, &upserted)?; + + Ok(upserted) +} + +/// A folder deletion may have already cascaded over the model, so absent models are skipped. +fn delete_existing_model(db: &ClientDb, resource: ImportResourceType, id: &str) -> Result<()> { + use ImportResourceType::*; + let source = &UpdateSource::Import; + match resource { + Environment => { + if db.get_environment(id).is_ok() { + db.delete_environment_by_id(id, source)?; + } + } + Folder => { + if db.get_folder(id).is_ok() { + db.delete_folder_by_id(id, source)?; + } + } + HttpRequest => { + if db.get_http_request(id).is_ok() { + db.delete_http_request_by_id(id, source)?; + } + } + GrpcRequest => { + if db.get_grpc_request(id).is_ok() { + db.delete_grpc_request_by_id(id, source)?; + } + } + WebsocketRequest => { + if db.get_websocket_request(id).is_ok() { + db.delete_websocket_request_by_id(id, source)?; + } + } + // The destination workspace is never a plan item, so there is nothing to delete + Workspace => {} + } + Ok(()) +} + +/// Link the committed workspace to the import's origin and store a snapshot per resource, so the +/// next import from the same origin can three-way merge instead of duplicating everything. +/// +/// Snapshots advance for everything the user decided on this round — applied items and keep-mine +/// conflicts alike — while deselected updates and deletions keep their old snapshot so they are +/// offered again next time. +fn record_import_source( + db: &ClientDb, + plan: &ImportPlan, + items: &BTreeMap, + upserted: &BatchUpsertResult, +) -> Result<()> { + let Some(origin) = &plan.origin else { + return Ok(()); + }; + + let workspace_id = match &plan.destination { + ImportDestination::ExistingWorkspace { workspace_id, .. } => workspace_id.clone(), + ImportDestination::NewWorkspace => match upserted.workspaces.first() { + Some(workspace) => workspace.id.clone(), + None => return Ok(()), + }, + }; + + let existing = db.find_import_source(&workspace_id, &plan.importer, &origin.origin)?; + let import_source = db.upsert_import_source( + &ImportSource { + id: existing.map(|s| s.id).unwrap_or_default(), + workspace_id, + importer: plan.importer.clone(), + origin: origin.origin.clone(), + origin_label: origin.label.clone(), + last_imported_at: Utc::now().naive_utc(), + ..Default::default() + }, + &UpdateSource::Import, + )?; + + let mut committed: BTreeMap<&str, String> = BTreeMap::new(); + for v in &upserted.environments { + committed.insert(&v.id, serde_json::to_string(v)?); + } + for v in &upserted.folders { + committed.insert(&v.id, serde_json::to_string(v)?); + } + for v in &upserted.http_requests { + committed.insert(&v.id, serde_json::to_string(v)?); + } + for v in &upserted.grpc_requests { + committed.insert(&v.id, serde_json::to_string(v)?); + } + for v in &upserted.websocket_requests { + committed.insert(&v.id, serde_json::to_string(v)?); + } + + let write_row = |model_id: &str, resource: ImportResourceType, incoming: &dyn Fn() -> Result| -> Result<()> { + let Some(source_key) = plan.source_keys.get(model_id) else { + return Ok(()); + }; + let snapshot = match committed.get(model_id) { + Some(json) => json.clone(), + None => match items.get(model_id).map(|i| i.action) { + Some( + ImportPlanAction::Unchanged + | ImportPlanAction::KeepLocal + | ImportPlanAction::Conflict, + ) => incoming()?, + // A deselected create, update, or delete stays offered next import + Some( + ImportPlanAction::Create + | ImportPlanAction::Update + | ImportPlanAction::Delete, + ) + | None => return Ok(()), + }, + }; + db.upsert_import_source_resource(&ImportSourceResource { + import_source_id: import_source.id.clone(), + source_key: source_key.clone(), + model_type: resource.as_str().to_string(), + model_id: model_id.to_string(), + snapshot, + ..Default::default() + })?; + Ok(()) + }; + + for v in &plan.resources.environments { + write_row(&v.id, ImportResourceType::Environment, &|| Ok(serde_json::to_string(v)?))?; + } + for v in &plan.resources.folders { + write_row(&v.id, ImportResourceType::Folder, &|| Ok(serde_json::to_string(v)?))?; + } + for v in &plan.resources.http_requests { + write_row(&v.id, ImportResourceType::HttpRequest, &|| Ok(serde_json::to_string(v)?))?; + } + for v in &plan.resources.grpc_requests { + write_row(&v.id, ImportResourceType::GrpcRequest, &|| Ok(serde_json::to_string(v)?))?; + } + for v in &plan.resources.websocket_requests { + write_row(&v.id, ImportResourceType::WebsocketRequest, &|| Ok(serde_json::to_string(v)?))?; + } + + let incoming_keys: BTreeSet<&String> = plan.source_keys.values().collect(); + for row in db.list_import_source_resources(&import_source.id)? { + if incoming_keys.contains(&row.source_key) { + continue; + } + // Keep only rows that back a deletion the user deselected; it will be offered again. + let keep = match ImportResourceType::from_str(&row.model_type) { + Some(resource) => { + items + .get(&row.model_id) + .is_some_and(|i| i.action == ImportPlanAction::Delete && !i.selected) + && existing_model_json(db, resource, &row.model_id)?.is_some() + } + None => false, + }; + if !keep { + db.delete_import_source_resource(&import_source.id, &row.source_key)?; + } + } + + Ok(()) +} + +/// Rewrite a plan against the destination's linked import source, if it has one: resources whose +/// source key was seen before adopt the existing model's ID, and every resource gets a plan item +/// describing the create / update / delete / conflict decision to preview. +fn merge_with_linked_source( + query_manager: &QueryManager, + plan: &mut ImportPlan, + original: &ImportResources, +) -> Result<()> { + let db = query_manager.connect(); + + let linked = match (&plan.origin, &plan.destination) { + (Some(origin), ImportDestination::ExistingWorkspace { workspace_id, .. }) => { + db.find_import_source(workspace_id, &plan.importer, &origin.origin)? + } + (None, _) | (Some(_), ImportDestination::NewWorkspace) => None, + }; + + let Some(source) = linked else { + plan.items = create_only_items(plan); + return Ok(()); + }; + + let workspace_id = source.workspace_id.clone(); + let rows: BTreeMap = db + .list_import_source_resources(&source.id)? + .into_iter() + .map(|row| (row.source_key.clone(), row)) + .collect(); + + // Resources whose key maps to a model that still exists adopt that model's ID. + let mut remap: BTreeMap = BTreeMap::new(); + let mut current_models: BTreeMap = BTreeMap::new(); + { + let mut consider = |planned_id: &str, resource: ImportResourceType| -> Result<()> { + let Some(key) = plan.source_keys.get(planned_id) else { return Ok(()) }; + let Some(row) = rows.get(key) else { return Ok(()) }; + if ImportResourceType::from_str(&row.model_type) != Some(resource) { + return Ok(()); + } + let Some(current) = existing_model_json(&db, resource, &row.model_id)? else { + return Ok(()); + }; + if current.get("workspaceId").and_then(|v| v.as_str()) != Some(workspace_id.as_str()) { + return Ok(()); + } + remap.insert(planned_id.to_string(), row.model_id.clone()); + current_models.insert(row.model_id.clone(), current); + Ok(()) + }; + for v in &plan.resources.folders { + consider(&v.id, ImportResourceType::Folder)?; + } + for v in &plan.resources.environments { + consider(&v.id, ImportResourceType::Environment)?; + } + for v in &plan.resources.http_requests { + consider(&v.id, ImportResourceType::HttpRequest)?; + } + for v in &plan.resources.grpc_requests { + consider(&v.id, ImportResourceType::GrpcRequest)?; + } + for v in &plan.resources.websocket_requests { + consider(&v.id, ImportResourceType::WebsocketRequest)?; + } + } + + let remap_ref = |id: Option| id.map(|v| remap.get(&v).cloned().unwrap_or(v)); + for v in &mut plan.resources.folders { + if let Some(existing_id) = remap.get(&v.id) { + v.id = existing_id.clone(); + } + v.folder_id = remap_ref(v.folder_id.take()); + } + for v in &mut plan.resources.environments { + if let Some(existing_id) = remap.get(&v.id) { + v.id = existing_id.clone(); + } + if v.parent_model == "folder" { + v.parent_id = remap_ref(v.parent_id.take()); + } + } + for v in &mut plan.resources.http_requests { + if let Some(existing_id) = remap.get(&v.id) { + v.id = existing_id.clone(); + } + v.folder_id = remap_ref(v.folder_id.take()); + } + for v in &mut plan.resources.grpc_requests { + if let Some(existing_id) = remap.get(&v.id) { + v.id = existing_id.clone(); + } + v.folder_id = remap_ref(v.folder_id.take()); + } + for v in &mut plan.resources.websocket_requests { + if let Some(existing_id) = remap.get(&v.id) { + v.id = existing_id.clone(); + } + v.folder_id = remap_ref(v.folder_id.take()); + } + plan.source_keys = plan + .source_keys + .iter() + .map(|(id, key)| (remap.get(id).cloned().unwrap_or_else(|| id.clone()), key.clone())) + .collect(); + + // A mapped environment that is currently the destination's base environment stays the base + // environment: it came from this source, so the imported-copy separation does not apply. + let mut restored_base_names = BTreeSet::new(); + for (i, v) in plan.resources.environments.iter_mut().enumerate() { + let is_current_base = current_models + .get(&v.id) + .and_then(|m| m.get("parentModel")) + .and_then(|p| p.as_str()) + == Some("workspace"); + if !is_current_base { + continue; + } + if let Some(source) = original.environments.get(i) { + v.name = source.name.clone(); + } + v.parent_model = "workspace".to_string(); + v.parent_id = None; + restored_base_names.insert(v.name.clone()); + } + if !restored_base_names.is_empty() { + plan.warnings.retain(|w| { + !(w.title == "Base environment kept separate" + && restored_base_names.iter().any(|n| w.detail.starts_with(&format!("{n} →")))) + }); + } + + let mut items = Vec::new(); + { + let mut classify = |any: AnyModel, + resource: ImportResourceType, + parent_id: Option| + -> Result<()> { + let planned_id = any.id().to_string(); + let name = any.resolved_name(); + + let mapped = plan + .source_keys + .get(&planned_id) + .and_then(|key| rows.get(key)) + .filter(|row| row.model_id == planned_id); + let Some(row) = mapped else { + items.push(ImportPlanItem { + action: ImportPlanAction::Create, + model: resource, + model_id: planned_id, + name, + parent_id, + selected: true, + resolution: None, + }); + return Ok(()); + }; + + let incoming = comparable(serde_json::to_value(&any)?); + let current = current_models + .get(&planned_id) + .cloned() + .map(comparable) + .unwrap_or_default(); + let (source_changed, local_changed) = + match serde_json::from_str::(&row.snapshot).ok().map(comparable) { + Some(snapshot) => (incoming != snapshot, current != snapshot), + // An unreadable snapshot can't prove anything unchanged, so surface a conflict. + None => (true, true), + }; + + let (action, selected, resolution) = match (source_changed, local_changed) { + (false, false) => (ImportPlanAction::Unchanged, false, None), + (true, false) => (ImportPlanAction::Update, true, None), + (false, true) => (ImportPlanAction::KeepLocal, false, None), + (true, true) => ( + ImportPlanAction::Conflict, + true, + Some(ImportConflictResolution::KeepMine), + ), + }; + items.push(ImportPlanItem { + action, + model: resource, + model_id: planned_id, + name, + parent_id, + selected, + resolution, + }); + Ok(()) + }; + + for v in &plan.resources.folders { + classify(AnyModel::Folder(v.clone()), ImportResourceType::Folder, v.folder_id.clone())?; + } + for v in &plan.resources.http_requests { + classify(AnyModel::HttpRequest(v.clone()), ImportResourceType::HttpRequest, v.folder_id.clone())?; + } + for v in &plan.resources.grpc_requests { + classify(AnyModel::GrpcRequest(v.clone()), ImportResourceType::GrpcRequest, v.folder_id.clone())?; + } + for v in &plan.resources.websocket_requests { + classify(AnyModel::WebsocketRequest(v.clone()), ImportResourceType::WebsocketRequest, v.folder_id.clone())?; + } + for v in &plan.resources.environments { + classify(AnyModel::Environment(v.clone()), ImportResourceType::Environment, v.parent_id.clone())?; + } + } + + // Mapped models the source no longer has become deletion offers, deselected by default. + let incoming_keys: BTreeSet<&String> = plan.source_keys.values().collect(); + for (key, row) in &rows { + if incoming_keys.contains(key) { + continue; + } + let Some(resource) = ImportResourceType::from_str(&row.model_type) else { + continue; + }; + let Some(current) = existing_model_json(&db, resource, &row.model_id)? else { + continue; + }; + if current.get("workspaceId").and_then(|v| v.as_str()) != Some(workspace_id.as_str()) { + continue; + } + let name = serde_json::from_value::(current.clone()) + .map(|m| m.resolved_name()) + .unwrap_or_else(|_| "Unknown".to_string()); + let parent_id = current + .get("folderId") + .or_else(|| current.get("parentId")) + .and_then(|v| v.as_str()) + .map(str::to_string); + items.push(ImportPlanItem { + action: ImportPlanAction::Delete, + model: resource, + model_id: row.model_id.clone(), + name, + parent_id, + selected: false, + resolution: None, + }); + } + + plan.items = items; + Ok(()) +} + +fn create_only_items(plan: &ImportPlan) -> Vec { + let mut items = Vec::new(); + let mut push = |any: AnyModel, resource: ImportResourceType, parent_id: Option| { + items.push(ImportPlanItem { + action: ImportPlanAction::Create, + model: resource, + model_id: any.id().to_string(), + name: any.resolved_name(), + parent_id, + selected: true, + resolution: None, + }); + }; + for v in &plan.resources.folders { + push(AnyModel::Folder(v.clone()), ImportResourceType::Folder, v.folder_id.clone()); + } + for v in &plan.resources.http_requests { + push(AnyModel::HttpRequest(v.clone()), ImportResourceType::HttpRequest, v.folder_id.clone()); + } + for v in &plan.resources.grpc_requests { + push(AnyModel::GrpcRequest(v.clone()), ImportResourceType::GrpcRequest, v.folder_id.clone()); + } + for v in &plan.resources.websocket_requests { + push(AnyModel::WebsocketRequest(v.clone()), ImportResourceType::WebsocketRequest, v.folder_id.clone()); + } + for v in &plan.resources.environments { + push(AnyModel::Environment(v.clone()), ImportResourceType::Environment, v.parent_id.clone()); + } + items +} + +/// Strip identity and bookkeeping fields so equality means "same content in the same place". +/// The deprecated environment `base` flag mirrors `parentModel`, which is compared already. +fn comparable(mut value: Value) -> Value { + if let Some(object) = value.as_object_mut() { + for field in ["id", "model", "workspaceId", "createdAt", "updatedAt", "base"] { + object.remove(field); + } + } + value +} + +fn existing_model_json( + db: &ClientDb, + resource: ImportResourceType, + id: &str, +) -> Result> { + use ImportResourceType::*; + let value = match resource { + Environment => db.get_environment(id).ok().map(|m| serde_json::to_value(&m)), + Folder => db.get_folder(id).ok().map(|m| serde_json::to_value(&m)), + HttpRequest => db.get_http_request(id).ok().map(|m| serde_json::to_value(&m)), + GrpcRequest => db.get_grpc_request(id).ok().map(|m| serde_json::to_value(&m)), + WebsocketRequest => db.get_websocket_request(id).ok().map(|m| serde_json::to_value(&m)), + Workspace => None, + }; + Ok(value.transpose()?) +} + fn validate_destination( query_manager: &QueryManager, destination: &ImportDestination, @@ -351,7 +925,19 @@ fn validate_plan(plan: &ImportPlan) -> Result<()> { ); } - if plan.resources.environments.iter().any(|v| v.parent_model == "workspace") { + // A merging plan may update the base environment it created earlier, which its plan + // item records; anything else must not replace the destination's base environment. + let updates_own_base = |id: &str| { + plan.items + .iter() + .any(|i| i.model_id == id && i.action != ImportPlanAction::Create) + }; + if plan + .resources + .environments + .iter() + .any(|v| v.parent_model == "workspace" && !updates_own_base(&v.id)) + { return invalid( "An existing-workspace import plan must not replace the base environment" .to_string(), @@ -463,38 +1049,16 @@ fn display_list(items: &BTreeSet<&str>) -> String { } } -/// Importer-assigned IDs, captured before planning replaces them with freshly minted ones. -/// -/// Positional: each vector lines up with the same-named planned collection, in order. -struct SourceIds { - workspaces: Vec, - environments: Vec, - folders: Vec, - http_requests: Vec, - grpc_requests: Vec, - websocket_requests: Vec, -} - -impl SourceIds { - fn collect(resources: &ImportResources) -> Self { - let ids = |ids: &mut dyn Iterator| ids.cloned().collect::>(); - SourceIds { - workspaces: ids(&mut resources.workspaces.iter().map(|v| &v.id)), - environments: ids(&mut resources.environments.iter().map(|v| &v.id)), - folders: ids(&mut resources.folders.iter().map(|v| &v.id)), - http_requests: ids(&mut resources.http_requests.iter().map(|v| &v.id)), - grpc_requests: ids(&mut resources.grpc_requests.iter().map(|v| &v.id)), - websocket_requests: ids(&mut resources.websocket_requests.iter().map(|v| &v.id)), - } - } -} - +/// Keys are derived from the document as the importer produced it — names, routes, and folder +/// ancestry before any destination re-rooting or renaming — so the same document maps onto the +/// same keys no matter which workspace it is imported into. Collections are positional: planned +/// entry `i` came from original entry `i`. fn assign_source_keys( resources: &BatchUpsertResult, - source_ids: &SourceIds, + original: &ImportResources, plugin_keys: &BTreeMap, ) -> BTreeMap { - let folder_tree = resources + let folder_tree = original .folders .iter() .map(|v| (v.id.clone(), (v.name.clone(), v.folder_id.clone()))) @@ -506,43 +1070,54 @@ fn assign_source_keys( let mut candidates: Vec<(&str, Option<&String>, String, String)> = Vec::new(); for (i, v) in resources.workspaces.iter().enumerate() { - let key = fallback_key("workspace", &[], &v.name); - candidates.push((&v.id, plugin_key(source_ids.workspaces.get(i)), key.clone(), key)); + let source = original.workspaces.get(i); + let name = source.map(|s| s.name.as_str()).unwrap_or(v.name.as_str()); + let key = fallback_key("workspace", &[], name); + candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), key.clone(), key)); } for (i, v) in resources.environments.iter().enumerate() { - let ancestry = ancestry_path(&folder_tree, v.parent_id.as_deref()); - let key = fallback_key("environment", &ancestry, &v.name); - candidates.push((&v.id, plugin_key(source_ids.environments.get(i)), key.clone(), key)); + let source = original.environments.get(i); + let name = source.map(|s| s.name.as_str()).unwrap_or(v.name.as_str()); + let parent_id = source.and_then(|s| { + if s.parent_model == "folder" { s.parent_id.as_deref() } else { None } + }); + let ancestry = ancestry_path(&folder_tree, parent_id); + let key = fallback_key("environment", &ancestry, name); + candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), key.clone(), key)); } for (i, v) in resources.folders.iter().enumerate() { - let ancestry = ancestry_path(&folder_tree, v.folder_id.as_deref()); - let key = fallback_key("folder", &ancestry, &v.name); - candidates.push((&v.id, plugin_key(source_ids.folders.get(i)), key.clone(), key)); + let source = original.folders.get(i); + let name = source.map(|s| s.name.as_str()).unwrap_or(v.name.as_str()); + let ancestry = + ancestry_path(&folder_tree, source.and_then(|s| s.folder_id.as_deref())); + let key = fallback_key("folder", &ancestry, name); + candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), key.clone(), key)); } for (i, v) in resources.http_requests.iter().enumerate() { - let ancestry = ancestry_path(&folder_tree, v.folder_id.as_deref()); - let method = if v.method.is_empty() { "GET" } else { v.method.as_str() }; - let route = format!("{method} {}", v.url); - let (by_name, by_content) = derived_pair("http_request", &ancestry, &v.name, &route); - candidates.push((&v.id, plugin_key(source_ids.http_requests.get(i)), by_name, by_content)); + let source = original.http_requests.get(i); + let s = source.unwrap_or(v); + let ancestry = ancestry_path(&folder_tree, s.folder_id.as_deref()); + let method = if s.method.is_empty() { "GET" } else { s.method.as_str() }; + let route = format!("{method} {}", s.url); + let (by_name, by_content) = derived_pair("http_request", &ancestry, &s.name, &route); + candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), by_name, by_content)); } for (i, v) in resources.grpc_requests.iter().enumerate() { - let ancestry = ancestry_path(&folder_tree, v.folder_id.as_deref()); - let service = v.service.clone().unwrap_or_default(); - let method = v.method.clone().unwrap_or_default(); - let route = format!("{} {service}/{method}", v.url); - let (by_name, by_content) = derived_pair("grpc_request", &ancestry, &v.name, &route); - candidates.push((&v.id, plugin_key(source_ids.grpc_requests.get(i)), by_name, by_content)); + let source = original.grpc_requests.get(i); + let s = source.unwrap_or(v); + let ancestry = ancestry_path(&folder_tree, s.folder_id.as_deref()); + let service = s.service.clone().unwrap_or_default(); + let method = s.method.clone().unwrap_or_default(); + let route = format!("{} {service}/{method}", s.url); + let (by_name, by_content) = derived_pair("grpc_request", &ancestry, &s.name, &route); + candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), by_name, by_content)); } for (i, v) in resources.websocket_requests.iter().enumerate() { - let ancestry = ancestry_path(&folder_tree, v.folder_id.as_deref()); - let (by_name, by_content) = derived_pair("websocket_request", &ancestry, &v.name, &v.url); - candidates.push(( - &v.id, - plugin_key(source_ids.websocket_requests.get(i)), - by_name, - by_content, - )); + let source = original.websocket_requests.get(i); + let s = source.unwrap_or(v); + let ancestry = ancestry_path(&folder_tree, s.folder_id.as_deref()); + let (by_name, by_content) = derived_pair("websocket_request", &ancestry, &s.name, &s.url); + candidates.push((&v.id, plugin_key(source.map(|s| &s.id)), by_name, by_content)); } // A name shared by several resources identifies none of them, so every member of the group @@ -753,6 +1328,7 @@ mod tests { }, imported_resources(), None, + None, ) .expect("plan import"); @@ -855,6 +1431,7 @@ mod tests { ImportDestination::NewWorkspace, resources, None, + None, ) .expect("plan import"); @@ -921,6 +1498,7 @@ mod tests { }, resources, None, + None, ) .expect("plan import"); @@ -950,6 +1528,7 @@ mod tests { ImportDestination::NewWorkspace, imported_resources(), None, + Some(linked_origin()), ) .expect("plan import"); let workspace_id = plan.resources.workspaces[0].id.clone(); @@ -968,6 +1547,10 @@ mod tests { let db = query_manager.connect(); assert!(db.get_workspace(&workspace_id).is_err(), "workspace insert must roll back"); assert!(db.get_environment(&environment_id).is_err(), "environment must not exist"); + assert!( + db.list_import_sources(&workspace_id).expect("list import sources").is_empty(), + "import source must roll back" + ); } fn request_key<'a>(plan: &'a ImportPlan, name: &str) -> &'a str { @@ -992,6 +1575,7 @@ mod tests { ImportDestination::NewWorkspace, resources, source_keys, + None, ) .expect("plan import") } @@ -1105,4 +1689,491 @@ mod tests { assert!(!request_key(&plan, "Root Request").starts_with("fb:")); assert!(request_key(&plan, "Nested Request").starts_with("fb:")); } + + fn linked_origin() -> ImportOrigin { + ImportOrigin { origin: "/tmp/api.yaml".to_string(), label: "api.yaml".to_string() } + } + + fn importer_keys() -> BTreeMap { + BTreeMap::from([ + ("ev_source_base".to_string(), "env:base".to_string()), + ("fl_source".to_string(), "folder:src".to_string()), + ("rq_root".to_string(), "op:root".to_string()), + ("rq_nested".to_string(), "op:nested".to_string()), + ("rq_extra".to_string(), "op:extra".to_string()), + ]) + } + + fn first_import(query_manager: &QueryManager) -> BatchUpsertResult { + let plan = plan_import_resources( + query_manager, + "OpenAPI".to_string(), + ImportDestination::NewWorkspace, + imported_resources(), + Some(importer_keys()), + Some(linked_origin()), + ) + .expect("plan first import"); + commit_import_plan(query_manager, plan).expect("commit first import") + } + + fn replan( + query_manager: &QueryManager, + workspace_id: &str, + resources: ImportResources, + ) -> ImportPlan { + plan_import_resources( + query_manager, + "OpenAPI".to_string(), + ImportDestination::ExistingWorkspace { + workspace_id: workspace_id.to_string(), + folder_id: None, + }, + resources, + Some(importer_keys()), + Some(linked_origin()), + ) + .expect("plan re-import") + } + + fn item_by_name<'a>(plan: &'a ImportPlan, name: &str) -> &'a ImportPlanItem { + plan.items + .iter() + .find(|i| i.name == name) + .unwrap_or_else(|| panic!("no plan item named {name}: {:?}", plan.items)) + } + + #[test] + fn commit_records_the_linked_source_and_snapshots() { + let (query_manager, _blob_manager, _rx) = + yaak_models::init_in_memory().expect("initialize database"); + let committed = first_import(&query_manager); + let workspace_id = committed.workspaces[0].id.clone(); + + let source = { + let db = query_manager.connect(); + let source = db + .find_import_source(&workspace_id, "OpenAPI", "/tmp/api.yaml") + .expect("query import source") + .expect("import source recorded"); + assert_eq!(source.origin_label, "api.yaml"); + + let rows = db.list_import_source_resources(&source.id).expect("list resource rows"); + assert_eq!(rows.len(), 4, "one row per non-workspace resource: {rows:?}"); + for row in &rows { + let snapshot: Value = serde_json::from_str(&row.snapshot).expect("parse snapshot"); + let resource = ImportResourceType::from_str(&row.model_type) + .expect("row has a known resource type"); + let current = existing_model_json(&db, resource, &row.model_id) + .expect("query current model") + .expect("row target exists"); + assert_eq!(comparable(snapshot), comparable(current)); + } + source + }; + + // Re-importing the identical document is a no-op offer: everything unchanged, + // ancestry re-rooting does not count as a change, and the base environment it + // created stays the base environment. + let plan = replan(&query_manager, &workspace_id, imported_resources()); + assert!( + plan.items.iter().all(|i| i.action == ImportPlanAction::Unchanged), + "expected all unchanged: {:?}", + plan.items + ); + let base = plan + .resources + .environments + .iter() + .find(|e| e.name == "Global Variables") + .expect("base environment keeps its original name"); + assert_eq!(base.parent_model, "workspace"); + commit_import_plan(&query_manager, plan).expect("commit re-import"); + + let db = query_manager.connect(); + assert_eq!(db.list_http_requests(&workspace_id).expect("list requests").len(), 2); + assert_eq!(db.list_folders(&workspace_id).expect("list folders").len(), 1); + assert_eq!( + db.list_environments_ensure_base(&workspace_id).expect("list environments").len(), + 1 + ); + let rows = db.list_import_source_resources(&source.id).expect("list resource rows"); + assert_eq!(rows.len(), 4, "re-commit replaces rows instead of accumulating"); + } + + #[test] + fn re_import_merges_source_and_local_changes() { + let (query_manager, _blob_manager, _rx) = + yaak_models::init_in_memory().expect("initialize database"); + let committed = first_import(&query_manager); + let workspace_id = committed.workspaces[0].id.clone(); + let root_id = committed + .http_requests + .iter() + .find(|r| r.name == "Root Request") + .expect("root request") + .id + .clone(); + + { + let db = query_manager.connect(); + let nested = db + .list_http_requests(&workspace_id) + .expect("list requests") + .into_iter() + .find(|r| r.name == "Nested Request") + .expect("nested request"); + db.upsert_http_request( + &HttpRequest { + url: "https://example.com/nested-local".to_string(), + ..nested.clone() + }, + &UpdateSource::Background, + ) + .expect("edit nested request locally"); + } + + let mut resources = imported_resources(); + resources.http_requests[0].url = "https://example.com/root-v2".to_string(); + resources.http_requests.push(HttpRequest { + id: "rq_extra".to_string(), + model: "http_request".to_string(), + workspace_id: "wk_source".to_string(), + name: "Extra Request".to_string(), + method: "GET".to_string(), + url: "https://example.com/extra".to_string(), + ..Default::default() + }); + + let plan = replan(&query_manager, &workspace_id, resources); + let root = item_by_name(&plan, "Root Request"); + assert_eq!(root.action, ImportPlanAction::Update); + assert!(root.selected); + assert_eq!(root.model_id, root_id, "update targets the mapped model"); + let nested = item_by_name(&plan, "Nested Request"); + assert_eq!(nested.action, ImportPlanAction::KeepLocal); + assert!(!nested.selected); + let extra = item_by_name(&plan, "Extra Request"); + assert_eq!(extra.action, ImportPlanAction::Create); + assert!(extra.selected); + assert_eq!(item_by_name(&plan, "Imported Folder").action, ImportPlanAction::Unchanged); + + commit_import_plan(&query_manager, plan).expect("commit merge"); + + let db = query_manager.connect(); + let requests = db.list_http_requests(&workspace_id).expect("list requests"); + assert_eq!(requests.len(), 3); + assert_eq!( + requests.iter().find(|r| r.name == "Root Request").expect("root").url, + "https://example.com/root-v2" + ); + assert_eq!( + requests.iter().find(|r| r.name == "Nested Request").expect("nested").url, + "https://example.com/nested-local" + ); + } + + #[test] + fn rename_in_source_updates_the_same_model() { + let (query_manager, _blob_manager, _rx) = + yaak_models::init_in_memory().expect("initialize database"); + let committed = first_import(&query_manager); + let workspace_id = committed.workspaces[0].id.clone(); + let root_id = committed + .http_requests + .iter() + .find(|r| r.name == "Root Request") + .expect("root request") + .id + .clone(); + + let mut resources = imported_resources(); + resources.http_requests[0].name = "Root Request Renamed".to_string(); + let plan = replan(&query_manager, &workspace_id, resources); + let renamed = item_by_name(&plan, "Root Request Renamed"); + assert_eq!(renamed.action, ImportPlanAction::Update); + assert_eq!(renamed.model_id, root_id); + + commit_import_plan(&query_manager, plan).expect("commit rename"); + let requests = + query_manager.connect().list_http_requests(&workspace_id).expect("list requests"); + assert_eq!(requests.len(), 2, "rename must not duplicate"); + assert!(requests.iter().any(|r| r.id == root_id && r.name == "Root Request Renamed")); + } + + #[test] + fn keep_mine_conflicts_advance_and_are_not_offered_again() { + let (query_manager, _blob_manager, _rx) = + yaak_models::init_in_memory().expect("initialize database"); + let committed = first_import(&query_manager); + let workspace_id = committed.workspaces[0].id.clone(); + let root_id = committed + .http_requests + .iter() + .find(|r| r.name == "Root Request") + .expect("root request") + .id + .clone(); + + { + let db = query_manager.connect(); + let root = db.get_http_request(&root_id).expect("get root"); + db.upsert_http_request( + &HttpRequest { url: "https://example.com/root-local".to_string(), ..root }, + &UpdateSource::Background, + ) + .expect("edit root locally"); + } + + let mut resources = imported_resources(); + resources.http_requests[0].url = "https://example.com/root-v2".to_string(); + + let plan = replan(&query_manager, &workspace_id, resources.clone()); + let root = item_by_name(&plan, "Root Request"); + assert_eq!(root.action, ImportPlanAction::Conflict); + assert_eq!(root.resolution, Some(ImportConflictResolution::KeepMine)); + commit_import_plan(&query_manager, plan).expect("commit keep-mine"); + + assert_eq!( + query_manager.connect().get_http_request(&root_id).expect("get root").url, + "https://example.com/root-local", + "keep-mine must not overwrite the local edit" + ); + + // The decision was recorded, so the same source version stops nagging. + let plan = replan(&query_manager, &workspace_id, resources); + assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::KeepLocal); + + // A newer source version conflicts again; taking it overwrites the local edit. + let mut resources = imported_resources(); + resources.http_requests[0].url = "https://example.com/root-v3".to_string(); + let mut plan = replan(&query_manager, &workspace_id, resources.clone()); + assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::Conflict); + for item in plan.items.iter_mut() { + if item.action == ImportPlanAction::Conflict { + item.resolution = Some(ImportConflictResolution::TakeSource); + } + } + commit_import_plan(&query_manager, plan).expect("commit take-source"); + assert_eq!( + query_manager.connect().get_http_request(&root_id).expect("get root").url, + "https://example.com/root-v3" + ); + let plan = replan(&query_manager, &workspace_id, resources); + assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::Unchanged); + } + + #[test] + fn deselected_update_is_offered_again_next_import() { + let (query_manager, _blob_manager, _rx) = + yaak_models::init_in_memory().expect("initialize database"); + let committed = first_import(&query_manager); + let workspace_id = committed.workspaces[0].id.clone(); + + let mut resources = imported_resources(); + resources.http_requests[0].url = "https://example.com/root-v2".to_string(); + + let mut plan = replan(&query_manager, &workspace_id, resources.clone()); + for item in plan.items.iter_mut() { + if item.action == ImportPlanAction::Update { + item.selected = false; + } + } + commit_import_plan(&query_manager, plan).expect("commit with deselected update"); + + let db = query_manager.connect(); + let root = db + .list_http_requests(&workspace_id) + .expect("list requests") + .into_iter() + .find(|r| r.name == "Root Request") + .expect("root request"); + assert_eq!(root.url, "https://example.com/root", "deselected update must not apply"); + drop(db); + + let plan = replan(&query_manager, &workspace_id, resources); + assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::Update); + } + + #[test] + fn source_removals_are_deselected_offers_until_applied() { + let (query_manager, _blob_manager, _rx) = + yaak_models::init_in_memory().expect("initialize database"); + let committed = first_import(&query_manager); + let workspace_id = committed.workspaces[0].id.clone(); + + let mut resources = imported_resources(); + resources.http_requests.remove(1); + + let plan = replan(&query_manager, &workspace_id, resources.clone()); + let removal = item_by_name(&plan, "Nested Request"); + assert_eq!(removal.action, ImportPlanAction::Delete); + assert!(!removal.selected, "deletions default to deselected"); + commit_import_plan(&query_manager, plan).expect("commit with default selection"); + assert_eq!( + query_manager.connect().list_http_requests(&workspace_id).expect("list").len(), + 2, + "deselected deletion must not delete" + ); + + let mut plan = replan(&query_manager, &workspace_id, resources.clone()); + assert_eq!(item_by_name(&plan, "Nested Request").action, ImportPlanAction::Delete); + for item in plan.items.iter_mut() { + if item.action == ImportPlanAction::Delete { + item.selected = true; + } + } + commit_import_plan(&query_manager, plan).expect("commit with deletion"); + assert_eq!( + query_manager.connect().list_http_requests(&workspace_id).expect("list").len(), + 1 + ); + + let plan = replan(&query_manager, &workspace_id, resources); + assert!( + plan.items.iter().all(|i| i.action != ImportPlanAction::Delete), + "applied deletion must not be offered again: {:?}", + plan.items + ); + } + + #[test] + fn locally_deleted_mapped_model_plans_as_create() { + let (query_manager, _blob_manager, _rx) = + yaak_models::init_in_memory().expect("initialize database"); + let committed = first_import(&query_manager); + let workspace_id = committed.workspaces[0].id.clone(); + let root_id = committed + .http_requests + .iter() + .find(|r| r.name == "Root Request") + .expect("root request") + .id + .clone(); + + query_manager + .connect() + .delete_http_request_by_id(&root_id, &UpdateSource::Background) + .expect("delete root locally"); + + let plan = replan(&query_manager, &workspace_id, imported_resources()); + let root = item_by_name(&plan, "Root Request"); + assert_eq!(root.action, ImportPlanAction::Create, "no silent resurrection as an update"); + assert_ne!(root.model_id, root_id); + + commit_import_plan(&query_manager, plan).expect("commit re-create"); + assert_eq!( + query_manager.connect().list_http_requests(&workspace_id).expect("list").len(), + 2 + ); + } + + #[test] + fn deselecting_a_new_folder_skips_its_descendants() { + let (query_manager, _blob_manager, _rx) = + yaak_models::init_in_memory().expect("initialize database"); + let mut plan = plan_import_resources( + &query_manager, + "OpenAPI".to_string(), + ImportDestination::NewWorkspace, + imported_resources(), + Some(importer_keys()), + Some(linked_origin()), + ) + .expect("plan import"); + + assert_eq!(plan.items.len(), 4, "one create item per non-workspace resource"); + assert!(plan.items.iter().all(|i| i.action == ImportPlanAction::Create && i.selected)); + + for item in plan.items.iter_mut() { + if item.model == ImportResourceType::Folder { + item.selected = false; + } + } + let committed = commit_import_plan(&query_manager, plan).expect("commit import"); + let workspace_id = committed.workspaces[0].id.clone(); + + let db = query_manager.connect(); + assert!(db.list_folders(&workspace_id).expect("list folders").is_empty()); + let requests = db.list_http_requests(&workspace_id).expect("list requests"); + assert_eq!(requests.len(), 1, "requests inside the skipped folder are skipped too"); + assert_eq!(requests[0].name, "Root Request"); + + let source = db + .find_import_source(&workspace_id, "OpenAPI", "/tmp/api.yaml") + .expect("query import source") + .expect("import source recorded"); + let rows = db.list_import_source_resources(&source.id).expect("list resource rows"); + assert_eq!(rows.len(), 2, "skipped resources must not advance snapshots: {rows:?}"); + } + + +#[test] +fn desktop_style_json_roundtrip_records_source() { + let (query_manager, _blob_manager, _rx) = + yaak_models::init_in_memory().expect("initialize database"); + let plan = plan_import_resources( + &query_manager, + "OpenAPI".to_string(), + ImportDestination::NewWorkspace, + imported_resources(), + None, + Some(linked_origin()), + ) + .expect("plan import"); + let json = serde_json::to_string(&plan).expect("serialize plan"); + let plan: ImportPlan = serde_json::from_str(&json).expect("deserialize plan"); + let committed = commit_import_plan(&query_manager, plan).expect("commit"); + let workspace_id = committed.workspaces[0].id.clone(); + let source = query_manager + .connect() + .find_import_source(&workspace_id, "OpenAPI", "/tmp/api.yaml") + .expect("query") + .expect("source recorded after JSON round-trip"); + assert_eq!(source.origin_label, "api.yaml"); +} + +#[test] +fn selected_keep_local_reverts_the_local_edit() { + let (query_manager, _blob_manager, _rx) = + yaak_models::init_in_memory().expect("initialize database"); + let committed = first_import(&query_manager); + let workspace_id = committed.workspaces[0].id.clone(); + let root_id = committed + .http_requests + .iter() + .find(|r| r.name == "Root Request") + .expect("root request") + .id + .clone(); + + { + let db = query_manager.connect(); + let root = db.get_http_request(&root_id).expect("get root"); + db.upsert_http_request( + &HttpRequest { url: "https://example.com/root-local".to_string(), ..root }, + &UpdateSource::Background, + ) + .expect("edit root locally"); + } + + let mut plan = replan(&query_manager, &workspace_id, imported_resources()); + let root = item_by_name(&plan, "Root Request"); + assert_eq!(root.action, ImportPlanAction::KeepLocal); + assert!(!root.selected, "keep-local defaults to keeping the local edit"); + for item in plan.items.iter_mut() { + if item.action == ImportPlanAction::KeepLocal { + item.selected = true; + } + } + commit_import_plan(&query_manager, plan).expect("commit revert"); + + assert_eq!( + query_manager.connect().get_http_request(&root_id).expect("get root").url, + "https://example.com/root", + "selected keep-local must revert to the source version" + ); + let plan = replan(&query_manager, &workspace_id, imported_resources()); + assert_eq!(item_by_name(&plan, "Root Request").action, ImportPlanAction::Unchanged); +} } diff --git a/packages/platform/src/web/commands.ts b/packages/platform/src/web/commands.ts index df3a209e..a8d75094 100644 --- a/packages/platform/src/web/commands.ts +++ b/packages/platform/src/web/commands.ts @@ -268,6 +268,8 @@ const DECLINED: Partial