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:
jonvdveen
2026-07-02 22:54:43 -06:00
co-authored by Claude Fable 5
parent f0d8a88c27
commit 95ede2efd7
5 changed files with 29 additions and 4 deletions
+1
View File
@@ -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&apos;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>
+19
View File
@@ -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}` };
}
+2 -2
View File
@@ -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");