Action
Create Caption
Posted by David Degner,
Last update
about 16 hours ago
An action for photographers that reads a call sheet and writes a basic slug, title, caption, keywords from it.
The sister Call Sheet from Emails action can be found here: https://actions.getdrafts.com/a/2VF
Steps
-
script
const PRIMARY_MODEL = "models/gemini-3-flash-preview"; function extractJson(text) { if (!text) return ""; let t = String(text).trim(); t = t.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, ""); const first = t.indexOf("{"); const last = t.lastIndexOf("}"); if (first !== -1 && last !== -1 && last > first) return t.slice(first, last + 1); return t; } function oneLine(s) { return String(s || "").replace(/\s+/g, " ").trim(); } function stripTrailingPeriod(s) { return oneLine(s).replace(/[.。]\s*$/, ""); } function toCamelCaseWords(input) { let s = oneLine(input); s = s.replace(/[_|,]+/g, " "); s = s.replace(/[^A-Za-z0-9 ]+/g, " ").replace(/\s+/g, " ").trim(); if (!s) return ""; const words = s.split(" ").filter(Boolean).slice(0, 3); return words.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(""); } function sanitizeKeyword(k) { let s = String(k || "").trim(); // Prevent accidental Photo Mechanic tag injection s = s.replace(/[{}]/g, ""); // Remove leading hashtags s = s.replace(/^#+/, ""); // Collapse whitespace and trim trailing punctuation s = s.replace(/\s+/g, " ").replace(/[.,;:]+$/g, "").trim(); return s; } function normalizeKeywords(val) { let arr = []; if (Array.isArray(val)) { arr = val; } else if (typeof val === "string") { arr = val.split(/[,;\n|]+/); } const seen = {}; const out = []; for (let item of arr) { const s = sanitizeKeyword(item); if (!s) continue; const key = s.toLowerCase(); if (seen[key]) continue; seen[key] = true; out.push(s); if (out.length >= 20) break; } return out; } let f = () => { const draftContent = draft.content; if (!draftContent || draftContent.trim().length === 0) { app.displayAlert("Empty Draft", "There is no text to process."); return false; } // Save current draft state (does not change content) draft.update(); const systemInstruction = "You are generating metadata for an editorial photo shoot based on the shoot notes.\n" + "Return ONLY valid JSON (no code fences, no extra text) with exactly these keys:\n" + ' slug_words: string (1–3 words naming the subject; no punctuation)\n' + ' title: string (short shoot title)\n' + ' shortDescription: string (present-tense clause that fits after \": \" and before \" on {iptcmonthname} {day0}\")\n' + " longDescription: string (1–2 sentences expanding context; do NOT include date; do NOT include city/state; do NOT include any {tags}; do NOT include photographer credit; do NOT end with a period)\n" + " keywords: array of strings (6–12 useful keywords for photo ingest/search; include proper nouns if present in notes; avoid generic words like \"photo\"; do NOT include any {tags})\n" + "If details are missing, stay accurate and generic rather than guessing."; const combinedPrompt = systemInstruction + "\n\n--- Shoot Notes ---\n" + draftContent; let ai = new GoogleAI(); ai.apiVersion = "v1beta"; let raw = ""; try { raw = ai.quickPrompt(combinedPrompt, PRIMARY_MODEL); if (!raw || raw.trim().length === 0) throw new Error("Empty response received."); } catch (error) { app.displayAlert("AI Error", "Gemini failed: " + (ai.lastError || error)); return false; } let data; try { const jsonText = extractJson(raw); data = JSON.parse(jsonText); } catch (error) { app.displayAlert( "Parse Error", "Gemini returned something that wasn't valid JSON.\n\nResponse was:\n" + raw ); return false; } const slugWords = Array.isArray(data.slug_words) ? data.slug_words.join(" ") : data.slug_words; const slug = toCamelCaseWords(slugWords); const title = oneLine(data.title); const shortDesc = stripTrailingPeriod(data.shortDescription).replace(/[{}]/g, ""); const longDesc = stripTrailingPeriod(data.longDescription).replace(/[{}]/g, ""); if (!slug || !title || !shortDesc || !longDesc) { app.displayAlert( "Missing Fields", "Gemini did not return all required fields. Got:\n" + JSON.stringify(data, null, 2) ); return false; } const keywordsArr = normalizeKeywords(data.keywords); const keywordsLine = keywordsArr.length ? keywordsArr.join(", ") : ""; const caption = `{city:UC}, {state:UC} - {iptcmonthname:UC} {day0}: ` + `${shortDesc} on {iptcmonthname} {day0}, {iptcyear4} in {city}, {state}. ` + `${longDesc}. ( David Degner / www.DavidDegner.com )`; const output = `Slug: ${slug}\n` + `Title: ${title}\n` + `Caption: ${caption}\n` + `Keywords: ${keywordsLine}`; let newDraft = new Draft(); newDraft.content = output; newDraft.update(); editor.load(newDraft); return true; }; if (!f()) { context.cancel(); }
Options
-
After Success Default Notification Error Log Level Error
Items available in the Drafts Directory are uploaded by community members. Use appropriate caution reviewing downloaded items before use.