[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
325
domain/snippetTransfer.ts
Normal file
325
domain/snippetTransfer.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import type { Snippet } from "./models";
|
||||
import { getSnippetPackageAncestors } from "./snippetPackage.ts";
|
||||
import { normalizeVaultOrder } from "./vaultOrder";
|
||||
import { normalizeGroupTargetPaths } from "./hostGroupPathMutations";
|
||||
|
||||
export const SNIPPET_EXPORT_KIND = "netcatty.snippets" as const;
|
||||
export const SNIPPET_EXPORT_VERSION = 2 as const;
|
||||
export const SNIPPET_EXPORT_VERSION_LEGACY = 1 as const;
|
||||
|
||||
export type SnippetImportConflictAction = "skip" | "overwrite";
|
||||
|
||||
export type SnippetExportItem = {
|
||||
label: string;
|
||||
command: string;
|
||||
tags?: string[];
|
||||
package?: string;
|
||||
targetGroups?: string[];
|
||||
shortkey?: string;
|
||||
noAutoRun?: boolean;
|
||||
multiLineRunMode?: Snippet["multiLineRunMode"];
|
||||
kind?: "snippet" | "script";
|
||||
language?: "javascript" | "python";
|
||||
description?: string;
|
||||
trigger?: "manual" | "onConnect" | "onOutput";
|
||||
triggerPattern?: string;
|
||||
};
|
||||
|
||||
export type SnippetExportPayload = {
|
||||
kind: typeof SNIPPET_EXPORT_KIND;
|
||||
version: typeof SNIPPET_EXPORT_VERSION | typeof SNIPPET_EXPORT_VERSION_LEGACY;
|
||||
exportedAt: string;
|
||||
snippetPackages: string[];
|
||||
snippets: SnippetExportItem[];
|
||||
};
|
||||
|
||||
export type SnippetImportStats = {
|
||||
imported: number;
|
||||
overwritten: number;
|
||||
skipped: number;
|
||||
conflicts: number;
|
||||
};
|
||||
|
||||
export type SnippetImportMergeResult = {
|
||||
snippets: Snippet[];
|
||||
snippetPackages: string[];
|
||||
stats: SnippetImportStats;
|
||||
};
|
||||
|
||||
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const uniqueStrings = (values: unknown[]): string[] => {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
if (typeof value !== "string") continue;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
result.push(trimmed);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const collectSnippetPackagePaths = (
|
||||
snippets: Pick<SnippetExportItem, "package">[],
|
||||
snippetPackages: string[] = [],
|
||||
): string[] => {
|
||||
const referenced = new Set<string>();
|
||||
snippets.forEach((snippet) => {
|
||||
const packagePath = snippet.package?.trim();
|
||||
if (!packagePath) return;
|
||||
getSnippetPackageAncestors(packagePath).forEach((path) => referenced.add(path));
|
||||
});
|
||||
|
||||
const ordered = uniqueStrings([
|
||||
...snippetPackages.filter((path) => referenced.has(path)),
|
||||
...Array.from(referenced),
|
||||
]);
|
||||
return ordered;
|
||||
};
|
||||
|
||||
const mergeSnippetPackagePaths = (...groups: string[][]): string[] => {
|
||||
const paths: string[] = [];
|
||||
groups.flat().forEach((path) => {
|
||||
getSnippetPackageAncestors(path).forEach((ancestor) => paths.push(ancestor));
|
||||
});
|
||||
return uniqueStrings(paths);
|
||||
};
|
||||
|
||||
const toExportItem = (snippet: Snippet): SnippetExportItem => ({
|
||||
label: snippet.label,
|
||||
command: snippet.command,
|
||||
tags: Array.isArray(snippet.tags) ? [...snippet.tags] : [],
|
||||
package: snippet.package || "",
|
||||
...(snippet.targetGroups !== undefined ? { targetGroups: [...snippet.targetGroups] } : {}),
|
||||
...(snippet.shortkey ? { shortkey: snippet.shortkey } : {}),
|
||||
...(snippet.noAutoRun ? { noAutoRun: snippet.noAutoRun } : {}),
|
||||
...(snippet.multiLineRunMode ? { multiLineRunMode: snippet.multiLineRunMode } : {}),
|
||||
...(snippet.kind ? { kind: snippet.kind } : {}),
|
||||
...(snippet.language ? { language: snippet.language } : {}),
|
||||
...(snippet.description ? { description: snippet.description } : {}),
|
||||
...(snippet.trigger ? { trigger: snippet.trigger } : {}),
|
||||
...(snippet.triggerPattern ? { triggerPattern: snippet.triggerPattern } : {}),
|
||||
});
|
||||
|
||||
export const buildSnippetExportPayload = ({
|
||||
snippets,
|
||||
snippetPackages,
|
||||
exportedAt = new Date().toISOString(),
|
||||
}: {
|
||||
snippets: Snippet[];
|
||||
snippetPackages: string[];
|
||||
exportedAt?: string;
|
||||
}): SnippetExportPayload => {
|
||||
const exportItems = snippets.map(toExportItem);
|
||||
return {
|
||||
kind: SNIPPET_EXPORT_KIND,
|
||||
version: SNIPPET_EXPORT_VERSION,
|
||||
exportedAt,
|
||||
snippetPackages: collectSnippetPackagePaths(exportItems, snippetPackages),
|
||||
snippets: exportItems,
|
||||
};
|
||||
};
|
||||
|
||||
const fallbackLabel = (command: string): string => {
|
||||
const firstLine = command.split(/\r?\n/).find((line) => line.trim());
|
||||
return firstLine?.trim().slice(0, 80) || "Imported snippet";
|
||||
};
|
||||
|
||||
const sanitizeImportItem = (value: unknown): SnippetExportItem | null => {
|
||||
if (!isObjectRecord(value) || typeof value.command !== "string") return null;
|
||||
if (!value.command.trim()) return null;
|
||||
const label = typeof value.label === "string" && value.label.trim()
|
||||
? value.label.trim()
|
||||
: fallbackLabel(value.command);
|
||||
return {
|
||||
label,
|
||||
command: value.command,
|
||||
tags: Array.isArray(value.tags)
|
||||
? uniqueStrings(value.tags)
|
||||
: [],
|
||||
package: typeof value.package === "string" ? value.package.trim() : "",
|
||||
targetGroups: Array.isArray(value.targetGroups)
|
||||
? normalizeGroupTargetPaths(value.targetGroups.filter(
|
||||
(entry): entry is string => typeof entry === "string",
|
||||
))
|
||||
: undefined,
|
||||
shortkey: typeof value.shortkey === "string" && value.shortkey.trim()
|
||||
? value.shortkey.trim()
|
||||
: undefined,
|
||||
noAutoRun: value.noAutoRun === true ? true : undefined,
|
||||
multiLineRunMode: value.multiLineRunMode === "lineDelay" || value.multiLineRunMode === "paste"
|
||||
? value.multiLineRunMode
|
||||
: undefined,
|
||||
kind: value.kind === "script" ? "script" : undefined,
|
||||
language: value.language === "javascript" || value.language === "python"
|
||||
? value.language
|
||||
: undefined,
|
||||
description: typeof value.description === "string" && value.description.trim()
|
||||
? value.description.trim()
|
||||
: undefined,
|
||||
trigger: value.trigger === "onConnect" || value.trigger === "onOutput"
|
||||
? value.trigger
|
||||
: undefined,
|
||||
triggerPattern: typeof value.triggerPattern === "string" && value.triggerPattern.trim()
|
||||
? value.triggerPattern.trim()
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const parseSnippetImportObject = (parsed: Record<string, unknown>): SnippetExportPayload => {
|
||||
const version = parsed.version;
|
||||
if (parsed.kind !== SNIPPET_EXPORT_KIND) {
|
||||
throw new Error("Unsupported snippet import file.");
|
||||
}
|
||||
if (version !== SNIPPET_EXPORT_VERSION && version !== SNIPPET_EXPORT_VERSION_LEGACY) {
|
||||
throw new Error("Unsupported snippet import file.");
|
||||
}
|
||||
if (!Array.isArray(parsed.snippets)) {
|
||||
throw new Error("Snippet import file has no snippets.");
|
||||
}
|
||||
|
||||
const snippets = parsed.snippets
|
||||
.map(sanitizeImportItem)
|
||||
.filter((item): item is SnippetExportItem => Boolean(item));
|
||||
|
||||
return {
|
||||
kind: SNIPPET_EXPORT_KIND,
|
||||
version: version === SNIPPET_EXPORT_VERSION ? SNIPPET_EXPORT_VERSION : SNIPPET_EXPORT_VERSION_LEGACY,
|
||||
exportedAt: typeof parsed.exportedAt === "string" ? parsed.exportedAt : "",
|
||||
snippetPackages: collectSnippetPackagePaths(
|
||||
snippets,
|
||||
Array.isArray(parsed.snippetPackages) ? uniqueStrings(parsed.snippetPackages) : [],
|
||||
),
|
||||
snippets,
|
||||
};
|
||||
};
|
||||
|
||||
export const combineSnippetImportPayloads = (payloads: SnippetExportPayload[]): SnippetExportPayload => {
|
||||
const snippets = payloads.flatMap((payload) => payload.snippets);
|
||||
const snippetPackages = payloads.flatMap((payload) => payload.snippetPackages);
|
||||
return {
|
||||
kind: SNIPPET_EXPORT_KIND,
|
||||
version: SNIPPET_EXPORT_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
snippetPackages: collectSnippetPackagePaths(snippets, snippetPackages),
|
||||
snippets,
|
||||
};
|
||||
};
|
||||
|
||||
export const parseSnippetImportPayload = (jsonString: string): SnippetExportPayload => {
|
||||
const parsed = JSON.parse(jsonString) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
const nestedPayloads = parsed
|
||||
.filter(isObjectRecord)
|
||||
.filter((item) => Array.isArray(item.snippets))
|
||||
.map(parseSnippetImportObject);
|
||||
if (nestedPayloads.length > 0 && nestedPayloads.length === parsed.length) {
|
||||
return combineSnippetImportPayloads(nestedPayloads);
|
||||
}
|
||||
|
||||
const snippets = parsed
|
||||
.map(sanitizeImportItem)
|
||||
.filter((item): item is SnippetExportItem => Boolean(item));
|
||||
return {
|
||||
kind: SNIPPET_EXPORT_KIND,
|
||||
version: SNIPPET_EXPORT_VERSION,
|
||||
exportedAt: "",
|
||||
snippetPackages: collectSnippetPackagePaths(snippets),
|
||||
snippets,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isObjectRecord(parsed)) {
|
||||
throw new Error("Invalid snippet import file.");
|
||||
}
|
||||
return parseSnippetImportObject(parsed);
|
||||
};
|
||||
|
||||
const toImportedSnippet = (item: SnippetExportItem, id: string, order?: number): Snippet => ({
|
||||
id,
|
||||
label: item.label,
|
||||
command: item.command,
|
||||
tags: item.tags || [],
|
||||
package: item.package || "",
|
||||
targets: [],
|
||||
...(item.targetGroups !== undefined ? { targetGroups: [...item.targetGroups] } : {}),
|
||||
...(item.shortkey ? { shortkey: item.shortkey } : {}),
|
||||
...(item.noAutoRun ? { noAutoRun: item.noAutoRun } : {}),
|
||||
...(item.multiLineRunMode ? { multiLineRunMode: item.multiLineRunMode } : {}),
|
||||
...(item.kind ? { kind: item.kind } : {}),
|
||||
...(item.language ? { language: item.language } : {}),
|
||||
...(item.description ? { description: item.description } : {}),
|
||||
...(item.trigger ? { trigger: item.trigger } : {}),
|
||||
...(item.triggerPattern ? { triggerPattern: item.triggerPattern } : {}),
|
||||
order,
|
||||
});
|
||||
|
||||
export const mergeSnippetImportPayload = ({
|
||||
existingSnippets,
|
||||
existingSnippetPackages,
|
||||
payload,
|
||||
conflictAction,
|
||||
createId,
|
||||
}: {
|
||||
existingSnippets: Snippet[];
|
||||
existingSnippetPackages: string[];
|
||||
payload: SnippetExportPayload;
|
||||
conflictAction: SnippetImportConflictAction;
|
||||
createId: () => string;
|
||||
}): SnippetImportMergeResult => {
|
||||
const next = [...existingSnippets];
|
||||
const commandToIndex = new Map<string, number>();
|
||||
next.forEach((snippet, index) => {
|
||||
if (!commandToIndex.has(snippet.command)) {
|
||||
commandToIndex.set(snippet.command, index);
|
||||
}
|
||||
});
|
||||
|
||||
const stats: SnippetImportStats = {
|
||||
imported: 0,
|
||||
overwritten: 0,
|
||||
skipped: 0,
|
||||
conflicts: 0,
|
||||
};
|
||||
|
||||
const getNextOrder = () => {
|
||||
const maxOrder = next.reduce((max, snippet, index) => {
|
||||
const order = typeof snippet.order === "number" ? snippet.order : (index + 1) * 1000;
|
||||
return Math.max(max, order);
|
||||
}, 0);
|
||||
return maxOrder + 1000;
|
||||
};
|
||||
|
||||
payload.snippets.forEach((item) => {
|
||||
const existingIndex = commandToIndex.get(item.command);
|
||||
if (existingIndex !== undefined) {
|
||||
stats.conflicts += 1;
|
||||
if (conflictAction === "skip") {
|
||||
stats.skipped += 1;
|
||||
return;
|
||||
}
|
||||
const existing = next[existingIndex];
|
||||
next[existingIndex] = toImportedSnippet(item, existing.id, existing.order);
|
||||
stats.overwritten += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const imported = toImportedSnippet(item, createId(), getNextOrder());
|
||||
commandToIndex.set(imported.command, next.length);
|
||||
next.push(imported);
|
||||
stats.imported += 1;
|
||||
});
|
||||
|
||||
return {
|
||||
snippets: normalizeVaultOrder(next),
|
||||
snippetPackages: mergeSnippetPackagePaths(
|
||||
existingSnippetPackages,
|
||||
payload.snippetPackages,
|
||||
next.map((snippet) => snippet.package || ""),
|
||||
),
|
||||
stats,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user