feat: Remove Tag workflow action to complement Add Tag
Removes a tag (by name, matching the tag's full path value) from the matched assets via DELETE /tags/:id/assets. The tag is looked up without creating it — removing a tag that doesn't exist is a no-op rather than an error, so add/remove pairs are safe in the same workflow. Registered in the node palette, action node, run-detail view, and action config (same tag-name field as Add Tag). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f0d8a88c27
commit
95ede2efd7
@@ -27,6 +27,7 @@ const actionItems: PaletteItem[] = [
|
||||
{ type: "action", subType: "unfavorite", label: "Unfavorite", icon: HeartOff, color: "text-purple-500" },
|
||||
{ type: "action", subType: "archive", label: "Archive", icon: Archive, color: "text-purple-500" },
|
||||
{ type: "action", subType: "tag", label: "Add Tag", icon: Tag, color: "text-purple-500" },
|
||||
{ type: "action", subType: "remove_tag", label: "Remove Tag", icon: Tag, color: "text-purple-500" },
|
||||
];
|
||||
|
||||
function PaletteGroup({ title, items }: { title: string; items: PaletteItem[] }) {
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function ActionConfig({ subType, config, onChange }: ActionConfig
|
||||
);
|
||||
}
|
||||
|
||||
if (subType === "tag") {
|
||||
if (subType === "tag" || subType === "remove_tag") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Tag Name</Label>
|
||||
@@ -125,6 +125,9 @@ export default function ActionConfig({ subType, config, onChange }: ActionConfig
|
||||
value={config.tagName || ""}
|
||||
onChange={(e) => onChange({ ...config, tagName: e.target.value })}
|
||||
/>
|
||||
{subType === "remove_tag" && (
|
||||
<p className="text-[10px] text-muted-foreground">Removes this tag from matched assets. If the tag doesn't exist, nothing happens.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const actionIcons: Record<string, any> = {
|
||||
unfavorite: HeartOff,
|
||||
archive: Archive,
|
||||
tag: Tag,
|
||||
remove_tag: Tag,
|
||||
};
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
@@ -19,6 +20,7 @@ const actionLabels: Record<string, string> = {
|
||||
unfavorite: "Unfavorite",
|
||||
archive: "Archive",
|
||||
tag: "Add Tag",
|
||||
remove_tag: "Remove Tag",
|
||||
};
|
||||
|
||||
export default function ActionNode({ data, selected }: NodeProps) {
|
||||
@@ -42,7 +44,7 @@ export default function ActionNode({ data, selected }: NodeProps) {
|
||||
{(subType === "add_to_album" || subType === "remove_from_album") && config.albumName && (
|
||||
<p className="text-[10px] text-muted-foreground truncate max-w-[160px]">{config.albumName}</p>
|
||||
)}
|
||||
{subType === "tag" && config.tagName && (
|
||||
{(subType === "tag" || subType === "remove_tag") && config.tagName && (
|
||||
<p className="text-[10px] text-muted-foreground truncate max-w-[160px]">{config.tagName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -180,6 +180,25 @@ export async function executeAction(
|
||||
}
|
||||
}
|
||||
|
||||
case "remove_tag": {
|
||||
if (!config.tagName) throw new Error("Tag name is required");
|
||||
try {
|
||||
// Look up the tag by value (full path for nested tags) without creating it —
|
||||
// removing a tag that doesn't exist is a no-op, not an error.
|
||||
const result = await db.execute(
|
||||
sql`SELECT id FROM "tag" WHERE "userId" = ${user.id} AND "value" = ${config.tagName} LIMIT 1`
|
||||
);
|
||||
const tagId = result.rows?.[0]?.id as string | undefined;
|
||||
if (!tagId) {
|
||||
return { action: "remove_tag", assetsProcessed: 0 };
|
||||
}
|
||||
await immichFetch(`/tags/${tagId}/assets`, "DELETE", { ids: assetIds }, user);
|
||||
return { action: "remove_tag", assetsProcessed: assetIds.length };
|
||||
} catch (e: any) {
|
||||
return { action: "remove_tag", assetsProcessed: 0, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return { action: subType, assetsProcessed: 0, error: `Unknown action: ${subType}` };
|
||||
}
|
||||
|
||||
@@ -135,11 +135,11 @@ function AnnotatedActionNode({ data }: NodeProps) {
|
||||
const step = data.runStep as any;
|
||||
const icons: Record<string, any> = {
|
||||
create_album: FolderPlus, add_to_album: FolderInput, remove_from_album: FolderMinus,
|
||||
favorite: Heart, unfavorite: HeartOff, archive: Archive, tag: Tag,
|
||||
favorite: Heart, unfavorite: HeartOff, archive: Archive, tag: Tag, remove_tag: Tag,
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
create_album: "Create Album", add_to_album: "Add to Album", remove_from_album: "Remove from Album",
|
||||
favorite: "Favorite", unfavorite: "Unfavorite", archive: "Archive", tag: "Add Tag",
|
||||
favorite: "Favorite", unfavorite: "Unfavorite", archive: "Archive", tag: "Add Tag", remove_tag: "Remove Tag",
|
||||
};
|
||||
const Icon = icons[subType] || FolderPlus;
|
||||
const isDryRun = step?.detail?.includes("DRY RUN");
|
||||
|
||||
Reference in New Issue
Block a user